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/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..1e4d213 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,6 +13,7 @@ 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_react_handlers.py** | Demonstrates async REACT handlers and how they differ from sync handlers | ## Prerequisites @@ -75,6 +76,7 @@ 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_react_handlers.py** to compare async and sync REACT handler behavior ## 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" }, -]