diff --git a/CONTEXT.md b/CONTEXT.md index 7ac7ce5..e7d726e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -60,7 +60,7 @@ User's Python app (this library) Other KBs in the network ``` -**Key runtime model**: The `KnowledgeBase` registers itself and its KIs with the SC, then enters a **long-polling loop** (`start_handling_loop()`). On each poll the SC either returns an incoming KI call to handle or asks to re-poll. The KB dispatches calls to registered handler functions, serializes the result, and replies to the SC. For outgoing interactions (`ask()` / `post()`), the KB sends a request to the SC which fans out through the network. +**Key runtime model**: The `KnowledgeBase` registers itself and its KIs with the SC, then enters a **concurrent long-polling loop** (`start_handling_loop()`). The loop runs multiple poll-dispatch cycles concurrently, bounded by a semaphore (`max_concurrent_handlers`, default 10). Each cycle acquires the semaphore, polls the SC, and on HANDLE spawns an `asyncio.Task` that runs the handler, posts the response, and releases the semaphore. Handler exceptions are caught — an empty binding set is posted back so the SC doesn't hang. On EXIT, the loop stops polling and awaits all in-flight handler tasks. For outgoing interactions (`ask()` / `post()`), the KB sends a request to the SC which fans out through the network. --- @@ -129,9 +129,10 @@ builder = KnowledgeBase.from_settings(settings) # settings: KnowledgeBaseSettin #### Lifecycle ```python -kb.connect() # Verify SC is reachable (raises KnowledgeEngineNotAvailableError if not) -kb.register() # Register KB + sync all KIs with the SC (re-registers if already registered) -kb.unregister() # Unregister KB from SC (KIs automatically unregistered) +await kb.connect() # Verify SC is reachable (raises KnowledgeEngineNotAvailableError if not) +await kb.register() # Register KB + sync all KIs with the SC (re-registers if already registered) +await kb.unregister() # Unregister KB from SC (KIs automatically unregistered) +await kb.close() # Close the underlying HTTP client and release resources ``` #### Registering KIs (decorator pattern) @@ -163,15 +164,16 @@ kb.post_ki(name="...", argument_graph_pattern="...", result_graph_pattern="...", #### Outgoing interactions ```python -result = kb.ask(binding_set, ki_name="...") # Returns BindingSet or list[BindingModel] -result = kb.post(binding_set, ki_name="...") # Returns result BindingSet or list[BindingModel] +result = await kb.ask(binding_set, ki_name="...") # Returns BindingSet or list[BindingModel] +result = await kb.post(binding_set, ki_name="...") # Returns result BindingSet or list[BindingModel] ``` #### Handling loop ```python -kb.start_handling_loop() # Blocks, handles incoming KIs forever -kb.start_handling_loop(loops=10) # Runs exactly 10 poll iterations (useful for testing) +await kb.start_handling_loop() # Concurrent dispatch, up to 10 in-flight +await kb.start_handling_loop(max_concurrent_handlers=5) # Limit to 5 concurrent handlers +await kb.start_handling_loop(loops=10) # Runs exactly 10 poll cycles (useful for testing) ``` --- @@ -199,8 +201,8 @@ def handler( **Behaviour:** - The framework inspects handler signatures at registration time and resolves `Depends` params at call time. -- Dependency factories are **sync-only** (async support is out of scope). -- Factories can themselves declare `Depends` parameters — nested/transitive resolution is supported. +- Dependency factories can be **sync (`def`) or async (`async def`)** — async factories are detected via `asyncio.iscoroutinefunction()` and awaited automatically; sync factories are called directly. +- Factories can themselves declare `Depends` parameters — nested/transitive resolution is supported, including mixed sync/async chains. - `cache=True` (default): factory called once per KI-call invocation; result shared across all uses. - `cache=False`: factory called fresh every time it is needed. @@ -444,5 +446,5 @@ These are excluded from linting (`ruff`) and are kept for historical reference o - **KI registry indexed by ID after registration**: `KnowledgeBase` maintains a secondary index (`_ki_registry_by_id`) populated once a KI is registered with the SC and assigned an ID. The handling loop dispatches by ID using this index. - **Handler introspection**: `KnowledgeInteractionContext.__post_init__` inspects handler signatures to auto-detect binding models, enabling transparent (de)serialization without manual type dispatch. Dispatch logic (validate → call → serialize for ANSWER/REACT; prepare_outgoing + parse_result for ASK/POST) lives in `KnowledgeInteractionContext`, not in `KnowledgeBase`. - **`KnowledgeBaseBuilder` wraps `KnowledgeBase`**: Settings-based KI registration belongs to `KnowledgeBaseBuilder`, not to `KnowledgeBase`. `KnowledgeBase.from_settings()` returns a builder; `builder.build()` returns the finished `KnowledgeBase`. `KnowledgeBase` itself has no knowledge of settings. ASK/POST KIs are auto-registered at `build()` time; ANSWER/REACT KIs require a handler attached via `builder.handler(name, func)` before `build()` is called. -- **Dependency injection via `Depends`**: `KnowledgeInteractionContext.dispatch()` calls `resolve_dependencies(handler)` before invoking the handler, passing resolved values as kwargs. The resolver (`src/dependency_injection.py`) uses `get_type_hints(include_extras=True)` to find `Annotated[T, Depends(factory)]` params, recursively resolves factory deps (transitive), and caches results per invocation when `cache=True`. `@wraps` on the decorator wrapper preserves `__annotations__`, so the resolver sees the original handler's hints. +- **Dependency injection via `Depends`**: `KnowledgeInteractionContext.dispatch()` calls `resolve_dependencies(handler)` before invoking the handler, passing resolved values as kwargs. The resolver (`src/dependency_injection.py`) uses `get_type_hints(include_extras=True)` to find `Annotated[T, Depends(factory)]` params, recursively resolves factory deps (transitive), and caches results per invocation when `cache=True`. Factories can be sync (`def`) or async (`async def`) — async factories are detected via `asyncio.iscoroutinefunction()` and awaited; sync factories are called directly. `@wraps` on the decorator wrapper preserves `__annotations__`, so the resolver sees the original handler's hints. - **`dependency_overrides`**: `KnowledgeBase.dependency_overrides` is a `dict[Callable, Callable]` (à la FastAPI) that substitutes dependency factories at resolution time. Overrides are checked transitively at every level and inherit the original `Depends(cache=...)` setting. The dict is passed explicitly from `KnowledgeBase.call()` → `dispatch()` → `resolve_dependencies()` to keep `KnowledgeInteractionContext` decoupled from `KnowledgeBase`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3467a7a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,102 @@ +# Contributing to Knowledge Mapper + +Thank you for your interest in contributing to the Knowledge Mapper! This guide covers setting up a development environment, running tests, building distributions, and code style. + +## Development Environment + +**Requirements:** + +- Python ≥ 3.13 +- [uv](https://docs.astral.sh/uv/) (package manager) +- Docker (for integration tests) + +**Setup:** + +```bash +# Clone the repository +git clone https://github.com/TNO/knowledge-mapper.git +cd knowledge-mapper + +# Install all dependencies (including dev) +uv sync +``` + +## Running Tests + +### Unit Tests + +Unit tests use `TestClient`, an in-memory fake Smart Connector. No external services needed. + +```bash +uv run pytest +``` + +### Integration Tests + +Integration tests require a live Knowledge Engine runtime. Use the Docker Compose file in `examples/`: + +```bash +# Start the Knowledge Engine +docker compose -f examples/compose.yaml up -d + +# Run tests +uv run pytest + +# Stop the Knowledge Engine +docker compose -f examples/compose.yaml down +``` + +## Code Style + +This project uses [ruff](https://docs.astral.sh/ruff/) for linting and formatting, configured in `pyproject.toml`. + +```bash +# Check for lint errors +uv run ruff check . + +# Auto-fix lint errors +uv run ruff check . --fix + +# Format code +uv run ruff format . +``` + +Key style settings: + +- Line length: 88 characters +- Target Python version: 3.13 +- Enabled rule sets: pycodestyle (E), Pyflakes (F), pyupgrade (UP), flake8-bugbear (B), flake8-simplify (SIM), isort (I) + +## Building a Distribution + +The project uses `setuptools` as its build backend, configured in `pyproject.toml`. + +```bash +# Build source and wheel distributions +uv build +``` + +The built distributions will be in the `dist/` directory. + +## Publishing a Release + +Releases are published to PyPI. Make sure the version number in `src/knowledge_mapper/__init__.py` and `pyproject.toml` are updated before publishing. + +```bash +# Build the distribution +uv build + +# Publish to PyPI (requires credentials) +uv publish +``` + +## Project Structure + +- `src/knowledge_mapper/` — main package source +- `tests/` — unit and integration tests +- `examples/` — runnable examples demonstrating features +- `docs/` — documentation assets (architecture diagrams) + +## Legacy Code + +The directories `src/knowledge_mapper/legacy/` and `examples/legacy/` contain pre-overhaul code kept for historical reference. **Do not modify these.** diff --git a/README.md b/README.md index ecf8e79..21ccf5f 100644 --- a/README.md +++ b/README.md @@ -1,208 +1,235 @@ # Knowledge Mapper -The Knowledge Mapper makes it easier to share your data in a knowledge base to the TNO Knowledge Engine (TKE) network. -It maps SQL, SPARQL, and Python classes to the format used by Smart Connectors in a TKE network. -This allows your knowledge base to be connected to the network using only a single configuration file. - -The mapper also helps if you use other programming and query languages. -It provides functions that allow you to easily share data to a TKE network. -The mapper takes care of connecting to the TKE network and helps in registering your knowledge base and knowledge interactions. - -## Current status - -The Knowledge mapper is currently undergoing heavy development and an overhaul of its architecture. Expect new versions to be incompatible with projects developed using older versions (<= 0.0.24). The old legacy code can be found at git tag `mapper-legacy`. - -## Where does it operate? - -Given the configuration of your mappings, it talks to the knowledge engine's REST API to register the relevant knowledge interactions. - -When there is an incoming request from the knowledge network (through the REST API), the mapper uses the configuration to retrieve the knowledge from the knowledge base. - -The following diagram shows where the Knowledge Mapper operates within the Knowledge Engine ecosystem. As an example, it shows how a SPARQL data source can be connected with a simple configuration file and a single command: +The Knowledge Mapper is a Python SDK for connecting your applications to the [TNO Knowledge Engine (TKE)](https://docs.knowledge-engine.eu/) network. Define knowledge interactions with decorators, use typed binding models, and let the SDK handle registration, polling, and data exchange with the network. ![architecture diagram](./docs/img/architecture.png) -## Getting Started with Examples +## Quick Start + +```bash +pip install knowledge_mapper +``` -The easiest way to learn the Knowledge Mapper is by exploring the [examples](./examples/README.md). They demonstrate key features like creating knowledge bases, defining knowledge interactions, using binding models, dependency injection, and testing. Each example includes inline comments and can be run locally with a Knowledge Engine instance. See the [examples README](./examples/README.md) for setup instructions and an overview of all available examples. +```python +import asyncio +from knowledge_mapper import KnowledgeBase + +kb = KnowledgeBase( + id="http://example.org/my-kb", + name="my-kb", + description="A simple example KB.", + ke_url="http://localhost:8280/rest", +) + +@kb.answer_ki( + name="greeting", + graph_pattern='?s ?message .', +) +def handle(binding_set, info): + return binding_set + +async def main(): + await kb.connect() + await kb.register() + await kb.start_handling_loop() + +asyncio.run(main()) +``` -## How do I use it? +## Installation -1. Install `knowledge_mapper` in a Python environment with `pip`: +Install from PyPI: ```bash pip install knowledge_mapper ``` -2. Make a configuration file (e.g. `config.jsonc`) that defines the knowledge interactions and mappings from your data source. (See [the examples linked here](./examples/README.md).) - -3. Start your Knowledge Mapper: +For local development (from the repository root): ```bash -python -m knowledge_mapper config.jsonc +pip install -e . ``` -## Configuration +## Examples -The minimal configuration looks like this: -```jsonc -{ - // The endpoint where a knowledge engine is available. - "knowledge_engine_endpoint": "http://localhost:8280/rest", - "knowledge_base": { - // An URL representing the identity of this knowledge base - "id": "https://example.org/a-knowledge-base", - // A name for this knowledge base - "name": "Some knowledge base", - // A description for this knowledge base - "description": "This is just an example." - }, +The [`examples/`](./examples/) directory contains runnable examples covering all major features. Each example has inline comments explaining the code. - "knowledge_interactions": [ - // Several knowledge interaction definitions can be placed here. - ] -} -``` +| Example | Description | +|---------|-------------| +| [01-basic.py](./examples/01-basic.py) | Minimal knowledge base with a simple ANSWER knowledge interaction | +| [02-binding_models.py](./examples/02-binding_models.py) | Pydantic-style binding models for type-safe bindings | +| [03-ask_interaction.py](./examples/03-ask_interaction.py) | Outgoing ASK knowledge interactions with typed binding models | +| [04-post_measurement.py](./examples/04-post_measurement.py) | POST interactions for pushing measurements or data to the network | +| [05-custom-settings/](./examples/05-custom-settings/) | Custom settings subclass with YAML configuration | +| [06-dependency_injection.py](./examples/06-dependency_injection.py) | Dependency injection for handlers (database connections, configs, etc.) | +| [07-testing/](./examples/07-testing/) | Writing tests with the in-memory `TestClient` | +| [08-async_handlers.py](./examples/08-async_handlers.py) | Async and sync REACT handlers side by side | +| [09-sparql-store/](./examples/09-sparql-store/) | Connecting a SPARQL store as a knowledge base | -In the `knowledge_interaction` property, you can add the definitions of your knowledge interactions, including their graph patterns. +See the [examples README](./examples/README.md) for prerequisites and setup instructions. -Let's add a knowledge interaction that expresses that we have knowledge available about trees: +## API Reference -```jsonc -{ - // ... - "knowledge_interactions": [ - { - // The type of this knowledge interaction. If we have knowledge - // available that is requestable, the type should be "answer". - "type": "answer", - // The graph pattern that expresses the 'shape' of our knowledge. - "pattern": "?tree ?height . ?tree ?name ." - }, - ] -} -``` +### `KnowledgeBase` -However, at this point the knowledge mapper will not know where to get this knowledge! So let's add this to the configuration too. Let's assume we have the data about the trees in a SQL database. +The main entry point. Create one, register knowledge interactions with decorators, then connect and start handling. -```jsonc -{ - // ... +```python +from knowledge_mapper import KnowledgeBase - // Connection details for the SQL database - "sql_host": "sql-db", - "sql_port": 3306, - "sql_database": "treedb", - "sql_user": "user", - "sql_password": "password", +kb = KnowledgeBase( + id="http://example.org/my-kb", + name="my-kb", + description="...", + ke_url="http://localhost:8280/rest", +) +``` - "knowledge_interactions": [ - { - // ... +**Lifecycle:** - // SQL query to query data to be used to fill bindings for the graph pattern. - // Note that the column names in the result set "tree" and "height" must - // correspond with the variable names in the graph pattern. - "sql_query": "SELECT id AS tree, height, name FROM trees" - }, - ] -} +```python +await kb.connect() # Verify the Smart Connector is reachable +await kb.register() # Register KB and all KIs with the Smart Connector +await kb.start_handling_loop() # Start concurrent long-polling for incoming requests +await kb.unregister() # Unregister from the Smart Connector +await kb.close() # Close the HTTP client ``` -Notice the similarity between this SQL-query and the graph pattern defined in the knowledge interaction above. -The knowledge mapper maps the variables in the SQL results to graph pattern variables in the knowledge interaction. -For example, SQL variable **height** becomes **?height** in the graph pattern (i.e., objects for predicate ). +**Registering Knowledge Interactions (decorators):** -With this configuration (see [here](examples/sql-mapper/config.jsonc) for the entire file) we can start the Knowledge Mapper: +```python +# ANSWER — respond to incoming queries +@kb.answer_ki(name="...", graph_pattern="...", prefixes={...}) +def my_handler(binding_set, info): + return binding_set +# REACT — handle incoming POST data +@kb.react_ki(name="...", argument_graph_pattern="...", result_graph_pattern="...", prefixes={...}) +def my_react_handler(binding_set, info): + return result_binding_set ``` -python -m knowledge_mapper examples/sql-mapper/config.jsonc -``` - -The Knowledge Mapper will now continuously listen for incoming knowledge requests, and answer them by using the given SQL query and mapping them to bindings for the graph pattern. - -## Additional features +**Registering Knowledge Interactions (non-decorator):** -### Authorization with deny-unless-permit policy +```python +# ASK — query the network (no handler needed) +kb.ask_ki(name="...", graph_pattern="...", binding_model=MyModel, prefixes={...}) -In order for another knowledge base to request a knowledge interaction, authorization can be set using the boolean configuration property `authorization_enabled`. This is an optional setting which means that if the property is absent no authorization is being applied and all knowledge interactions are permitted. +# POST — push data to the network (no handler needed) +kb.post_ki(name="...", argument_graph_pattern="...", result_graph_pattern="...", prefixes={...}) +``` -If the property is set to `true`, a deny-unless-permit policy is being applied. Then, for every knowledge interaction, a `permitted` list can be added that indicates which knowledge bases are permitted to request that knowledge interaction. +**Outgoing interactions:** -There are some special cases for the values of this `permitted` list: -- If this list is absent or empty, NO knowledge bases are permitted. -- If the list equals `*`, ALL knowledge bases are permitted. +```python +result = await kb.ask(binding_set, ki_name="...") +result = await kb.post(binding_set, ki_name="...") +``` -For all other cases, the `permitted` list contains the ids of the knowledge bases that are permitted. +### `BindingModel` -The configuration file below gives an example of authorization enabled and a knowledge interaction with a permitted list with a single other knowledge base. +A Pydantic `BaseModel` subclass that maps Python types to RDF N3 encoding automatically. -### Knowledge gaps +```python +from knowledge_mapper import BindingModel, Uri, Literal -The knowledge mapper code also contains operations to register ASK knowledge interactions with an additional option or flag to receive knowledge gaps as part of the result of the ASK to the knowledge network. A knowledge gap exists when the pattern in the ASK knowledge interaction can not be matched to the complete set of knowledge interactions in the network. As a result, the knowledge network returns an empty binding set and a set of triple patterns that need to be solved in order to close the gap. +class PersonBinding(BindingModel): + person: Uri # URIRef, serialized as <...> + name: Literal[str] # Python str, serialized as "..."^^xsd:string + age: Literal[int] # Python int, serialized as "..."^^xsd:integer +``` -To use this feature, the ASK knowledge interaction should be registered with the option `knowledge_gaps_enabled` set to true and the knowledge base should be registered with `enable_reasoner` set to true as well. Please look at the `register` operation in `tke_client.py` and the `register_knowledge_interaction` in `knowledge_base.py` how to use this feature. +Use `BindingModel` for type safety and automatic serialization. Use raw `BindingSet` (`Sequence[dict[str, str]]`) for passthrough data. +### `Depends` — Dependency Injection -## Configuration +Handlers can declare dependencies using `Depends()` in `Annotated` type hints. The framework resolves them at call time. -There are multiple possibilities for configuration of the knowledge mapper depending on the type of knowledge base. +```python +from typing import Annotated +from knowledge_mapper import Depends -### SQL +def get_db() -> MyDatabase: + return MyDatabase(url="...") -See [the example config for SQL data sources](examples/sql-mapper/config.jsonc). +@kb.answer_ki(name="...", graph_pattern="...") +def handler( + binding_set: list[PersonBinding], + info: KnowledgeInteractionInfo, + db: Annotated[MyDatabase, Depends(get_db)], +) -> list[PersonBinding]: + return db.query(binding_set) +``` -### SPARQL +Dependencies can be sync or async, support nesting, and are cached per invocation by default (`cache=True`). -See [the example config for SPARQL data sources](examples/sparql-mapper/config.jsonc). +### `KnowledgeBaseBuilder` -### Custom data source -See [the example config for a custom data source](custom-mapper/config.jsonc). +Build a `KnowledgeBase` from settings. Returned by `KnowledgeBase.from_settings()`. -# Development instructions +```python +from knowledge_mapper import KnowledgeBase, KnowledgeBaseSettings -## Testing +settings = KnowledgeBaseSettings(...) +builder = KnowledgeBase.from_settings(settings) -There's unit tests in the Python package that require a TKE runtime to be running at port 8082: -```bash -# Start the TKE runtime -docker run -d --rm -p 8280:8280 --name tke-runtime ci.tno.nl/tke/knowledge-engine/smart-connector-rest-dist:1.1.0 -# Perform the unit tests -pytest +# Attach handlers for ANSWER/REACT KIs defined in settings +builder.handler("my-answer-ki", my_handler_func) -# Stop the TKE runtime -docker stop tke-runtime +# ASK/POST KIs are auto-registered from settings +kb = builder.build() ``` -There's also an example setup that acts like an integration test. See [examples/README.md](examples/README.md). +## Configuration +`KnowledgeBaseSettings` is a Pydantic `BaseSettings` subclass that supports configuration from (highest priority first): + +1. Keyword arguments +2. Environment variables (delimiter `__`, e.g. `KNOWLEDGE_BASE__ID`) +3. YAML config file +4. JSON config file +5. Field defaults + +Example YAML configuration: + +```yaml +knowledge_base: + id: "http://example.org/my-kb" + name: "my-kb" + description: "My knowledge base" +knowledge_engine_endpoint: "http://localhost:8280/rest" +knowledge_interactions: + - name: my-answer-ki + type: AnswerKnowledgeInteraction + prefixes: + ex: "http://example.org/" + graph_pattern: "?s ?p ?o ." +``` -# Developer instructions +Subclass `KnowledgeBaseSettings` to add application-specific settings: -These are instructions for developers that work on the Knowledge Mapper project. +```python +from knowledge_mapper import KnowledgeBaseSettings -## Building a new distribution +class AppSettings(KnowledgeBaseSettings): + db_url: str = "sqlite:///./app.db" +``` -- Make sure the `./dist` directory is empty or non-existing. -- Make sure you use a Python environment with the packages `distutils` and `wheel` installed. -- Make sure the version number is correct in `setup.py` *AND* `knowledge_mapper/__init__.py`. -- Build the project: +## Development ```bash -# this creates a source distribution (`sdist`) and a built distribution (`bdist_wheel`). -python setup.py sdist bdist_wheel +uv sync # Install dependencies +uv run pytest # Run tests +uv run ruff check . # Lint +uv run ruff format . # Format ``` -- There should now be 2 files under the `./dist` directory. - -## Releasing a new distribution -- Make sure you just built a new distribution with a *NEW* version number and have it in `./dist` -- Use `twine` to upload your new distribution to PyPI: +Tests use `TestClient`, an in-memory fake Smart Connector — no live KE runtime needed for unit tests. For integration tests, start a KE runtime with Docker: -``` -twine upload dist/* +```bash +docker compose -f examples/compose.yaml up -d +uv run pytest +docker compose -f examples/compose.yaml down ``` -- Enter your PyPI credentials in the prompt -- Make sure the new version is working as intended (attempt to upgrade project that use it) +See [CONTRIBUTING.md](./CONTRIBUTING.md) for detailed development and contribution guidelines. diff --git a/examples/01-basic.py b/examples/01-basic.py index eb4acf6..cf0d086 100644 --- a/examples/01-basic.py +++ b/examples/01-basic.py @@ -35,10 +35,16 @@ def example_answer_ki(binding_set, info): return binding_set -if __name__ == "__main__": +async def main(): # Connect to the KE, then register and unregister this KB. - kb.connect() - kb.register() + await kb.connect() + await kb.register() logger.info("Registered a Knowledge Base in the basic example!") - kb.unregister() + await kb.unregister() logger.info("Unregistered the Knowledge Base in the basic example!") + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/examples/02-binding_models.py b/examples/02-binding_models.py index 93fa179..14ddb74 100644 --- a/examples/02-binding_models.py +++ b/examples/02-binding_models.py @@ -96,11 +96,17 @@ def binding_models_raw_answer_ki( ] -if __name__ == "__main__": +async def main(): # Register both KIs, then cleanly unregister. - kb.connect() - kb.register() + await kb.connect() + await kb.register() logger.info("Registered the binding models example KB!") - kb.unregister() + await kb.unregister() logger.info("Unregistered the binding models example KB!") + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/examples/03-ask_interaction.py b/examples/03-ask_interaction.py index d73792e..0d70650 100644 --- a/examples/03-ask_interaction.py +++ b/examples/03-ask_interaction.py @@ -4,6 +4,7 @@ and typed results work end-to-end. """ +from rdflib import URIRef from shared import get_example_logger from knowledge_mapper import BindingModel, KnowledgeBase, Literal, Uri @@ -38,19 +39,30 @@ class PersonBinding(BindingModel): prefixes={"ex": "http://example.org/knowledge-mapper/ask-interaction#"}, ) -if __name__ == "__main__": + +async def main(): # Register this KB, execute one ASK request, and then unregister. - kb.register() + await kb.register() logger.info("KB registered.") - result = kb.ask( + result = await kb.ask( [ - { - "person": "http://example.org/knowledge-mapper/ask-interaction#person1", - } + PersonBinding( + person=URIRef( + "http://example.org/knowledge-mapper/ask-interaction#person1" + ), + name=None, + age=None, + ) ], "ask-ki", ) logger.info(f"Received result from ASK KI: {result}") - kb.unregister() + await kb.unregister() logger.info("KB unregistered.") + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/examples/04-post_measurement.py b/examples/04-post_measurement.py index 96f9d7f..fd16b32 100644 --- a/examples/04-post_measurement.py +++ b/examples/04-post_measurement.py @@ -58,15 +58,15 @@ class ResultBinding(BindingModel): ) -if __name__ == "__main__": - # Register KB, wait briefly for manual testing, then execute one POST. - kb.register() +async def main(): + # Register this KB, wait briefly for manual testing, then execute one POST. + await kb.register() logger.info("KB registered.") time.sleep( 5 ) # Sleep for a bit to allow time for testing the POST KI with an external client logger.info("Posting...") - result_bindings = kb.post( + result_bindings = await kb.post( [ MeasurementBinding( measurement=URIRef( @@ -82,5 +82,11 @@ class ResultBinding(BindingModel): "post-measurement-ki", ) logger.info(f"Received result bindings: {result_bindings}") - kb.unregister() + await kb.unregister() logger.info("KB unregistered.") + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/examples/06-dependency_injection.py b/examples/06-dependency_injection.py index 3403c08..3d9bfb6 100644 --- a/examples/06-dependency_injection.py +++ b/examples/06-dependency_injection.py @@ -173,8 +173,15 @@ def answer_sensor_readings( ] -if __name__ == "__main__": - kb.connect() - kb.register() +async def main(): + await kb.connect() + await kb.register() logger.info("Registered the dependency-injection example KB!") - kb.unregister() + await kb.unregister() + logger.info("Unregistered the dependency-injection example KB!") + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/examples/07-testing/kb.py b/examples/07-testing/kb.py index fea31fb..b8f7ab3 100644 --- a/examples/07-testing/kb.py +++ b/examples/07-testing/kb.py @@ -1,6 +1,5 @@ import sys from pathlib import Path -from time import sleep from typing import cast from rdflib import URIRef @@ -47,8 +46,8 @@ class ExampleBinding(BindingModel): ) -def ask_for_values_of_subject(subject_name: str) -> list[str]: - result_binding_set: list[ExampleBinding] = kb.ask( +async def ask_for_values_of_subject(subject_name: str) -> list[str]: + result_binding_set: list[ExampleBinding] = await kb.ask( [ ExampleBinding( s=URIRef(f"http://example.org/knowledge-mapper/testing#{subject_name}"), @@ -85,11 +84,11 @@ class ResultBinding(BindingModel): ) -def repeat_value_post(value: str, iterations: int) -> list[URIRef]: +async def repeat_value_post(value: str, iterations: int) -> list[URIRef]: result_binding_set: list[ResultBinding] = [] for i in range(iterations): result_binding_set.extend( - kb.post( + await kb.post( [ ExampleBinding( s=URIRef( @@ -101,7 +100,6 @@ def repeat_value_post(value: str, iterations: int) -> list[URIRef]: "post-ki", ) # type: ignore ) - sleep(1) return [cast(URIRef, binding.other) for binding in result_binding_set] diff --git a/examples/07-testing/test_kb.py b/examples/07-testing/test_kb.py index 51543c4..7222185 100644 --- a/examples/07-testing/test_kb.py +++ b/examples/07-testing/test_kb.py @@ -12,10 +12,13 @@ # to the KE, so its important to replace it with a TestClient test_client = TestClient(fake_url="http://fake-ke") kb.client = test_client -# Here the KB and its interactions are registered with the TestClient, which always -# succeeds. This registration is necessary for the KB to be able to execute -# interactions in the tests. -kb.register() + + +@pytest.fixture(autouse=True) +async def _register_kb(): + if not kb.is_registered: + await kb.register() + yield @pytest.fixture() @@ -26,15 +29,15 @@ def client(): # In a test you can do any ASK interaction that is registered. # The TestClient will return an empty result binding set by default, disregarding the # input. -def test_ask_ki_no_resuls(): - result_binding_set = kb.ask([], "ask-ki-no-binding-model") +async def test_ask_ki_no_resuls(): + result_binding_set = await kb.ask([], "ask-ki-no-binding-model") assert result_binding_set == [] # You likely want to mock result binding sets, which can be done using the TestClient as # in this test. The mocked result is returned when the ASK interaction is executed, # disregarding the input. -def test_ask_ki_with_result(client: TestClient): +async def test_ask_ki_with_result(client: TestClient): client.mock_result_binding_set( "ask-ki-no-binding-model", [ @@ -44,7 +47,7 @@ def test_ask_ki_with_result(client: TestClient): } ], ) - result_binding_set = kb.ask([], "ask-ki-no-binding-model") + result_binding_set = await kb.ask([], "ask-ki-no-binding-model") assert result_binding_set == [ { "s": "", @@ -56,7 +59,7 @@ def test_ask_ki_with_result(client: TestClient): # This is a little more useful when you have a binding model, testing the correctness of # the binding model according to the graph pattern. One test per interaction like this # is probably a good idea, to isolate issues with the binding model from other issues. -def test_ask_ki_with_binding_model(client: TestClient): +async def test_ask_ki_with_binding_model(client: TestClient): client.mock_result_binding_set( "ask-ki-with-binding-model", [ @@ -67,7 +70,7 @@ def test_ask_ki_with_binding_model(client: TestClient): ], ) - result_binding_set = kb.ask( + result_binding_set = await kb.ask( [ ExampleBinding( s=URIRef("http://example.org/knowledge-mapper/testing#Subject"), @@ -86,7 +89,7 @@ def test_ask_ki_with_binding_model(client: TestClient): # However, most likely you will want to test the logic around interactions, where you # might want to mock different results for different inputs. -def test_function_containing_ask(client: TestClient): +async def test_function_containing_ask(client: TestClient): client.mock_result_binding_set( ki_name="ask-ki-with-binding-model", binding_set=[ @@ -97,12 +100,12 @@ def test_function_containing_ask(client: TestClient): ], ) - result = ask_for_values_of_subject("Subject") + result = await ask_for_values_of_subject("Subject") assert result == ["test value"] # Similar approaches can be taken for POST interactions. -def test_function_containing_post(client: TestClient): +async def test_function_containing_post(client: TestClient): client.mock_result_binding_set( ki_name="post-ki", binding_set=[ @@ -113,5 +116,5 @@ def test_function_containing_post(client: TestClient): ], ) - result = repeat_value_post("test value", 1) + result = await repeat_value_post("test value", 1) assert result == [URIRef("http://example.org/knowledge-mapper/testing#Other")] diff --git a/examples/08-async_handlers.py b/examples/08-async_handlers.py new file mode 100644 index 0000000..a26bd15 --- /dev/null +++ b/examples/08-async_handlers.py @@ -0,0 +1,126 @@ +"""Async REACT handlers example. + +Demonstrates two REACT handlers in one KB: +1) a sync handler (``def``) +2) an async handler (``async def``) + +The comments highlight how the mapper executes each style differently. +""" + +import asyncio +import time + +from shared import get_example_logger + +from knowledge_mapper import ( + BindingModel, + KnowledgeBase, + KnowledgeInteractionInfo, + Literal, + Uri, +) + +EXAMPLE_NAME = "async-handlers" +logger = get_example_logger(EXAMPLE_NAME) + +EX = "http://example.org/knowledge-mapper/async-handlers#" + + +class DeviceCommandBinding(BindingModel): + device: Uri + desired_state: Literal[str] + + +class DeviceAckBinding(BindingModel): + device: Uri + accepted: Literal[bool] + source: Literal[str] + + +kb = KnowledgeBase( + id=f"{EX}kb", + name="async-handlers-kb", + description="A KB that demonstrates async vs sync REACT handlers.", + ke_url="http://localhost:8280/rest", +) + + +@kb.react_ki( + name="device-react-sync-ki", + argument_graph_pattern=""" + ?device a ex:Device ; + ex:desiredState ?desiredState . + """, + result_graph_pattern=""" + ?device ex:accepted ?accepted ; + ex:handledBy ?source . + """, + prefixes={"ex": EX}, +) +def react_device_sync( + binding_set: list[DeviceCommandBinding], + info: KnowledgeInteractionInfo, +) -> list[DeviceAckBinding]: + # Sync handlers are executed with asyncio.to_thread(...). + # That keeps the event loop responsive, but this function itself still blocks + # the worker thread while it runs. + time.sleep(4) + return [ + DeviceAckBinding(device=b.device, accepted=True, source="sync-handler") + for b in binding_set + ] + + +@kb.react_ki( + name="device-react-async-ki", + argument_graph_pattern=""" + ?device a ex:Device ; + ex:desiredState ?desiredState . + """, + result_graph_pattern=""" + ?device ex:accepted ?accepted ; + ex:handledBy ?source . + """, + prefixes={"ex": EX}, +) +async def react_device_async( + binding_set: list[DeviceCommandBinding], + info: KnowledgeInteractionInfo, +) -> list[DeviceAckBinding]: + # Async handlers are awaited directly on the event loop. + # Use this style when the handler performs awaitable I/O (HTTP calls, DB + # drivers, message brokers, etc.) so multiple requests can overlap. + await asyncio.sleep(4) + return [ + DeviceAckBinding(device=b.device, accepted=True, source="async-handler") + for b in binding_set + ] + + +async def main(): + await kb.connect() + await kb.register() + logger.info("KB registered.") + + # In real usage, REACT handlers are triggered by incoming POST interactions + # from the KE. Here we call them locally via kb.call(...) to make behavior + # easy to observe in a standalone script. + incoming = [ + { + "device": f"<{EX}device-1>", + "desiredState": '"on"', + } + ] + + sync_result = await kb.call(incoming, "device-react-sync-ki") + logger.info("Sync REACT result: %s", sync_result) + + async_result = await kb.call(incoming, "device-react-async-ki") + logger.info("Async REACT result: %s", async_result) + + await kb.unregister() + logger.info("KB unregistered.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/README.md b/examples/README.md index 7209927..5c77b75 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,6 +13,8 @@ The best way to get started with the Knowledge Mapper is by exploring the exampl | **05-custom-settings/** | Shows how to use custom settings to configure your knowledge mapper | | **06-dependency_injection.py** | Uses dependency injection to inject resources like configs or database connections | | **07-testing/** | Demonstrates how to write tests for your knowledge base using the fake client | +| **08-async_handlers.py** | Demonstrates async REACT handlers and how they differ from sync handlers | +| **09-sparql-store/** | Connecting a SPARQL store as a knowledge base | ## Prerequisites @@ -75,6 +77,8 @@ python -m pytest 07-testing/ 4. Check **05-custom-settings/** for configuration patterns 5. Study **06-dependency_injection.py** to see how to manage dependencies 6. Review **07-testing/** to learn testing strategies +7. Run **08-async_handlers.py** to compare async and sync REACT handler behavior +8. Explore **09-sparql-store/** to see how to connect a SPARQL store ## Tips diff --git a/pyproject.toml b/pyproject.toml index 4ffd67e..7ae1c8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,15 +5,16 @@ description = "The Knowledge Mapper makes it easier to share your data in a know readme = "README.md" requires-python = ">=3.13" dependencies = [ + "httpx>=0.28", "pydantic>=2.12.5", "pydantic-settings[yaml]>=2.13.1", "rdflib>=7.6.0", - "requests>=2.32.5", ] [dependency-groups] dev = [ "pytest>=9.0.2", + "pytest-asyncio>=0.26", "setuptools>=82.0.1", ] @@ -21,6 +22,7 @@ dev = [ [tool.pytest.ini_options] minversion = "6.0" +asyncio_mode = "auto" addopts = [ "--import-mode=importlib", "-ra", diff --git a/src/knowledge_mapper/dependency_injection.py b/src/knowledge_mapper/dependency_injection.py index 48212aa..5401752 100644 --- a/src/knowledge_mapper/dependency_injection.py +++ b/src/knowledge_mapper/dependency_injection.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect from collections.abc import Callable from dataclasses import dataclass, field from typing import Any, get_args, get_type_hints @@ -25,9 +26,10 @@ def handler( return db.query(binding_set) Args: - factory: A callable (sync) that returns the dependency value. The - factory may itself declare ``Annotated[T, Depends(...)]`` parameters - for nested/transitive resolution. + factory: A callable (sync or async) that returns the dependency + value. The factory may itself declare + ``Annotated[T, Depends(...)]`` parameters for nested/transitive + resolution. cache: When ``True`` (the default) the factory is called at most once per KI-call invocation and the result is shared across all parameters that reference the same factory. When ``False`` the @@ -58,7 +60,7 @@ def _get_dep_params(func: Callable[..., Any]) -> dict[str, Depends]: return dep_params -def resolve_dependencies( +async def resolve_dependencies( func: Callable[..., Any], cache: dict[Callable[..., Any], Any] | None = None, overrides: dict[Callable[..., Any], Callable[..., Any]] | None = None, @@ -94,9 +96,13 @@ def resolve_dependencies( if dep.cache and actual_factory in cache: resolved[param_name] = cache[actual_factory] else: - # Recursively resolve factory's own dependencies first - factory_kwargs = resolve_dependencies(actual_factory, cache, overrides) - value = actual_factory(**factory_kwargs) + factory_kwargs = await resolve_dependencies( + actual_factory, cache, overrides + ) + if inspect.iscoroutinefunction(actual_factory): + value = await actual_factory(**factory_kwargs) + else: + value = actual_factory(**factory_kwargs) if dep.cache: cache[actual_factory] = value resolved[param_name] = value diff --git a/src/knowledge_mapper/kb/builder.py b/src/knowledge_mapper/kb/builder.py index f00a4e0..20ea2d3 100644 --- a/src/knowledge_mapper/kb/builder.py +++ b/src/knowledge_mapper/kb/builder.py @@ -91,12 +91,11 @@ def build(self) -> KnowledgeBase: for ki in self._settings.knowledge_interactions: if ki.type in (KiTypes.ASK, KiTypes.POST): - self._kb.register_ki( + self._kb._register_ki_locally( KnowledgeInteractionContext( info=ki, handler=None, ), - defer_ke_registration=True, ) return self._kb diff --git a/src/knowledge_mapper/kb/knowledge_base.py b/src/knowledge_mapper/kb/knowledge_base.py index 46b56ce..d072cc0 100644 --- a/src/knowledge_mapper/kb/knowledge_base.py +++ b/src/knowledge_mapper/kb/knowledge_base.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio +import inspect import logging from collections.abc import Callable, Sequence from enum import StrEnum @@ -7,7 +9,7 @@ from typing import TYPE_CHECKING, Any from ..ke import Client -from ..ke.client import ClientProtocol, PollResult +from ..ke.client import ClientProtocol, HandleRequest, PollResult from ..ke.errors import KnowledgeEngineNotAvailableError from ..ke.models import ( AskAnswerInteractionInfo, @@ -68,16 +70,16 @@ def from_settings(cls, settings: KnowledgeBaseSettings) -> KnowledgeBaseBuilder: return KnowledgeBaseBuilder(settings) - def connect(self) -> None: + async def connect(self) -> None: """Checks whether the KE runtime is available and raises an exception if not. Raises: KnowledgeEngineNotAvailableError: If the KE runtime cannot be reached. """ - if not self.client.ke_is_available(): + if not await self.client.ke_is_available(): raise KnowledgeEngineNotAvailableError(self.client.ke_url) - def register(self) -> None: + async def register(self) -> None: """Register this knowledge base at the KE runtime, reregister if already registered. Automatically syncs knowledge interactions with KE runtime. @@ -88,12 +90,12 @@ def register(self) -> None: logger.info( "Registering knowledge base '%s' (%s).", self.info.id, self.info.name ) - self.client.register_kb(self.info, reregister=True) + await self.client.register_kb(self.info, reregister=True) self.state = KnowledgeBaseState.REGISTERED - self.sync_knowledge_interactions() + await self.sync_knowledge_interactions() return - def unregister(self) -> None: + async def unregister(self) -> None: """Unregister this knowledge base at the KE runtime, do nothing if not currently registered. Knowledge interactions automatically unregistered. @@ -114,14 +116,35 @@ def unregister(self) -> None: logger.info( "Unregistering knowledge base '%s' (%s).", self.info.id, self.info.name ) - self.client.unregister_kb(self.info.id) + await self.client.unregister_kb(self.info.id) self.state = KnowledgeBaseState.UNREGISTERED self._ki_registry_by_id.clear() for ki_ctx in self.ki_registry.values(): ki_ctx.status = KnowledgeInteractionStatus.UNREGISTERED return - def register_ki( + def _register_ki_locally( + self, + ki_ctx: KnowledgeInteractionContext[Any, ...], + ) -> None: + """Validate and store a KI context in the local registry (sync). + + Does NOT contact the KE runtime. Use :meth:`register_ki` for full + async registration, or call this from synchronous code (e.g. decorators) + followed by :meth:`sync_knowledge_interactions` to push to the KE. + """ + if ki_ctx.info.name in (ki.info.name for ki in self.ki_registry.values()): + raise ValueError( + f"A KI named '{ki_ctx.info.name}' is already registered for this KB." + ) + if ki_ctx.status == KnowledgeInteractionStatus.REGISTERED: + raise ValueError( + f"Cannot register KI '{ki_ctx.info.name}' because it is already " + f"registered." + ) + self.ki_registry[ki_ctx.info.name] = ki_ctx + + async def register_ki( self, ki_ctx: KnowledgeInteractionContext[Any, ...], defer_ke_registration: bool = False, @@ -144,21 +167,13 @@ def register_ki( f"registered. Consider setting defer_ke_registration=True to defer " f"registration until the KB itself is registered." ) - if ki_ctx.info.name in (ki.info.name for ki in self.ki_registry.values()): - raise ValueError( - f"A KI named '{ki_ctx.info.name}' is already registered for this KB." - ) - if ki_ctx.status == KnowledgeInteractionStatus.REGISTERED: - raise ValueError( - f"Cannot register KI '{ki_ctx.info.name}' because it is already " - f"registered." - ) - self.ki_registry[ki_ctx.info.name] = ki_ctx + self._register_ki_locally(ki_ctx) + if defer_ke_registration: return ki_ctx.info - registered_ki = self.client.register_ki( + registered_ki = await self.client.register_ki( kb_id=self.info.id, ki=ki_ctx.info, ) @@ -183,28 +198,48 @@ def _register_ki_decorator( """ def decorator(func: Handler) -> Handler: - @wraps(func) - def wrapper( - binding_set: BindingSet | list[BindingModel], - info: KnowledgeInteractionInfo, - *args, - **kwargs, - ) -> BindingSet | Sequence[BindingModel]: - return func(binding_set, info, *args, **kwargs) - - self.register_ki( - KnowledgeInteractionContext( - info=info, - handler=wrapper, - status=KnowledgeInteractionStatus.UNREGISTERED, - ), - defer_ke_registration=defer_ke_registration, - ) - return wrapper + if inspect.iscoroutinefunction(func): + + @wraps(func) + async def async_wrapper( + binding_set: BindingSet | list[BindingModel], + info: KnowledgeInteractionInfo, + *args, + **kwargs, + ) -> BindingSet | Sequence[BindingModel]: + return await func(binding_set, info, *args, **kwargs) + + self._register_ki_locally( + KnowledgeInteractionContext( + info=info, + handler=async_wrapper, + status=KnowledgeInteractionStatus.UNREGISTERED, + ), + ) + return async_wrapper + else: + + @wraps(func) + def wrapper( + binding_set: BindingSet | list[BindingModel], + info: KnowledgeInteractionInfo, + *args, + **kwargs, + ) -> BindingSet | Sequence[BindingModel]: + return func(binding_set, info, *args, **kwargs) + + self._register_ki_locally( + KnowledgeInteractionContext( + info=info, + handler=wrapper, + status=KnowledgeInteractionStatus.UNREGISTERED, + ), + ) + return wrapper return decorator - def sync_knowledge_interactions(self) -> None: + async def sync_knowledge_interactions(self) -> None: """Synchronize registration of knowledge interactions in this object's local KI registry with the interactions registered at the KE runtime, so all unregistered KIs in the local registry are registered. @@ -224,7 +259,7 @@ def sync_knowledge_interactions(self) -> None: for ki_ctx in self.ki_registry.values(): if ki_ctx.status == KnowledgeInteractionStatus.REGISTERED: continue - ki_ctx.info = self.client.register_ki( + ki_ctx.info = await self.client.register_ki( kb_id=self.info.id, ki=ki_ctx.info, ) @@ -272,7 +307,7 @@ def ask_ki( UnexpectedHttpResponseError: Propagated from ``register_ki`` when contacting the KE runtime. """ - self.register_ki( + self._register_ki_locally( KnowledgeInteractionContext( info=AskAnswerInteractionInfo( type=KiTypes.ASK, @@ -285,7 +320,6 @@ def ask_ki( validation_model=binding_model, serialization_model=binding_model, ), - defer_ke_registration=defer_ke_registration, ) return @@ -338,7 +372,7 @@ def post_ki( UnexpectedHttpResponseError: Propagated from ``register_ki`` when contacting the KE runtime. """ - self.register_ki( + self._register_ki_locally( KnowledgeInteractionContext( info=PostReactInteractionInfo( type=KiTypes.POST, @@ -352,7 +386,6 @@ def post_ki( validation_model=result_binding_model, serialization_model=argument_binding_model, ), - defer_ke_registration=defer_ke_registration, ) return @@ -386,18 +419,18 @@ def react_ki( defer_ke_registration=defer_ke_registration, ) - def call(self, binding_set: BindingSet, ki_name: str) -> BindingSet: + async def call(self, binding_set: BindingSet, ki_name: str) -> BindingSet: """Invoke the handler of a registered KI by its name. Raises: KeyError: If ``ki_name`` is not found in the local KI registry. """ - return self.ki_registry[ki_name].dispatch( + return await self.ki_registry[ki_name].dispatch( binding_set, dependency_overrides=self.dependency_overrides or None, ) - def post( + async def post( self, binding_set: Sequence[BindingModel] | BindingSet, ki_name: str ) -> Sequence[BindingModel] | BindingSet: """Invoke a POST KI by its name. @@ -419,14 +452,14 @@ def post( ) assert ki_ctx.info.id is not None # Should always be set for registered KIs - post_result = self.client.post( + post_result = await self.client.post( kb_id=self.info.id, ki_id=ki_ctx.info.id, binding_set=ki_ctx.prepare_outgoing(binding_set), ) return ki_ctx.parse_result(post_result.result_binding_set) - def ask( + async def ask( self, binding_set: Sequence[BindingModel] | BindingSet, ki_name: str ) -> Sequence[BindingModel] | BindingSet: """Invoke an ASK KI by its name. @@ -448,68 +481,170 @@ def ask( ) assert ki_ctx.info.id is not None # Should always be set for registered KIs - ask_result = self.client.ask( + ask_result = await self.client.ask( kb_id=self.info.id, ki_id=ki_ctx.info.id, binding_set=ki_ctx.prepare_outgoing(binding_set), ) return ki_ctx.parse_result(ask_result.binding_set) - def start_handling_loop(self, loops: int | None = None) -> None: - """Poll the KE runtime for incoming KI calls and dispatch them to handlers. + def _require_loop(self) -> asyncio.AbstractEventLoop: + """Return the stored event loop or raise if the handling loop is not running.""" + try: + loop = self._loop + except AttributeError: + loop = None + if loop is None: + raise RuntimeError( + "ask_sync() / post_sync() are only available from within a sync " + "handler running inside the handling loop. Start the handling loop " + "with start_handling_loop() first." + ) + return loop + + def ask_sync( + self, + binding_set: Sequence[BindingModel] | BindingSet, + ki_name: str, + ) -> Sequence[BindingModel] | BindingSet: + """Blocking bridge to :meth:`ask` for use in sync handlers. + + Schedules the async ``ask()`` coroutine on the event loop stored by + :meth:`start_handling_loop` and blocks the calling thread until the + result is ready. + + Raises: + RuntimeError: If called outside the handling loop context. + """ + loop = self._require_loop() + future = asyncio.run_coroutine_threadsafe( + self.ask(binding_set, ki_name=ki_name), loop + ) + return future.result() + + def post_sync( + self, + binding_set: Sequence[BindingModel] | BindingSet, + ki_name: str, + ) -> Sequence[BindingModel] | BindingSet: + """Blocking bridge to :meth:`post` for use in sync handlers. + + Schedules the async ``post()`` coroutine on the event loop stored by + :meth:`start_handling_loop` and blocks the calling thread until the + result is ready. + + Raises: + RuntimeError: If called outside the handling loop context. + """ + loop = self._require_loop() + future = asyncio.run_coroutine_threadsafe( + self.post(binding_set, ki_name=ki_name), loop + ) + return future.result() + + async def start_handling_loop( + self, + loops: int | None = None, + max_concurrent_handlers: int = 10, + ) -> None: + """Poll the KE runtime for incoming KI calls and dispatch them concurrently. + + Runs multiple concurrent poll-dispatch cycles, bounded by a semaphore. + Each cycle acquires the semaphore, polls, and on HANDLE spawns a task + that runs the handler, posts the response, and releases the semaphore. + + Stops when an EXIT signal is received or ``loops`` poll cycles have + been completed. On EXIT, all in-flight handler tasks are awaited + before returning. - Runs until an EXIT signal is received from the KE runtime, or until - ``loops`` iterations have been completed if ``loops`` is specified. + Args: + loops: If set, limits the total number of poll cycles (useful for + testing). ``None`` means poll indefinitely. + max_concurrent_handlers: Maximum number of concurrent handler tasks + (semaphore size). Defaults to 10. Raises: RuntimeError: If the KB is not registered. - KeyError: If the KE runtime refers to a KI not found in the local registry. - SmartConnectorNotFoundError: If the KB's smart connector is not found in - the KE runtime. - UnexpectedHttpResponseError: If the KE runtime returns an unexpected HTTP - response. - RuntimeError: If an unknown long-polling result is obtained from the KE - client. """ + import asyncio + if self.state != KnowledgeBaseState.REGISTERED: raise RuntimeError( "Cannot start handling loop because the KB is not registered. Please " "register the KB first." ) + self._loop = asyncio.get_running_loop() + semaphore = asyncio.Semaphore(max_concurrent_handlers) + in_flight: set[asyncio.Task[None]] = set() + loops_done = 0 while loops is None or loops_done < loops: + await semaphore.acquire() loops_done += 1 - poll_result, maybe_handle_request = self.client.poll_ki_call( + + poll_result, maybe_handle_request = await self.client.poll_ki_call( kb_id=self.info.id ) match poll_result, maybe_handle_request: case PollResult.HANDLE, _: assert maybe_handle_request is not None - ki_id = maybe_handle_request.knowledge_interaction_id - ki_ctx = self._ki_registry_by_id[ki_id] - result_binding_set = self.call( - maybe_handle_request.binding_set, - ki_ctx.info.name, - ) - self.client.post_handle_response( - kb_id=self.info.id, - ki_id=maybe_handle_request.knowledge_interaction_id, - handle_request_id=maybe_handle_request.handle_request_id, - binding_set=result_binding_set, - ) + + async def _handle( + handle_request: HandleRequest, + ) -> None: + ki_id = handle_request.knowledge_interaction_id + ki_ctx = self._ki_registry_by_id[ki_id] + try: + result_binding_set = await self.call( + handle_request.binding_set, + ki_ctx.info.name, + ) + except Exception: + logger.exception( + "Handler for KI '%s' raised an exception " + "(request %d from %s). Posting empty binding set.", + ki_ctx.info.name, + handle_request.handle_request_id, + handle_request.requesting_knowledge_base_id, + ) + result_binding_set = [] + + await self.client.post_handle_response( + kb_id=self.info.id, + ki_id=handle_request.knowledge_interaction_id, + handle_request_id=handle_request.handle_request_id, + binding_set=result_binding_set, + ) + semaphore.release() + + task = asyncio.create_task(_handle(maybe_handle_request)) + in_flight.add(task) + task.add_done_callback(in_flight.discard) case PollResult.REPOLL, None: + semaphore.release() continue + case PollResult.EXIT, None: + semaphore.release() logger.info("Received exit signal from KE, stopping handling loop.") - return + break + case _: + semaphore.release() raise RuntimeError( f"Unexpected poll result: {poll_result} or request:" f"{maybe_handle_request}" ) + if in_flight: + await asyncio.gather(*in_flight) + + async def close(self) -> None: + """Close the underlying client, releasing any held resources.""" + await self.client.close() + @property def is_registered(self) -> bool: """Is the knowledge base in the registered state""" diff --git a/src/knowledge_mapper/ke/client.py b/src/knowledge_mapper/ke/client.py index 7a018b3..7d037c5 100644 --- a/src/knowledge_mapper/ke/client.py +++ b/src/knowledge_mapper/ke/client.py @@ -2,7 +2,7 @@ from enum import StrEnum from typing import Protocol -import requests +import httpx from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel @@ -41,11 +41,11 @@ class HandleRequest(BaseModel): class ClientProtocol(Protocol): """Interface for communicating with a Knowledge Engine runtime.""" - def ke_is_available(self) -> bool: + async def ke_is_available(self) -> bool: """Return ``True`` if the KE runtime is reachable, ``False`` otherwise.""" ... - def ke_version(self) -> str: + async def ke_version(self) -> str: """Return the version string of the KE runtime. Raises: @@ -54,7 +54,7 @@ def ke_version(self) -> str: """ ... - def get_knowledge_base(self, id: str) -> KnowledgeBaseInfo | None: + async def get_knowledge_base(self, id: str) -> KnowledgeBaseInfo | None: """Return the KB with the given ID, or ``None`` if it does not exist. Raises: @@ -63,7 +63,7 @@ def get_knowledge_base(self, id: str) -> KnowledgeBaseInfo | None: """ ... - def get_all_knowledge_bases(self) -> list[KnowledgeBaseInfo]: + async def get_all_knowledge_bases(self) -> list[KnowledgeBaseInfo]: """Return all KBs registered at the KE runtime. Raises: @@ -72,7 +72,9 @@ def get_all_knowledge_bases(self) -> list[KnowledgeBaseInfo]: """ ... - def register_kb(self, info: KnowledgeBaseInfo, reregister: bool = True) -> None: + async def register_kb( + self, info: KnowledgeBaseInfo, reregister: bool = True + ) -> None: """Register a KB at the KE runtime, optionally re-registering if it already exists. @@ -82,7 +84,7 @@ def register_kb(self, info: KnowledgeBaseInfo, reregister: bool = True) -> None: """ ... - def unregister_kb(self, id: str) -> None: + async def unregister_kb(self, id: str) -> None: """Unregister the KB with the given ID from the KE runtime. Raises: @@ -93,7 +95,7 @@ def unregister_kb(self, id: str) -> None: """ ... - def get_all_knowledge_interactions( + async def get_all_knowledge_interactions( self, kb_id: str ) -> list[KnowledgeInteractionInfo]: """Return all knowledge interactions registered for the given KB. @@ -106,7 +108,7 @@ def get_all_knowledge_interactions( """ ... - def register_ki( + async def register_ki( self, kb_id: str, ki: KnowledgeInteractionInfo ) -> KnowledgeInteractionInfo: """Register a knowledge interaction for the given KB and return it with its @@ -120,7 +122,7 @@ def register_ki( """ ... - def poll_ki_call(self, kb_id: str) -> tuple[PollResult, HandleRequest | None]: + async def poll_ki_call(self, kb_id: str) -> tuple[PollResult, HandleRequest | None]: """Poll the KE runtime for an incoming KI call for the given KB. Raises: @@ -131,7 +133,7 @@ def poll_ki_call(self, kb_id: str) -> tuple[PollResult, HandleRequest | None]: """ ... - def post_handle_response( + async def post_handle_response( self, kb_id: str, ki_id: str, handle_request_id: int, binding_set: BindingSet ) -> None: """Post the response to a KI call that was received via ``poll_ki_call``. @@ -144,7 +146,7 @@ def post_handle_response( """ ... - def ask( + async def ask( self, kb_id: str, ki_id: str, @@ -162,7 +164,7 @@ def ask( """ ... - def post( + async def post( self, kb_id: str, ki_id: str, @@ -180,6 +182,10 @@ def post( """ ... + async def close(self) -> None: + """Close the underlying HTTP client and release resources.""" + ... + @property def ke_url(self) -> str: """Return the base URL of the KE runtime this client is communicating with.""" @@ -191,74 +197,79 @@ class Client(ClientProtocol): def __init__(self, ke_url: str): self._ke_url = ke_url + self._http = httpx.AsyncClient() - def ke_is_available(self) -> bool: + async def ke_is_available(self) -> bool: try: - _ = requests.get(f"{self.ke_url}/version") + _ = await self._http.get(f"{self.ke_url}/version") return True - except requests.exceptions.RequestException: + except httpx.HTTPError: return False - def ke_version(self) -> str: - response = requests.get(f"{self.ke_url}/version") + async def ke_version(self) -> str: + response = await self._http.get(f"{self.ke_url}/version") return response.json()["version"] - def get_knowledge_base(self, id: str) -> KnowledgeBaseInfo | None: - response = requests.get(f"{self.ke_url}/sc", headers={"Knowledge-Base-Id": id}) + async def get_knowledge_base(self, id: str) -> KnowledgeBaseInfo | None: + response = await self._http.get( + f"{self.ke_url}/sc", headers={"Knowledge-Base-Id": id} + ) if response.status_code == 404: return None - if not response.ok: + if not response.is_success: raise UnexpectedHttpResponseError(response) # KE returns a list with only one element here. return KnowledgeBaseInfo.model_validate(response.json()[0]) - def get_all_knowledge_bases(self) -> list[KnowledgeBaseInfo]: - response = requests.get(f"{self.ke_url}/sc") - if not response.ok: + async def get_all_knowledge_bases(self) -> list[KnowledgeBaseInfo]: + response = await self._http.get(f"{self.ke_url}/sc") + if not response.is_success: raise UnexpectedHttpResponseError(response) return [ KnowledgeBaseInfo.model_validate(kb_json) for kb_json in response.json() ] - def register_kb(self, info: KnowledgeBaseInfo, reregister: bool = True) -> None: - if self.get_knowledge_base(info.id) is not None: + async def register_kb( + self, info: KnowledgeBaseInfo, reregister: bool = True + ) -> None: + if await self.get_knowledge_base(info.id) is not None: if reregister: - self.unregister_kb(info.id) + await self.unregister_kb(info.id) else: return logger.debug("Registering knowledge base '%s' at %s.", info.id, self.ke_url) - response = requests.post( + response = await self._http.post( f"{self.ke_url}/sc", json=info.model_dump(by_alias=True), ) - if not response.ok: + if not response.is_success: raise UnexpectedHttpResponseError(response) return - def unregister_kb(self, id: str) -> None: + async def unregister_kb(self, id: str) -> None: logger.debug("Unregistering knowledge base '%s' at %s.", id, self.ke_url) - response = requests.delete( + response = await self._http.delete( f"{self.ke_url}/sc", headers={"Knowledge-Base-Id": id} ) if response.status_code == 404: raise SmartConnectorNotFoundError(id, self.ke_url) - if not response.ok: + if not response.is_success: raise UnexpectedHttpResponseError(response) return - def get_all_knowledge_interactions( + async def get_all_knowledge_interactions( self, kb_id: str ) -> list[KnowledgeInteractionInfo]: - response = requests.get( + response = await self._http.get( f"{self.ke_url}/sc/ki", headers={"Knowledge-Base-Id": kb_id}, ) if response.status_code == 404: raise SmartConnectorNotFoundError(kb_id, self.ke_url) - if not response.ok: + if not response.is_success: raise UnexpectedHttpResponseError(response) kis = [] @@ -270,7 +281,7 @@ def get_all_knowledge_interactions( kis.append(PostReactInteractionInfo.model_validate(kb_info)) return kis - def register_ki( + async def register_ki( self, kb_id: str, ki: KnowledgeInteractionInfo ) -> KnowledgeInteractionInfo: logger.debug( @@ -279,14 +290,14 @@ def register_ki( kb_id, self.ke_url, ) - response = requests.post( + response = await self._http.post( f"{self.ke_url}/sc/ki", json=ki.model_dump(by_alias=True), headers={"Knowledge-Base-Id": kb_id}, ) if response.status_code == 404: raise SmartConnectorNotFoundError(kb_id, self.ke_url) - if not response.ok: + if not response.is_success: raise UnexpectedHttpResponseError(response) registered_ki = ki.model_copy( @@ -294,10 +305,13 @@ def register_ki( ) return registered_ki - def poll_ki_call(self, kb_id: str) -> tuple[PollResult, HandleRequest | None]: + async def poll_ki_call(self, kb_id: str) -> tuple[PollResult, HandleRequest | None]: logger.debug("Polling for KI calls...") - response = requests.get( - f"{self.ke_url}/sc/handle", headers={"Knowledge-Base-Id": kb_id} + response = await self._http.get( + f"{self.ke_url}/sc/handle", + headers={"Knowledge-Base-Id": kb_id}, + # Set a longer timeout for this request due to the KE 30 second long-polling + timeout=httpx.Timeout(35.0, connect=5.0), ) if response.status_code == 200: @@ -320,11 +334,11 @@ def poll_ki_call(self, kb_id: str) -> tuple[PollResult, HandleRequest | None]: else: raise UnexpectedHttpResponseError(response) - def post_handle_response( + async def post_handle_response( self, kb_id: str, ki_id: str, handle_request_id: int, binding_set: BindingSet ) -> None: logger.debug("Posting handle response for KI call.") - response = requests.post( + response = await self._http.post( f"{self.ke_url}/sc/handle", json={ "handleRequestId": handle_request_id, @@ -336,10 +350,10 @@ def post_handle_response( }, ) - if not response.ok: + if not response.is_success: raise UnexpectedHttpResponseError(response) - def post( + async def post( self, kb_id: str, ki_id: str, @@ -356,7 +370,7 @@ def post( else: payload = binding_set - response = requests.post( + response = await self._http.post( f"{self.ke_url}/sc/post", json=payload, headers={ @@ -365,12 +379,12 @@ def post( }, ) - if not response.ok: + if not response.is_success: raise UnexpectedHttpResponseError(response) return PostResult.model_validate(response.json()) - def ask( + async def ask( self, kb_id: str, ki_id: str, @@ -387,7 +401,7 @@ def ask( else: payload = binding_set - response = requests.post( + response = await self._http.post( f"{self.ke_url}/sc/ask", json=payload, headers={ @@ -396,11 +410,14 @@ def ask( }, ) - if not response.ok: + if not response.is_success: raise UnexpectedHttpResponseError(response) return AskResult.model_validate(response.json()) + async def close(self) -> None: + await self._http.aclose() + @property def ke_url(self) -> str: return self._ke_url diff --git a/src/knowledge_mapper/ke/errors.py b/src/knowledge_mapper/ke/errors.py index c3f9dfc..8fee8f2 100644 --- a/src/knowledge_mapper/ke/errors.py +++ b/src/knowledge_mapper/ke/errors.py @@ -1,4 +1,4 @@ -from requests.models import Response +from httpx import Response class UnexpectedHttpResponseError(Exception): diff --git a/src/knowledge_mapper/knowledge_interaction.py b/src/knowledge_mapper/knowledge_interaction.py index ecd43fa..5d30f1f 100644 --- a/src/knowledge_mapper/knowledge_interaction.py +++ b/src/knowledge_mapper/knowledge_interaction.py @@ -1,5 +1,6 @@ +import asyncio import inspect -from collections.abc import Callable, Sequence +from collections.abc import Callable, Coroutine, Sequence from dataclasses import dataclass from enum import StrEnum from typing import Any, Concatenate, get_args @@ -7,10 +8,15 @@ from .dependency_injection import resolve_dependencies from .ke.models import BindingModel, BindingSet, KiTypes, KnowledgeInteractionInfo -type Handler[B, **P] = Callable[ - Concatenate[B, KnowledgeInteractionInfo, P], - BindingSet | Sequence[BindingModel], -] +type _HandlerReturn = BindingSet | Sequence[BindingModel] + +type Handler[B, **P] = ( + Callable[Concatenate[B, KnowledgeInteractionInfo, P], _HandlerReturn] + | Callable[ + Concatenate[B, KnowledgeInteractionInfo, P], + Coroutine[Any, Any, _HandlerReturn], + ] +) class KnowledgeInteractionStatus(StrEnum): @@ -36,7 +42,7 @@ def __post_init__(self): self.handler ) - def dispatch( + async def dispatch( self, binding_set: BindingSet, dependency_overrides: ( @@ -50,16 +56,25 @@ def dispatch( """ assert self.handler is not None - dep_kwargs = resolve_dependencies(self.handler, overrides=dependency_overrides) + dep_kwargs = await resolve_dependencies( + self.handler, overrides=dependency_overrides + ) if self.validation_model: validated = [self.validation_model.model_validate(b) for b in binding_set] - result_bindings = self.handler(validated, self.info, **dep_kwargs) + input_data = validated else: - result_bindings = self.handler(binding_set, self.info, **dep_kwargs) + input_data = binding_set + + if inspect.iscoroutinefunction(self.handler): + result_bindings = await self.handler(input_data, self.info, **dep_kwargs) + else: + result_bindings = await asyncio.to_thread( + self.handler, input_data, self.info, **dep_kwargs + ) if self.serialization_model and result_bindings: - return [b.model_dump() for b in result_bindings] # pyright: ignore[reportAttributeAccessIssue] + return [b.dump_partial_binding() for b in result_bindings] # pyright: ignore[reportAttributeAccessIssue] return result_bindings # pyright: ignore[reportReturnType] def prepare_outgoing( @@ -70,7 +85,7 @@ def prepare_outgoing( Used by ``ask()`` / ``post()`` before calling the SC. """ if self.serialization_model: - return [b.model_dump() for b in binding_set] # pyright: ignore[reportAttributeAccessIssue] + return [b.dump_partial_binding() for b in binding_set] # pyright: ignore[reportAttributeAccessIssue] return binding_set # pyright: ignore[reportReturnType] def parse_result( diff --git a/src/knowledge_mapper/testing/fake_client.py b/src/knowledge_mapper/testing/fake_client.py index 6861481..aa79ed0 100644 --- a/src/knowledge_mapper/testing/fake_client.py +++ b/src/knowledge_mapper/testing/fake_client.py @@ -1,6 +1,6 @@ """In-memory FakeClient that satisfies ClientProtocol for use in tests.""" -from collections import deque +import asyncio from datetime import UTC, datetime from knowledge_mapper.ke.client import ClientProtocol, HandleRequest, PollResult @@ -27,40 +27,44 @@ def __init__(self, fake_url) -> None: # Maps ki_name -> BindingSet to return from execute_post_interaction self._mock_interaction_results: dict[str, BindingSet] = {} self._handle_responses: list[tuple[str, str, int, BindingSet]] = [] - self._incoming_calls: deque[tuple[PollResult, HandleRequest | None]] = deque() + self._incoming_calls: asyncio.Queue[tuple[PollResult, HandleRequest | None]] = ( + asyncio.Queue() + ) self._next_handle_request_id: int = 1 - def ke_is_available(self) -> bool: + async def ke_is_available(self) -> bool: return True - def ke_version(self) -> str: + async def ke_version(self) -> str: return "0.0.0-fake" - def get_knowledge_base(self, id: str) -> KnowledgeBaseInfo | None: + async def get_knowledge_base(self, id: str) -> KnowledgeBaseInfo | None: return self._knowledge_bases.get(id) - def get_all_knowledge_bases(self) -> list[KnowledgeBaseInfo]: + async def get_all_knowledge_bases(self) -> list[KnowledgeBaseInfo]: return list(self._knowledge_bases.values()) - def register_kb(self, info: KnowledgeBaseInfo, reregister: bool = True) -> None: + async def register_kb( + self, info: KnowledgeBaseInfo, reregister: bool = True + ) -> None: if info.id in self._knowledge_bases: if reregister: - self.unregister_kb(info.id) + await self.unregister_kb(info.id) else: return self._knowledge_bases[info.id] = info self._knowledge_interactions[info.id] = [] - def unregister_kb(self, id: str) -> None: + async def unregister_kb(self, id: str) -> None: self._knowledge_bases.pop(id) self._knowledge_interactions.pop(id, None) - def get_all_knowledge_interactions( + async def get_all_knowledge_interactions( self, kb_id: str ) -> list[KnowledgeInteractionInfo]: return list(self._knowledge_interactions.get(kb_id, [])) - def register_ki( + async def register_ki( self, kb_id: str, ki: KnowledgeInteractionInfo ) -> KnowledgeInteractionInfo: registered = ki.model_copy(update={"id": f"fake-ki-{self._next_ki_id}"}) @@ -68,12 +72,10 @@ def register_ki( self._knowledge_interactions.setdefault(kb_id, []).append(registered) return registered - def poll_ki_call(self, kb_id: str) -> tuple[PollResult, HandleRequest | None]: - if self._incoming_calls: - return self._incoming_calls.popleft() - return (PollResult.REPOLL, None) + async def poll_ki_call(self, kb_id: str) -> tuple[PollResult, HandleRequest | None]: + return await self._incoming_calls.get() - def post_handle_response( + async def post_handle_response( self, kb_id: str, ki_id: str, handle_request_id: int, binding_set: BindingSet ) -> None: self._handle_responses.append((kb_id, ki_id, handle_request_id, binding_set)) @@ -130,13 +132,13 @@ def enqueue_handle_request( requesting_knowledge_base_id=requesting_kb_id, ) self._next_handle_request_id += 1 - self._incoming_calls.append((PollResult.HANDLE, handle_request)) + self._incoming_calls.put_nowait((PollResult.HANDLE, handle_request)) def enqueue_exit(self) -> None: """Queue an EXIT signal so ``poll_ki_call`` terminates the handling loop.""" - self._incoming_calls.append((PollResult.EXIT, None)) + self._incoming_calls.put_nowait((PollResult.EXIT, None)) - def ask( + async def ask( self, kb_id: str, ki_id: str, @@ -174,7 +176,7 @@ def ask( ], ) - def post( + async def post( self, kb_id: str, ki_id: str, @@ -212,6 +214,9 @@ def post( ], ) + async def close(self) -> None: + pass + @property def ke_url(self) -> str: return self._ke_url diff --git a/tests/test_ask_and_post.py b/tests/test_ask_and_post.py index 0799685..869fd9f 100644 --- a/tests/test_ask_and_post.py +++ b/tests/test_ask_and_post.py @@ -12,7 +12,7 @@ def client(): @pytest.fixture -def kb(client: TestClient): +async def kb(client: TestClient): kb = KnowledgeBase( id="http://example.org/test#kb", name="test-kb", @@ -20,11 +20,11 @@ def kb(client: TestClient): ke_url="http://fake-ke", ) kb.client = client - kb.register() + await kb.register() return kb -def test_ask_interaction_no_binding_models(kb: KnowledgeBase, client: TestClient): +async def test_ask_interaction_no_binding_models(kb: KnowledgeBase, client: TestClient): kb.ask_ki( name="ask-ki", graph_pattern=""" @@ -33,8 +33,8 @@ def test_ask_interaction_no_binding_models(kb: KnowledgeBase, client: TestClient ex:hasAge ?age . """, prefixes={"ex": "http://example.org/test#"}, - defer_ke_registration=False, ) + await kb.sync_knowledge_interactions() client.mock_result_binding_set( ki_name="ask-ki", @@ -47,7 +47,7 @@ def test_ask_interaction_no_binding_models(kb: KnowledgeBase, client: TestClient ], ) - result = kb.ask( + result = await kb.ask( [ { "person": "http://example.org/test#person1", @@ -65,7 +65,9 @@ def test_ask_interaction_no_binding_models(kb: KnowledgeBase, client: TestClient ] -def test_ask_interaction_with_binding_models(kb: KnowledgeBase, client: TestClient): +async def test_ask_interaction_with_binding_models( + kb: KnowledgeBase, client: TestClient +): class PersonBinding(BindingModel): person: Uri name: Literal[str] @@ -80,8 +82,8 @@ class PersonBinding(BindingModel): """, binding_model=PersonBinding, prefixes={"ex": "http://example.org/test#"}, - defer_ke_registration=False, ) + await kb.sync_knowledge_interactions() client.mock_result_binding_set( ki_name="ask-ki", @@ -94,7 +96,7 @@ class PersonBinding(BindingModel): ], ) - result = kb.ask( + result = await kb.ask( [ PersonBinding( person=URIRef("http://example.org/test#person1"), @@ -114,7 +116,9 @@ class PersonBinding(BindingModel): ] -def test_post_measurement_no_binding_models(kb: KnowledgeBase, client: TestClient): +async def test_post_measurement_no_binding_models( + kb: KnowledgeBase, client: TestClient +): kb.post_ki( name="post-ki", argument_graph_pattern=""" @@ -128,8 +132,8 @@ def test_post_measurement_no_binding_models(kb: KnowledgeBase, client: TestClien ex:storedBy ?kb . """, prefixes={"ex": "http://example.org/test#"}, - defer_ke_registration=False, ) + await kb.sync_knowledge_interactions() client.mock_result_binding_set( ki_name="post-ki", @@ -141,7 +145,7 @@ def test_post_measurement_no_binding_models(kb: KnowledgeBase, client: TestClien ], ) - result = kb.post( + result = await kb.post( [ { "measurement": "", @@ -161,7 +165,9 @@ def test_post_measurement_no_binding_models(kb: KnowledgeBase, client: TestClien ] -def test_post_measurement_with_binding_models(kb: KnowledgeBase, client: TestClient): +async def test_post_measurement_with_binding_models( + kb: KnowledgeBase, client: TestClient +): class MeasurementBinding(BindingModel): measurement: Uri value: Literal[float] @@ -187,8 +193,8 @@ class ResultBinding(BindingModel): prefixes={"ex": "http://example.org/test#"}, argument_binding_model=MeasurementBinding, result_binding_model=ResultBinding, - defer_ke_registration=False, ) + await kb.sync_knowledge_interactions() client.mock_result_binding_set( ki_name="post-ki", @@ -200,7 +206,7 @@ class ResultBinding(BindingModel): ], ) - result = kb.post( + result = await kb.post( [ MeasurementBinding( measurement=URIRef("http://example.org/test#measurement1"), diff --git a/tests/test_client.py b/tests/test_client.py index f3ba1ff..157122c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -16,18 +16,28 @@ def client(): return Client(ke_url="http://fake-ke") -def test_register_knowledge_base(client: Client): +async def test_register_knowledge_base(client: Client): mock_get_response = MagicMock() mock_get_response.status_code = 404 mock_post_response = MagicMock() - mock_post_response.ok = True + mock_post_response.is_success = True with ( - patch("requests.get", return_value=mock_get_response) as mock_get, - patch("requests.post", return_value=mock_post_response) as mock_post, + patch.object( + client._http, + "get", + new_callable=AsyncMock, + return_value=mock_get_response, + ) as mock_get, + patch.object( + client._http, + "post", + new_callable=AsyncMock, + return_value=mock_post_response, + ) as mock_post, ): - client.register_kb( + await client.register_kb( info=KnowledgeBaseInfo( id="http://example.org/test#kb", name="test-kb", @@ -36,14 +46,22 @@ def test_register_knowledge_base(client: Client): ) mock_get.assert_called_once_with( - "http://fake-ke/sc", headers={"Knowledge-Base-Id": "http://example.org/test#kb"} + "http://fake-ke/sc", + headers={"Knowledge-Base-Id": "http://example.org/test#kb"}, + ) + mock_post.assert_called_once_with( + "http://fake-ke/sc", + json={ + "knowledgeBaseId": "http://example.org/test#kb", + "knowledgeBaseName": "test-kb", + "knowledgeBaseDescription": "A KB for testing.", + }, ) - mock_post.assert_called_once() -def test_get_knowledge_base(client: Client): +async def test_get_knowledge_base(client: Client): mock_response = MagicMock() - mock_response.ok = True + mock_response.is_success = True mock_response.json.return_value = [ { "knowledgeBaseId": "http://example.org/test#kb", @@ -52,8 +70,13 @@ def test_get_knowledge_base(client: Client): } ] - with patch("requests.get", return_value=mock_response) as mock_get: - kb_info = client.get_knowledge_base("http://example.org/test#kb") + with patch.object( + client._http, + "get", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_get: + kb_info = await client.get_knowledge_base("http://example.org/test#kb") mock_get.assert_called_once_with( "http://fake-ke/sc", headers={"Knowledge-Base-Id": "http://example.org/test#kb"} @@ -65,12 +88,17 @@ def test_get_knowledge_base(client: Client): ) -def test_get_knowledge_base_not_found(client: Client): +async def test_get_knowledge_base_not_found(client: Client): mock_response = MagicMock() mock_response.status_code = 404 - with patch("requests.get", return_value=mock_response) as mock_get: - kb_info = client.get_knowledge_base("http://example.org/nonexistent-kb") + with patch.object( + client._http, + "get", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_get: + kb_info = await client.get_knowledge_base("http://example.org/nonexistent-kb") mock_get.assert_called_once_with( "http://fake-ke/sc", @@ -79,9 +107,9 @@ def test_get_knowledge_base_not_found(client: Client): assert kb_info is None -def test_get_knowledge_interactions(client: Client): +async def test_get_knowledge_interactions(client: Client): mock_response = MagicMock() - mock_response.ok = True + mock_response.is_success = True mock_response.json.return_value = [ { "knowledgeInteractionType": "AskKnowledgeInteraction", @@ -100,8 +128,13 @@ def test_get_knowledge_interactions(client: Client): }, ] - with patch("requests.get", return_value=mock_response) as mock_get: - interactions = client.get_all_knowledge_interactions( + with patch.object( + client._http, + "get", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_get: + interactions = await client.get_all_knowledge_interactions( "http://example.org/test#kb" ) @@ -121,15 +154,20 @@ def test_get_knowledge_interactions(client: Client): assert interactions[1].result_graph_pattern == "?s ?p ?o . " -def test_register_knowledge_interaction(client: Client): +async def test_register_knowledge_interaction(client: Client): mock_response = MagicMock() - mock_response.ok = True + mock_response.is_success = True mock_response.json.return_value = { "knowledgeInteractionId": "http://example.org/test#kb/interaction/ask-interaction" } - with patch("requests.post", return_value=mock_response): - registered_ki = client.register_ki( + with patch.object( + client._http, + "post", + new_callable=AsyncMock, + return_value=mock_response, + ): + registered_ki = await client.register_ki( kb_id="http://example.org/test#kb", ki=KnowledgeInteractionInfo( type="AskKnowledgeInteraction", diff --git a/tests/test_dependency_injection.py b/tests/test_dependency_injection.py index 6838059..f0e2cf7 100644 --- a/tests/test_dependency_injection.py +++ b/tests/test_dependency_injection.py @@ -22,7 +22,7 @@ def kb(): # --------------------------------------------------------------------------- -def test_handler_receives_injected_dependency(kb: KnowledgeBase): +async def test_handler_receives_injected_dependency(kb: KnowledgeBase): """Handler with a Depends-annotated param receives the factory's return value.""" class FakeDb: @@ -40,7 +40,7 @@ def handler( ) -> BindingSet: return [{"result": db.query()}] - result = kb.call([], "test-ki") + result = await kb.call([], "test-ki") assert result == [{"result": "db-result"}] @@ -49,7 +49,7 @@ def handler( # --------------------------------------------------------------------------- -def test_cached_dependency_factory_called_once(kb: KnowledgeBase): +async def test_cached_dependency_factory_called_once(kb: KnowledgeBase): """With cache=True (default), a shared factory is called only once per KI call.""" call_count = 0 @@ -72,7 +72,7 @@ def handler( assert db is svc return [] - kb.call([], "cache-ki") + await kb.call([], "cache-ki") assert call_count == 1 @@ -81,7 +81,7 @@ def handler( # --------------------------------------------------------------------------- -def test_uncached_dependency_factory_called_each_time(kb: KnowledgeBase): +async def test_uncached_dependency_factory_called_each_time(kb: KnowledgeBase): """With cache=False, the factory is called fresh for every dependent param.""" call_count = 0 @@ -100,7 +100,7 @@ def handler( assert a != b # different values: factory called twice return [] - kb.call([], "nocache-ki") + await kb.call([], "nocache-ki") assert call_count == 2 @@ -109,7 +109,7 @@ def handler( # --------------------------------------------------------------------------- -def test_transitive_dependency_resolution(kb: KnowledgeBase): +async def test_transitive_dependency_resolution(kb: KnowledgeBase): """A factory that declares its own Depends params is resolved transitively.""" class Config: @@ -133,7 +133,7 @@ def handler( ) -> BindingSet: return [{"url": db.url}] - result = kb.call([], "transitive-ki") + result = await kb.call([], "transitive-ki") assert result == [{"url": "sqlite://:memory:"}] @@ -142,7 +142,7 @@ def handler( # --------------------------------------------------------------------------- -def test_dependency_override_replaces_factory(kb: KnowledgeBase): +async def test_dependency_override_replaces_factory(kb: KnowledgeBase): """A factory listed in dependency_overrides is replaced at resolution time.""" class RealDb: @@ -163,18 +163,18 @@ def handler( return [{"db": db.name}] # Without override — uses real factory - assert kb.call([], "override-ki") == [{"db": "real"}] + assert await kb.call([], "override-ki") == [{"db": "real"}] # With override — uses fake factory kb.dependency_overrides[get_db] = lambda: FakeDb() - assert kb.call([], "override-ki") == [{"db": "fake"}] + assert await kb.call([], "override-ki") == [{"db": "fake"}] # Clear override — back to real kb.dependency_overrides.clear() - assert kb.call([], "override-ki") == [{"db": "real"}] + assert await kb.call([], "override-ki") == [{"db": "real"}] -def test_dependency_override_transitive(kb: KnowledgeBase): +async def test_dependency_override_transitive(kb: KnowledgeBase): """Overriding a transitive (nested) factory propagates through the chain.""" class Config: @@ -203,10 +203,10 @@ def handler( # Override the leaf dependency — get_db still runs but receives TestConfig kb.dependency_overrides[get_config] = lambda: TestConfig() - assert kb.call([], "transitive-override-ki") == [{"url": "test://db"}] + assert await kb.call([], "transitive-override-ki") == [{"url": "test://db"}] -def test_dependency_override_respects_cache(kb: KnowledgeBase): +async def test_dependency_override_respects_cache(kb: KnowledgeBase): """Override factory inherits the cache=True setting from the Depends declaration.""" call_count = 0 @@ -232,6 +232,199 @@ def handler( return [{"val": val}] kb.dependency_overrides[get_value] = fake_get_value - kb.call([], "cache-override-ki") + await kb.call([], "cache-override-ki") # fake_get_value should be called only once due to cache=True assert call_count == 1 + + +# --------------------------------------------------------------------------- +# Async factory: tracer bullet +# --------------------------------------------------------------------------- + + +async def test_async_factory_is_awaited(kb: KnowledgeBase): + """An async def factory is detected and awaited, handler receives its value.""" + + class AsyncDb: + def query(self): + return "async-db-result" + + async def get_async_db() -> AsyncDb: + return AsyncDb() + + @kb.answer_ki(name="async-ki", graph_pattern="?s ?p ?o .") + def handler( + binding_set: BindingSet, + info, + db: Annotated[AsyncDb, Depends(get_async_db)], + ) -> BindingSet: + return [{"result": db.query()}] + + result = await kb.call([], "async-ki") + assert result == [{"result": "async-db-result"}] + + +# --------------------------------------------------------------------------- +# Mixed sync/async transitive chain +# --------------------------------------------------------------------------- + + +async def test_mixed_sync_async_transitive_chain(kb: KnowledgeBase): + """Async factory depending on sync factory (and vice versa) resolves correctly.""" + + class Config: + url = "async://:memory:" + + class Db: + def __init__(self, config: Config): + self.url = config.url + + def get_config() -> Config: + return Config() + + async def get_db(config: Annotated[Config, Depends(get_config)]) -> Db: + return Db(config) + + @kb.answer_ki(name="mixed-ki", graph_pattern="?s ?p ?o .") + def handler( + binding_set: BindingSet, + info, + db: Annotated[Db, Depends(get_db)], + ) -> BindingSet: + return [{"url": db.url}] + + result = await kb.call([], "mixed-ki") + assert result == [{"url": "async://:memory:"}] + + +# --------------------------------------------------------------------------- +# cache=True for async factory +# --------------------------------------------------------------------------- + + +async def test_cached_async_factory_called_once(kb: KnowledgeBase): + """With cache=True (default), an async factory is called only once per KI call.""" + call_count = 0 + + async def get_value(): + nonlocal call_count + call_count += 1 + return object() + + async def get_service(val: Annotated[object, Depends(get_value)]): + return val + + @kb.answer_ki(name="async-cache-ki", graph_pattern="?s ?p ?o .") + def handler( + binding_set: BindingSet, + info, + val: Annotated[object, Depends(get_value)], + svc: Annotated[object, Depends(get_service)], + ) -> BindingSet: + assert val is svc + return [] + + await kb.call([], "async-cache-ki") + assert call_count == 1 + + +# --------------------------------------------------------------------------- +# cache=False for async factory +# --------------------------------------------------------------------------- + + +async def test_uncached_async_factory_called_each_time(kb: KnowledgeBase): + """With cache=False, an async factory is called fresh for every dependent param.""" + call_count = 0 + + async def get_value(): + nonlocal call_count + call_count += 1 + return call_count + + @kb.answer_ki(name="async-nocache-ki", graph_pattern="?s ?p ?o .") + def handler( + binding_set: BindingSet, + info, + a: Annotated[int, Depends(get_value, cache=False)], + b: Annotated[int, Depends(get_value, cache=False)], + ) -> BindingSet: + assert a != b + return [] + + await kb.call([], "async-nocache-ki") + assert call_count == 2 + + +# --------------------------------------------------------------------------- +# dependency_overrides with async replacement factory +# --------------------------------------------------------------------------- + + +async def test_dependency_override_with_async_replacement(kb: KnowledgeBase): + """A sync factory can be overridden by an async factory.""" + + class RealDb: + name = "real" + + class FakeDb: + name = "async-fake" + + def get_db() -> RealDb: + return RealDb() + + @kb.answer_ki(name="async-override-ki", graph_pattern="?s ?p ?o .") + def handler( + binding_set: BindingSet, + info, + db: Annotated[RealDb, Depends(get_db)], + ) -> BindingSet: + return [{"db": db.name}] + + # Override sync factory with async factory + async def async_fake_db(): + return FakeDb() + + kb.dependency_overrides[get_db] = async_fake_db + assert await kb.call([], "async-override-ki") == [{"db": "async-fake"}] + + +# --------------------------------------------------------------------------- +# Transitive override with async +# --------------------------------------------------------------------------- + + +async def test_dependency_override_transitive_with_async(kb: KnowledgeBase): + """Overriding a leaf sync factory with an async factory propagates.""" + + class Config: + url = "prod://db" + + class AsyncConfig: + url = "async-test://db" + + class Db: + def __init__(self, config): + self.url = config.url + + def get_config() -> Config: + return Config() + + def get_db(config: Annotated[Config, Depends(get_config)]) -> Db: + return Db(config) + + @kb.answer_ki(name="async-transitive-override-ki", graph_pattern="?s ?p ?o .") + def handler( + binding_set: BindingSet, + info, + db: Annotated[Db, Depends(get_db)], + ) -> BindingSet: + return [{"url": db.url}] + + async def async_get_config(): + return AsyncConfig() + + kb.dependency_overrides[get_config] = async_get_config + assert await kb.call([], "async-transitive-override-ki") == [ + {"url": "async-test://db"} + ] diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index c40a8ca..dd9e42a 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -32,7 +32,7 @@ class ResultBinding(BindingModel): # -- dispatch (ANSWER/REACT) ------------------------------------------------- -def test_dispatch_untyped_handler(): +async def test_dispatch_untyped_handler(): """dispatch() with a raw-BindingSet handler passes through without conversion.""" def handler(binding_set: BindingSet, info) -> BindingSet: @@ -45,11 +45,11 @@ def handler(binding_set: BindingSet, info) -> BindingSet: handler=handler, ) - result = ctx.dispatch([{"sensor": ""}]) + result = await ctx.dispatch([{"sensor": ""}]) assert result == [{"sensor": ""}] -def test_dispatch_typed_handler(): +async def test_dispatch_typed_handler(): """dispatch() validates incoming bindings and serializes outgoing ones.""" def handler(binding_set: list[SensorBinding], info) -> list[SensorBinding]: @@ -63,11 +63,11 @@ def handler(binding_set: list[SensorBinding], info) -> list[SensorBinding]: ) raw_input = [{"sensor": ""}] - result = ctx.dispatch(raw_input) + result = await ctx.dispatch(raw_input) assert result == [{"sensor": ""}] -def test_dispatch_react_typed(): +async def test_dispatch_react_typed(): """dispatch() works for REACT KIs with typed handlers.""" def handler(binding_set: list[MeasurementBinding], info) -> list[ResultBinding]: @@ -90,7 +90,7 @@ def handler(binding_set: list[MeasurementBinding], info) -> list[ResultBinding]: "value": '"42.0"^^', } ] - result = ctx.dispatch(raw) + result = await ctx.dispatch(raw) assert result == [{"measurement": ""}] @@ -167,3 +167,75 @@ def test_parse_result_empty_binding_set(): ) assert ctx.parse_result([]) == [] + + +# -- async handler dispatch --------------------------------------------------- + + +async def test_dispatch_async_handler(): + """dispatch() detects an async handler and awaits it directly.""" + + async def handler(binding_set: BindingSet, info) -> BindingSet: + return [{"sensor": b["sensor"]} for b in binding_set] + + ctx = KnowledgeInteractionContext( + info=AskAnswerInteractionInfo( + type=KiTypes.ANSWER, name="ki", prefixes={}, graph_pattern=GRAPH_PATTERN + ), + handler=handler, + ) + + result = await ctx.dispatch([{"sensor": ""}]) + assert result == [{"sensor": ""}] + + +async def test_dispatch_sync_handler_runs_in_thread(): + """dispatch() runs a sync handler via asyncio.to_thread (off the event loop).""" + import threading + + event_loop_thread = threading.current_thread() + handler_thread = None + + def handler(binding_set: BindingSet, info) -> BindingSet: + nonlocal handler_thread + handler_thread = threading.current_thread() + return binding_set + + ctx = KnowledgeInteractionContext( + info=AskAnswerInteractionInfo( + type=KiTypes.ANSWER, name="ki", prefixes={}, graph_pattern=GRAPH_PATTERN + ), + handler=handler, + ) + + await ctx.dispatch([{"sensor": ""}]) + assert handler_thread is not None + assert handler_thread is not event_loop_thread + + +async def test_dispatch_async_handler_via_decorator(): + """Decorator-registered async handler is detected as async and awaited.""" + import threading + + from knowledge_mapper import KnowledgeBase + + kb = KnowledgeBase( + id="http://example.org/test#kb", + name="test-kb", + description="test", + ke_url="http://fake-ke", + ) + + event_loop_thread = threading.current_thread() + handler_thread = None + + @kb.answer_ki(name="async-ki", graph_pattern=GRAPH_PATTERN) + async def my_handler(binding_set: BindingSet, info) -> BindingSet: + nonlocal handler_thread + handler_thread = threading.current_thread() + return binding_set + + result = await kb.call([{"sensor": ""}], "async-ki") + assert result == [{"sensor": ""}] + # Async handler runs on the event loop thread, not in a separate thread + assert handler_thread is event_loop_thread diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 7aa1b2d..83ca890 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -29,7 +29,7 @@ def sensor_handler(binding_set, info): return [{"sensor": sensor} for sensor in filtered_sensors] -def test_handler_with_untyped_binding_set(kb: KnowledgeBase): +async def test_handler_with_untyped_binding_set(kb: KnowledgeBase): @kb.answer_ki( name="test-untyped-answer-ki", graph_pattern=""" @@ -52,7 +52,7 @@ def test_untyped_answer_ki(binding_set: BindingSet, info) -> BindingSet: filtered_sensors = SENSORS return [{"sensor": sensor} for sensor in filtered_sensors] - result = kb.call( + result = await kb.call( [{"sensor": ""}], "test-untyped-answer-ki" ) assert result == [ @@ -60,7 +60,7 @@ def test_untyped_answer_ki(binding_set: BindingSet, info) -> BindingSet: ] -def test_handler_with_typed_binding_set(kb: KnowledgeBase): +async def test_handler_with_typed_binding_set(kb: KnowledgeBase): class TestBinding(BindingModel): sensor: Uri @@ -84,9 +84,57 @@ def test_answer_ki(binding_set: list[TestBinding], info) -> list[TestBinding]: filtered_sensors = SENSORS return [TestBinding(sensor=sensor) for sensor in filtered_sensors] - result = kb.call( + result = await kb.call( [{"sensor": ""}], "typed-answer-ki" ) assert result == [ {"sensor": ""}, ] + + +async def test_async_handler_with_untyped_binding_set(kb: KnowledgeBase): + @kb.answer_ki( + name="test-async-untyped-answer-ki", + graph_pattern=""" + ?sensor a ex:Sensor ; + """, + prefixes={"ex": "http://example.org/test#"}, + ) + async def test_async_untyped_answer_ki(binding_set: BindingSet, info) -> BindingSet: + return [ + binding + for binding in binding_set + if binding["sensor"] == "" + ] + + result = await kb.call( + [{"sensor": ""}], + "test-async-untyped-answer-ki", + ) + assert result == [ + {"sensor": ""}, + ] + + +async def test_async_handler_with_typed_binding_set(kb: KnowledgeBase): + class TestBinding(BindingModel): + sensor: Uri + + @kb.answer_ki( + name="async-typed-answer-ki", + graph_pattern=""" + ?sensor a ex:Sensor ; + """, + prefixes={"ex": "http://example.org/test#"}, + ) + async def test_async_answer_ki( + binding_set: list[TestBinding], info + ) -> list[TestBinding]: + return binding_set + + result = await kb.call( + [{"sensor": ""}], "async-typed-answer-ki" + ) + assert result == [ + {"sensor": ""}, + ] diff --git a/tests/test_handling_loop.py b/tests/test_handling_loop.py index de57adb..af83b88 100644 --- a/tests/test_handling_loop.py +++ b/tests/test_handling_loop.py @@ -1,5 +1,8 @@ """Tests for the handling loop using TestClient's enqueue methods.""" +import asyncio +import time + import pytest from knowledge_mapper import KnowledgeBase @@ -16,7 +19,7 @@ def client() -> TestClient: @pytest.fixture -def kb(client: TestClient) -> KnowledgeBase: +async def kb(client: TestClient) -> KnowledgeBase: kb = KnowledgeBase( id="http://example.org/test#kb", name="test-kb", @@ -37,41 +40,41 @@ def echo_handler( captured.append(binding_set) return binding_set - kb.register() + await kb.register() kb._test_captured = captured # type: ignore[attr-defined] return kb -def test_handle_dispatches_to_handler(kb: KnowledgeBase, client: TestClient): +async def test_handle_dispatches_to_handler(kb: KnowledgeBase, client: TestClient): """Enqueueing a HANDLE request dispatches to the handler and posts a response.""" input_bs: BindingSet = [{"s": "ex:A", "p": "ex:rel", "o": "ex:B"}] client.enqueue_handle_request("echo-ki", input_bs) - kb.start_handling_loop(loops=1) + await kb.start_handling_loop(loops=1) assert kb._test_captured == [input_bs] # type: ignore[attr-defined] assert client.last_handle_response == input_bs -def test_exit_stops_loop(kb: KnowledgeBase, client: TestClient): +async def test_exit_stops_loop(kb: KnowledgeBase, client: TestClient): """An EXIT signal terminates the loop without requiring a loops limit.""" client.enqueue_exit() - kb.start_handling_loop() # would hang without the EXIT signal + await kb.start_handling_loop() # would hang without the EXIT signal -def test_handle_then_exit(kb: KnowledgeBase, client: TestClient): +async def test_handle_then_exit(kb: KnowledgeBase, client: TestClient): """A HANDLE followed by EXIT processes the request and then stops.""" input_bs: BindingSet = [{"s": "ex:X"}] client.enqueue_handle_request("echo-ki", input_bs) client.enqueue_exit() - kb.start_handling_loop() + await kb.start_handling_loop() assert kb._test_captured == [input_bs] # type: ignore[attr-defined] assert client.last_handle_response == input_bs -def test_multiple_handle_requests(kb: KnowledgeBase, client: TestClient): +async def test_multiple_handle_requests(kb: KnowledgeBase, client: TestClient): """Multiple HANDLE requests are processed in order.""" bs1: BindingSet = [{"s": "ex:1"}] bs2: BindingSet = [{"s": "ex:2"}] @@ -79,7 +82,7 @@ def test_multiple_handle_requests(kb: KnowledgeBase, client: TestClient): client.enqueue_handle_request("echo-ki", bs2) client.enqueue_exit() - kb.start_handling_loop() + await kb.start_handling_loop() assert kb._test_captured == [bs1, bs2] # type: ignore[attr-defined] assert len(client._handle_responses) == 2 @@ -87,13 +90,186 @@ def test_multiple_handle_requests(kb: KnowledgeBase, client: TestClient): assert client._handle_responses[1][3] == bs2 -def test_repoll_fallback(kb: KnowledgeBase, client: TestClient): - """With nothing enqueued, a single loop iteration REPOLLs without error.""" - kb.start_handling_loop(loops=1) - assert kb._test_captured == [] # type: ignore[attr-defined] - - def test_enqueue_unknown_ki_raises(client: TestClient): """Enqueueing a handle request for an unregistered KI raises KeyError.""" with pytest.raises(KeyError, match="No registered KI named 'nonexistent'"): client.enqueue_handle_request("nonexistent", []) + + +# -- Concurrent handling loop tests ------------------------------------------ + + +async def test_concurrent_dispatch_overlaps_in_time(client: TestClient): + """Two slow handlers run concurrently — total wall time is less than 2x.""" + kb = KnowledgeBase( + id="http://example.org/test#kb", + name="test-kb", + description="test", + ke_url="http://fake-ke", + ) + kb.client = client + + handler_entries: list[float] = [] + handler_exits: list[float] = [] + + @kb.answer_ki(name="slow-ki", graph_pattern="?s ?p ?o .") + async def slow_handler( + binding_set: BindingSet, info: KnowledgeInteractionInfo + ) -> BindingSet: + handler_entries.append(time.monotonic()) + await asyncio.sleep(0.1) + handler_exits.append(time.monotonic()) + return binding_set + + await kb.register() + + client.enqueue_handle_request("slow-ki", [{"s": "ex:1"}]) + client.enqueue_handle_request("slow-ki", [{"s": "ex:2"}]) + client.enqueue_exit() + + t0 = time.monotonic() + await kb.start_handling_loop() + elapsed = time.monotonic() - t0 + + assert len(handler_entries) == 2 + assert len(handler_exits) == 2 + # If sequential, elapsed >= 0.2s. Concurrent should be ~0.1s. + assert elapsed < 0.18, f"Handlers ran sequentially (elapsed={elapsed:.3f}s)" + # Second handler started before first handler finished + assert handler_entries[1] < handler_exits[0], "Handlers did not overlap" + + +async def test_handler_exception_posts_empty_binding_set(client: TestClient): + """When a handler raises, an empty binding set is posted and the loop continues.""" + kb = KnowledgeBase( + id="http://example.org/test#kb", + name="test-kb", + description="test", + ke_url="http://fake-ke", + ) + kb.client = client + + @kb.answer_ki(name="boom-ki", graph_pattern="?s ?p ?o .") + async def boom_handler( + binding_set: BindingSet, info: KnowledgeInteractionInfo + ) -> BindingSet: + raise RuntimeError("handler exploded") + + @kb.answer_ki(name="ok-ki", graph_pattern="?s ?p ?o .") + async def ok_handler( + binding_set: BindingSet, info: KnowledgeInteractionInfo + ) -> BindingSet: + return binding_set + + await kb.register() + + client.enqueue_handle_request("boom-ki", [{"s": "ex:bad"}]) + client.enqueue_handle_request("ok-ki", [{"s": "ex:good"}]) + client.enqueue_exit() + + await kb.start_handling_loop() + + assert len(client._handle_responses) == 2 + # First response is the error — empty binding set + assert client._handle_responses[0][3] == [] + # Second response is the success + assert client._handle_responses[1][3] == [{"s": "ex:good"}] + + +async def test_exit_awaits_in_flight_handlers(client: TestClient): + """On EXIT, the loop waits for in-flight handlers to finish before returning.""" + kb = KnowledgeBase( + id="http://example.org/test#kb", + name="test-kb", + description="test", + ke_url="http://fake-ke", + ) + kb.client = client + + handler_completed = False + + @kb.answer_ki(name="slow-ki", graph_pattern="?s ?p ?o .") + async def slow_handler( + binding_set: BindingSet, info: KnowledgeInteractionInfo + ) -> BindingSet: + nonlocal handler_completed + await asyncio.sleep(0.1) + handler_completed = True + return binding_set + + await kb.register() + + # Handler starts, then EXIT arrives while handler is still running + client.enqueue_handle_request("slow-ki", [{"s": "ex:1"}]) + client.enqueue_exit() + + await kb.start_handling_loop() + + # The loop should have waited for the handler to complete + assert handler_completed, "Loop returned before in-flight handler finished" + assert len(client._handle_responses) == 1 + + +async def test_semaphore_bounds_concurrency(client: TestClient): + """No more than max_concurrent_handlers run at the same time.""" + kb = KnowledgeBase( + id="http://example.org/test#kb", + name="test-kb", + description="test", + ke_url="http://fake-ke", + ) + kb.client = client + + max_observed = 0 + current = 0 + lock = asyncio.Lock() + + @kb.answer_ki(name="counting-ki", graph_pattern="?s ?p ?o .") + async def counting_handler( + binding_set: BindingSet, info: KnowledgeInteractionInfo + ) -> BindingSet: + nonlocal max_observed, current + async with lock: + current += 1 + if current > max_observed: + max_observed = current + await asyncio.sleep(0.05) + async with lock: + current -= 1 + return binding_set + + await kb.register() + + # Enqueue 5 requests but allow only 2 concurrent + for i in range(5): + client.enqueue_handle_request("counting-ki", [{"s": f"ex:{i}"}]) + client.enqueue_exit() + + await kb.start_handling_loop(max_concurrent_handlers=2) + + assert len(client._handle_responses) == 5 + assert max_observed <= 2, f"Concurrency exceeded limit: {max_observed}" + + +async def test_event_loop_stored_on_kb(client: TestClient): + """start_handling_loop() stores the running event loop on the KB instance.""" + kb = KnowledgeBase( + id="http://example.org/test#kb", + name="test-kb", + description="test", + ke_url="http://fake-ke", + ) + kb.client = client + + @kb.answer_ki(name="noop-ki", graph_pattern="?s ?p ?o .") + async def noop( + binding_set: BindingSet, info: KnowledgeInteractionInfo + ) -> BindingSet: + return binding_set + + await kb.register() + client.enqueue_exit() + + assert not hasattr(kb, "_loop") + await kb.start_handling_loop() + assert kb._loop is asyncio.get_running_loop() diff --git a/tests/test_kb_lifespan.py b/tests/test_kb_lifespan.py index 48a2be4..c56d34e 100644 --- a/tests/test_kb_lifespan.py +++ b/tests/test_kb_lifespan.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -25,33 +25,44 @@ def kb(client: TestClient) -> KnowledgeBase: return kb -def test_connect_to_ke(kb: KnowledgeBase): - kb.connect() # Should not raise an exception +async def test_connect_to_ke(kb: KnowledgeBase): + await kb.connect() # Should not raise an exception -def test_connect_raises_if_ke_unavailable(kb: KnowledgeBase): +async def test_connect_raises_if_ke_unavailable(kb: KnowledgeBase): with ( - patch.object(kb.client, "ke_is_available", return_value=False), + patch.object( + kb.client, + "ke_is_available", + new_callable=AsyncMock, + return_value=False, + ), pytest.raises(KnowledgeEngineNotAvailableError), ): - kb.connect() + await kb.connect() -def test_register_unregister_cycle(kb: KnowledgeBase, client: TestClient): - kb.connect() - kb.register() +async def test_register_unregister_cycle(kb: KnowledgeBase, client: TestClient): + await kb.connect() + await kb.register() assert kb.state == KnowledgeBaseState.REGISTERED - assert client.get_knowledge_base(kb.info.id) is not None - kb.unregister() + assert await client.get_knowledge_base(kb.info.id) is not None + await kb.unregister() assert kb.state == KnowledgeBaseState.UNREGISTERED - assert client.get_knowledge_base(kb.info.id) is None + assert await client.get_knowledge_base(kb.info.id) is None -def test_unregister_without_registering(kb: KnowledgeBase): - kb.connect() - kb.unregister() # Should not raise an exception, just log a warning +async def test_unregister_without_registering(kb: KnowledgeBase): + await kb.connect() + await kb.unregister() # Should not raise an exception, just log a warning -def test_start_handling_loop_without_registering(kb: KnowledgeBase): +async def test_start_handling_loop_without_registering(kb: KnowledgeBase): with pytest.raises(RuntimeError): - kb.start_handling_loop(loops=1) + await kb.start_handling_loop(loops=1) + + +async def test_close_delegates_to_client(kb: KnowledgeBase, client: TestClient): + await kb.connect() + await kb.register() + await kb.close() # Should not raise; delegates to client.close() diff --git a/tests/test_ki_registration.py b/tests/test_ki_registration.py index 98b30e5..78fa0e4 100644 --- a/tests/test_ki_registration.py +++ b/tests/test_ki_registration.py @@ -48,76 +48,76 @@ def handler(binding_set: BindingSet, info: KnowledgeInteractionInfo) -> BindingS ) -def test_register_ki(): +async def test_register_ki(): kb = kb_setup() - kb.register() - kb.register_ki(ki_ctx=ki_ctx_setup()) + await kb.register() + await kb.register_ki(ki_ctx=ki_ctx_setup()) assert len(kb.ki_registry) == 1 ki_ctx = next(iter(kb.ki_registry.values())) assert ki_ctx.info.name == "test-ki" -def test_register_ki_before_kb_registration(): +async def test_register_ki_before_kb_registration(): kb = kb_setup() with pytest.raises(ValueError): - kb.register_ki(ki_ctx=ki_ctx_setup()) + await kb.register_ki(ki_ctx=ki_ctx_setup()) -def test_register_ki_old_name(): +async def test_register_ki_old_name(): kb = kb_setup() - kb.register() + await kb.register() ki_ctx = ki_ctx_setup() - kb.register_ki(ki_ctx=ki_ctx) + await kb.register_ki(ki_ctx=ki_ctx) with pytest.raises(ValueError): - kb.register_ki(ki_ctx=ki_ctx) + await kb.register_ki(ki_ctx=ki_ctx) -def test_register_ki_already_registered(): +async def test_register_ki_already_registered(): kb = kb_setup() - kb.register() + await kb.register() ki_ctx = ki_ctx_setup() ki_ctx.status = KnowledgeInteractionStatus.REGISTERED with pytest.raises(ValueError): - kb.register_ki(ki_ctx=ki_ctx) + await kb.register_ki(ki_ctx=ki_ctx) -def test_sync_ki(): +async def test_sync_ki(): kb = kb_setup() - kb.register() + await kb.register() ki_ctx = ki_ctx_setup() - kb.register_ki(ki_ctx=ki_ctx, defer_ke_registration=True) + await kb.register_ki(ki_ctx=ki_ctx, defer_ke_registration=True) assert len(kb.ki_registry) == 1 assert ( next(iter(kb.ki_registry.values())).status == KnowledgeInteractionStatus.UNREGISTERED ) - kb.sync_knowledge_interactions() + await kb.sync_knowledge_interactions() assert ( next(iter(kb.ki_registry.values())).status == KnowledgeInteractionStatus.REGISTERED ) -def test_sync_ki_before_kb_registration(): +async def test_sync_ki_before_kb_registration(): kb = kb_setup() with pytest.raises(ValueError): - kb.sync_knowledge_interactions() + await kb.sync_knowledge_interactions() -def test_unregister_ki_after_kb_unregistration(): +async def test_unregister_ki_after_kb_unregistration(): kb = kb_setup() - kb.register() + await kb.register() ki_ctx = ki_ctx_setup() - kb.register_ki(ki_ctx=ki_ctx) - kb.unregister() + await kb.register_ki(ki_ctx=ki_ctx) + await kb.unregister() assert ( next(iter(kb.ki_registry.values())).status == KnowledgeInteractionStatus.UNREGISTERED ) -def test_register_answer_ki(): +async def test_register_answer_ki(): kb = kb_setup() @kb.answer_ki( @@ -133,7 +133,7 @@ def answer_test( ) -> BindingSet: return binding_set - kb.register() + await kb.register() assert len(kb.ki_registry) == 1 ki_info = next(iter(kb.ki_registry.values())).info @@ -141,7 +141,7 @@ def answer_test( assert ki_info.type == KiTypes.ANSWER -def test_register_react_ki(): +async def test_register_react_ki(): kb = kb_setup() @kb.react_ki( @@ -161,7 +161,7 @@ def react_test( ) -> BindingSet: return binding_set - kb.register() + await kb.register() assert len(kb.ki_registry) == 1 ki_info = next(iter(kb.ki_registry.values())).info @@ -216,7 +216,7 @@ def bad_handler(): ) -def test_call_handler(): +async def test_call_handler(): kb = kb_setup() @kb.answer_ki( @@ -232,9 +232,9 @@ def echo_handler( ) -> BindingSet: return binding_set - kb.register() + await kb.register() ki_info = next(iter(kb.ki_registry.values())).info input_binding_set = [{"input": "test:Input1", "value": "Hello"}] - result = kb.call(binding_set=input_binding_set, ki_name=ki_info.name) + result = await kb.call(binding_set=input_binding_set, ki_name=ki_info.name) assert result == input_binding_set diff --git a/tests/test_sync_bridge.py b/tests/test_sync_bridge.py new file mode 100644 index 0000000..62628a6 --- /dev/null +++ b/tests/test_sync_bridge.py @@ -0,0 +1,125 @@ +"""Tests for ask_sync() / post_sync() — sync bridges for outgoing KI calls.""" + +import pytest + +from knowledge_mapper import KnowledgeBase +from knowledge_mapper.ke.models import BindingSet, KnowledgeInteractionInfo +from knowledge_mapper.testing import TestClient + + +@pytest.fixture +def client() -> TestClient: + return TestClient(fake_url="http://fake-ke") + + +@pytest.fixture +async def kb(client: TestClient) -> KnowledgeBase: + kb = KnowledgeBase( + id="http://example.org/test#kb", + name="test-kb", + description="A KB for testing sync bridges.", + ke_url="http://fake-ke", + ) + kb.client = client + return kb + + +async def test_ask_sync_outside_handling_loop_raises(kb: KnowledgeBase): + """ask_sync() raises RuntimeError when called without a running handling loop.""" + kb.ask_ki(name="my-ask", graph_pattern="?s ?p ?o .") + await kb.register() + await kb.sync_knowledge_interactions() + + with pytest.raises(RuntimeError, match="handling loop"): + kb.ask_sync([{}], ki_name="my-ask") + + +async def test_ask_sync_from_sync_handler(kb: KnowledgeBase, client: TestClient): + """A sync handler can call ask_sync() to query the KE network.""" + kb.ask_ki(name="lookup", graph_pattern="?s ?p ?o .") + await kb.register() + await kb.sync_knowledge_interactions() + + client.mock_result_binding_set( + ki_name="lookup", + binding_set=[{"s": "ex:found"}], + ) + + ask_result_capture: list = [] + + @kb.react_ki( + name="my-react", + argument_graph_pattern="?x a ?t .", + result_graph_pattern="?x a ?t .", + ) + def sync_handler( + binding_set: BindingSet, info: KnowledgeInteractionInfo + ) -> BindingSet: + result = kb.ask_sync([{}], ki_name="lookup") + ask_result_capture.append(result) + return binding_set + + await kb.sync_knowledge_interactions() + + client.enqueue_handle_request("my-react", [{"x": "ex:A", "t": "ex:Thing"}]) + client.enqueue_exit() + + await kb.start_handling_loop() + + assert len(ask_result_capture) == 1 + assert ask_result_capture[0] == [{"s": "ex:found"}] + + +async def test_post_sync_outside_handling_loop_raises(kb: KnowledgeBase): + """post_sync() raises RuntimeError when called without a running handling loop.""" + kb.post_ki( + name="my-post", + argument_graph_pattern="?s ?p ?o .", + result_graph_pattern="?s ?p ?o .", + ) + await kb.register() + await kb.sync_knowledge_interactions() + + with pytest.raises(RuntimeError, match="handling loop"): + kb.post_sync([{}], ki_name="my-post") + + +async def test_post_sync_from_sync_handler(kb: KnowledgeBase, client: TestClient): + """A sync handler can call post_sync() to push data to the KE network.""" + kb.post_ki( + name="push", + argument_graph_pattern="?x a ?t .", + result_graph_pattern="?x ex:storedBy ?kb .", + prefixes={"ex": "http://example.org/test#"}, + ) + await kb.register() + await kb.sync_knowledge_interactions() + + client.mock_result_binding_set( + ki_name="push", + binding_set=[{"x": "ex:A", "kb": "ex:myKB"}], + ) + + post_result_capture: list = [] + + @kb.react_ki( + name="my-react", + argument_graph_pattern="?x a ?t .", + result_graph_pattern="?x a ?t .", + ) + def sync_handler( + binding_set: BindingSet, info: KnowledgeInteractionInfo + ) -> BindingSet: + result = kb.post_sync([{"x": "ex:A", "t": "ex:Thing"}], ki_name="push") + post_result_capture.append(result) + return binding_set + + await kb.sync_knowledge_interactions() + + client.enqueue_handle_request("my-react", [{"x": "ex:B", "t": "ex:Other"}]) + client.enqueue_exit() + + await kb.start_handling_loop() + + assert len(post_result_capture) == 1 + assert post_result_capture[0] == [{"x": "ex:A", "kb": "ex:myKB"}] diff --git a/uv.lock b/uv.lock index 9467724..1cf9947 100644 --- a/uv.lock +++ b/uv.lock @@ -12,69 +12,24 @@ wheels = [ ] [[package]] -name = "certifi" -version = "2026.2.25" +name = "anyio" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] -name = "charset-normalizer" -version = "3.4.6" +name = "certifi" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, - { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, - { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, - { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, - { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, - { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, - { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, - { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, - { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, - { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, - { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, - { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, - { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, - { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, - { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, - { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, - { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, - { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, - { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, - { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, - { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, - { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, - { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, - { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, - { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, - { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, - { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, - { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, - { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, - { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, - { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, - { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, - { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, - { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, - { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, - { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] @@ -86,6 +41,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -109,29 +101,31 @@ name = "knowledge-mapper" version = "0.1.0a0" source = { editable = "." } dependencies = [ + { name = "httpx" }, { name = "pydantic" }, { name = "pydantic-settings", extra = ["yaml"] }, { name = "rdflib" }, - { name = "requests" }, ] [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "setuptools" }, ] [package.metadata] requires-dist = [ + { name = "httpx", specifier = ">=0.28" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, { name = "rdflib", specifier = ">=7.6.0" }, - { name = "requests", specifier = ">=2.32.5" }, ] [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", specifier = ">=0.26" }, { name = "setuptools", specifier = ">=82.0.1" }, ] @@ -274,6 +268,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -331,21 +337,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl", hash = "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd", size = 615416, upload-time = "2026-02-13T07:15:46.487Z" }, ] -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - [[package]] name = "setuptools" version = "82.0.1" @@ -375,12 +366,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] - -[[package]] -name = "urllib3" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, -]