diff --git a/CONTEXT.md b/CONTEXT.md index 62a7bcb..fc56088 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -179,8 +179,20 @@ result = await kb.post(binding_set, ki_name="...") # Return ExchangeResult(binding_set=..., exchange_info=[ExchangeInfo, ...]) result = await kb.ask_with_info(binding_set, ki_name="...") result = await kb.post_with_info(binding_set, ki_name="...") + +# Typed results (issue #57): Sequence[MyModel] / ExchangeResult[ResultModel] +people = await kb.ask(binding_set, ki_name="...", binding_model=MyModel) +result = await kb.post_with_info( + binding_set, ki_name="...", result_binding_model=ResultModel +) ``` +The keyword-only `binding_model` (ASK) / `result_binding_model` (POST) argument on all +outgoing methods (incl. `*_sync`) only types the result; parsing still uses the model +registered for the KI, and a different model raises `ValueError`. Without it the result is +`Sequence[BindingModel | dict[str, str]]` (PEP 696 TypeVar default). `ExchangeResult[B]` is +generic in the binding type. + `ask()` / `post()` delegate to `ask_with_info()` / `post_with_info()` and drop the exchange info (issue #49). `ExchangeResult` is a frozen dataclass defined in `knowledge_interaction.py` (KB level, parsed bindings); `AskResult` / `PostResult` in `ke/models.py` remain the raw client diff --git a/README.md b/README.md index 45a2f61..77ccc44 100644 --- a/README.md +++ b/README.md @@ -146,10 +146,18 @@ result = await kb.ask(binding_set, ki_name="...") result = await kb.post(binding_set, ki_name="...") ``` +If the KI has a binding model, pass it to get a typed result (no `cast()` needed). It must be the model the KI was registered with, otherwise a `ValueError` is raised: + +```python +# Sequence[MyModel] / Sequence[ResultModel] +people = await kb.ask(binding_set, ki_name="...", binding_model=MyModel) +results = await kb.post(binding_set, ki_name="...", result_binding_model=ResultModel) +``` + To also get the exchange info reported by the Knowledge Engine (which knowledge bases were involved, with status and timing), use the `*_with_info` variants. They return an `ExchangeResult` with `binding_set` and `exchange_info` (a list of `ExchangeInfo`): ```python -result = await kb.ask_with_info(binding_set, ki_name="...") +result = await kb.ask_with_info(binding_set, ki_name="...", binding_model=MyModel) for info in result.exchange_info: print(info.knowledge_base_id, info.status, info.failed_message) ``` diff --git a/examples/03-ask_interaction.py b/examples/03-ask_interaction.py index f7add21..61f23ff 100644 --- a/examples/03-ask_interaction.py +++ b/examples/03-ask_interaction.py @@ -45,7 +45,8 @@ async def main(): await kb.register() logger.info("KB registered.") # ask_with_info() also returns the exchange info reported by the KE; use - # kb.ask() if you only need the bindings. + # kb.ask() if you only need the bindings. Passing binding_model types the + # result as PersonBindings. result = await kb.ask_with_info( [ PersonBinding( @@ -57,6 +58,7 @@ async def main(): ) ], "ask-ki", + binding_model=PersonBinding, ) logger.info(f"Received result from ASK KI: {result.binding_set}") for info in result.exchange_info: diff --git a/examples/04-post_measurement.py b/examples/04-post_measurement.py index fd16b32..494aa2e 100644 --- a/examples/04-post_measurement.py +++ b/examples/04-post_measurement.py @@ -80,6 +80,7 @@ async def main(): ) ], "post-measurement-ki", + result_binding_model=ResultBinding, ) logger.info(f"Received result bindings: {result_bindings}") await kb.unregister() diff --git a/examples/07-testing/kb.py b/examples/07-testing/kb.py index b8f7ab3..4985e51 100644 --- a/examples/07-testing/kb.py +++ b/examples/07-testing/kb.py @@ -47,7 +47,7 @@ class ExampleBinding(BindingModel): async def ask_for_values_of_subject(subject_name: str) -> list[str]: - result_binding_set: list[ExampleBinding] = await kb.ask( + result_binding_set = await kb.ask( [ ExampleBinding( s=URIRef(f"http://example.org/knowledge-mapper/testing#{subject_name}"), @@ -55,7 +55,8 @@ async def ask_for_values_of_subject(subject_name: str) -> list[str]: ) ], "ask-ki-with-binding-model", - ) # pyright: ignore[reportAssignmentType] + binding_model=ExampleBinding, + ) return ( [str(binding.value) for binding in result_binding_set] if result_binding_set @@ -98,7 +99,8 @@ async def repeat_value_post(value: str, iterations: int) -> list[URIRef]: ) ], "post-ki", - ) # type: ignore + result_binding_model=ResultBinding, + ) ) return [cast(URIRef, binding.other) for binding in result_binding_set] diff --git a/src/knowledge_mapper/kb/knowledge_base.py b/src/knowledge_mapper/kb/knowledge_base.py index 2f7a5c0..908773c 100644 --- a/src/knowledge_mapper/kb/knowledge_base.py +++ b/src/knowledge_mapper/kb/knowledge_base.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Sequence from enum import StrEnum from functools import wraps -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from ..ke import Client from ..ke.client import ClientProtocol, HandleRequest, PollResult @@ -38,6 +38,26 @@ logger = logging.getLogger(__name__) +# Item type of an outgoing call's result: a BindingModel subclass or a raw binding. +type _Binding = BindingModel | dict[str, str] + + +def _check_binding_model( + ki_ctx: KnowledgeInteractionContext[Any, ...], + ki_name: str, + binding_model: type[BindingModel | dict[str, str]] | None, +) -> None: + """Raise if a caller-supplied (result) binding model differs from the one the + KI was registered with, since results are parsed with the registered model.""" + if binding_model is None or binding_model is ki_ctx.validation_model: + return + registered = ki_ctx.validation_model + raise ValueError( + f"Binding model {binding_model.__name__} does not match the (result) " + f"binding model registered for KI '{ki_name}': " + f"{registered.__name__ if registered else None}." + ) + class KnowledgeBaseState(StrEnum): UNREGISTERED = "unregistered" @@ -540,35 +560,48 @@ async def call( requesting_kb_id=requesting_kb_id, ) - async def post( + async def post[R: _Binding = _Binding]( self, binding_set: Sequence[BindingModel] | BindingSet, ki_name: str, recipients: list[KnowledgeBaseId] | None = None, - ) -> Sequence[BindingModel] | BindingSet: + *, + result_binding_model: type[R] | None = None, + ) -> Sequence[R]: """Invoke a POST KI by its name and return the result bindings. - Use :meth:`post_with_info` to also get the exchange info. + Pass the KI's ``result_binding_model`` to have the result typed as a + sequence of that model. Use :meth:`post_with_info` to also get the + exchange info. Raises: KeyError: If ``ki_name`` is not found in the local KI registry. - ValueError: If the KI is not registered at the KE runtime. + ValueError: If the KI is not registered at the KE runtime, or + ``result_binding_model`` differs from the registered one. """ - result = await self.post_with_info(binding_set, ki_name, recipients) + result = await self.post_with_info( + binding_set, + ki_name, + recipients, + result_binding_model=result_binding_model, + ) return result.binding_set - async def post_with_info( + async def post_with_info[R: _Binding = _Binding]( self, binding_set: Sequence[BindingModel] | BindingSet, ki_name: str, recipients: list[KnowledgeBaseId] | None = None, - ) -> ExchangeResult: + *, + result_binding_model: type[R] | None = None, + ) -> ExchangeResult[R]: """Invoke a POST KI by its name and return the result bindings together with the exchange info reported by the KE. Raises: KeyError: If ``ki_name`` is not found in the local KI registry. - ValueError: If the KI is not registered at the KE runtime. + ValueError: If the KI is not registered at the KE runtime, or + ``result_binding_model`` differs from the registered one. """ ki_ctx = self.ki_registry[ki_name] if ki_ctx.definition.type != KiTypes.POST: @@ -576,6 +609,7 @@ async def post_with_info( f"KI named '{ki_name}' is of type {ki_ctx.definition.type}, not " f"POST, and cannot be called with the post() method." ) + _check_binding_model(ki_ctx, ki_name, result_binding_model) if ki_ctx.status != KnowledgeInteractionStatus.REGISTERED: raise ValueError( f"Cannot call KI '{ki_name}' because it is not registered. Please " @@ -590,39 +624,52 @@ async def post_with_info( recipients=recipients, ) return ExchangeResult( - binding_set=ki_ctx.parse_result(post_result.result_binding_set), + # Safe: parse_result() validates into the registered model, which + # _check_binding_model() verified equals R. + binding_set=cast( + Sequence[R], ki_ctx.parse_result(post_result.result_binding_set) + ), exchange_info=post_result.exchange_info, ) - async def ask( + async def ask[B: _Binding = _Binding]( self, binding_set: Sequence[BindingModel] | BindingSet, ki_name: str, recipients: list[KnowledgeBaseId] | None = None, - ) -> Sequence[BindingModel] | BindingSet: + *, + binding_model: type[B] | None = None, + ) -> Sequence[B]: """Invoke an ASK KI by its name and return the answer bindings. - Use :meth:`ask_with_info` to also get the exchange info. + Pass the KI's ``binding_model`` to have the result typed as a sequence + of that model. Use :meth:`ask_with_info` to also get the exchange info. Raises: KeyError: If ``ki_name`` is not found in the local KI registry. - ValueError: If the KI is not registered at the KE runtime. + ValueError: If the KI is not registered at the KE runtime, or + ``binding_model`` differs from the registered one. """ - result = await self.ask_with_info(binding_set, ki_name, recipients) + result = await self.ask_with_info( + binding_set, ki_name, recipients, binding_model=binding_model + ) return result.binding_set - async def ask_with_info( + async def ask_with_info[B: _Binding = _Binding]( self, binding_set: Sequence[BindingModel] | BindingSet, ki_name: str, recipients: list[KnowledgeBaseId] | None = None, - ) -> ExchangeResult: + *, + binding_model: type[B] | None = None, + ) -> ExchangeResult[B]: """Invoke an ASK KI by its name and return the answer bindings together with the exchange info reported by the KE. Raises: KeyError: If ``ki_name`` is not found in the local KI registry. - ValueError: If the KI is not registered at the KE runtime. + ValueError: If the KI is not registered at the KE runtime, or + ``binding_model`` differs from the registered one. """ ki_ctx = self.ki_registry[ki_name] if ki_ctx.definition.type != KiTypes.ASK: @@ -630,6 +677,7 @@ async def ask_with_info( f"KI named '{ki_name}' is of type {ki_ctx.definition.type}, not " f"ASK, and cannot be called with the ask() method." ) + _check_binding_model(ki_ctx, ki_name, binding_model) if ki_ctx.status != KnowledgeInteractionStatus.REGISTERED: raise ValueError( f"Cannot call KI '{ki_name}' because it is not registered. Please " @@ -644,7 +692,9 @@ async def ask_with_info( recipients=recipients, ) return ExchangeResult( - binding_set=ki_ctx.parse_result(ask_result.binding_set), + # Safe: parse_result() validates into the registered model, which + # _check_binding_model() verified equals B. + binding_set=cast(Sequence[B], ki_ctx.parse_result(ask_result.binding_set)), exchange_info=ask_result.exchange_info, ) @@ -662,12 +712,14 @@ def _require_loop(self) -> asyncio.AbstractEventLoop: ) return loop - def ask_sync( + def ask_sync[B: _Binding = _Binding]( self, binding_set: Sequence[BindingModel] | BindingSet, ki_name: str, recipients: list[KnowledgeBaseId] | None = None, - ) -> Sequence[BindingModel] | BindingSet: + *, + binding_model: type[B] | None = None, + ) -> Sequence[B]: """Blocking bridge to :meth:`ask` for use in sync handlers. Schedules the async ``ask()`` coroutine on the event loop stored by @@ -679,16 +731,24 @@ def ask_sync( """ loop = self._require_loop() future = asyncio.run_coroutine_threadsafe( - self.ask(binding_set, ki_name=ki_name, recipients=recipients), loop + self.ask( + binding_set, + ki_name=ki_name, + recipients=recipients, + binding_model=binding_model, + ), + loop, ) return future.result() - def post_sync( + def post_sync[R: _Binding = _Binding]( self, binding_set: Sequence[BindingModel] | BindingSet, ki_name: str, recipients: list[KnowledgeBaseId] | None = None, - ) -> Sequence[BindingModel] | BindingSet: + *, + result_binding_model: type[R] | None = None, + ) -> Sequence[R]: """Blocking bridge to :meth:`post` for use in sync handlers. Schedules the async ``post()`` coroutine on the event loop stored by @@ -700,16 +760,24 @@ def post_sync( """ loop = self._require_loop() future = asyncio.run_coroutine_threadsafe( - self.post(binding_set, ki_name=ki_name, recipients=recipients), loop + self.post( + binding_set, + ki_name=ki_name, + recipients=recipients, + result_binding_model=result_binding_model, + ), + loop, ) return future.result() - def ask_with_info_sync( + def ask_with_info_sync[B: _Binding = _Binding]( self, binding_set: Sequence[BindingModel] | BindingSet, ki_name: str, recipients: list[KnowledgeBaseId] | None = None, - ) -> ExchangeResult: + *, + binding_model: type[B] | None = None, + ) -> ExchangeResult[B]: """Blocking bridge to :meth:`ask_with_info` for use in sync handlers. Raises: @@ -717,17 +785,24 @@ def ask_with_info_sync( """ loop = self._require_loop() future = asyncio.run_coroutine_threadsafe( - self.ask_with_info(binding_set, ki_name=ki_name, recipients=recipients), + self.ask_with_info( + binding_set, + ki_name=ki_name, + recipients=recipients, + binding_model=binding_model, + ), loop, ) return future.result() - def post_with_info_sync( + def post_with_info_sync[R: _Binding = _Binding]( self, binding_set: Sequence[BindingModel] | BindingSet, ki_name: str, recipients: list[KnowledgeBaseId] | None = None, - ) -> ExchangeResult: + *, + result_binding_model: type[R] | None = None, + ) -> ExchangeResult[R]: """Blocking bridge to :meth:`post_with_info` for use in sync handlers. Raises: @@ -735,7 +810,12 @@ def post_with_info_sync( """ loop = self._require_loop() future = asyncio.run_coroutine_threadsafe( - self.post_with_info(binding_set, ki_name=ki_name, recipients=recipients), + self.post_with_info( + binding_set, + ki_name=ki_name, + recipients=recipients, + result_binding_model=result_binding_model, + ), loop, ) return future.result() diff --git a/src/knowledge_mapper/knowledge_interaction.py b/src/knowledge_mapper/knowledge_interaction.py index 969e517..d88b184 100644 --- a/src/knowledge_mapper/knowledge_interaction.py +++ b/src/knowledge_mapper/knowledge_interaction.py @@ -33,10 +33,11 @@ class KnowledgeInteractionStatus(StrEnum): @dataclass(frozen=True) -class ExchangeResult: +class ExchangeResult[B = BindingModel | dict[str, str]]: """Result of an outgoing ASK/POST call, including exchange metadata. - Returned by ``KnowledgeBase.ask_with_info()`` / ``post_with_info()``. + Returned by ``KnowledgeBase.ask_with_info()`` / ``post_with_info()``. ``B`` + is the (result) binding model passed to those calls, if any. Attributes: binding_set: The (result) bindings, parsed into ``BindingModel`` @@ -45,7 +46,7 @@ class ExchangeResult: with, including its status and timing. """ - binding_set: Sequence[BindingModel] | BindingSet + binding_set: Sequence[B] exchange_info: list[ExchangeInfo] diff --git a/tests/test_ask_and_post.py b/tests/test_ask_and_post.py index 4723b7e..dae6f61 100644 --- a/tests/test_ask_and_post.py +++ b/tests/test_ask_and_post.py @@ -310,3 +310,110 @@ async def test_ask_with_info_rejects_non_ask_ki(kb: KnowledgeBase): with pytest.raises(ValueError, match="not ASK"): await kb.ask_with_info([], "not-an-ask") + + +# --------------------------------------------------------------------------- +# binding_model / result_binding_model keyword: typed results +# --------------------------------------------------------------------------- + + +class StoredByBinding(BindingModel): + person: Uri + kb: Uri + + +async def test_ask_with_binding_model_returns_parsed_bindings( + kb: KnowledgeBase, client: TestClient +): + kb.ask_ki( + name="ask-typed-ki", + graph_pattern="?person ex:hasName ?name .", + prefixes={"ex": "http://example.org/test#"}, + binding_model=PersonBinding, + ) + await kb.sync_knowledge_interactions() + client.mock_result_binding_set( + ki_name="ask-typed-ki", + binding_set=[ + {"person": "", "name": '"Alice"^^xsd:string'} + ], + ) + + result = await kb.ask([], "ask-typed-ki", binding_model=PersonBinding) + with_info = await kb.ask_with_info([], "ask-typed-ki", binding_model=PersonBinding) + + expected = [ + PersonBinding(person=URIRef("http://example.org/test#p1"), name="Alice") + ] + assert result == expected + assert with_info.binding_set == expected + + +async def test_post_with_result_binding_model_returns_parsed_bindings( + kb: KnowledgeBase, client: TestClient +): + kb.post_ki( + name="post-typed-ki", + argument_graph_pattern="?person ex:hasName ?name .", + result_graph_pattern="?person ex:storedBy ?kb .", + prefixes={"ex": "http://example.org/test#"}, + argument_binding_model=PersonBinding, + result_binding_model=StoredByBinding, + ) + await kb.sync_knowledge_interactions() + client.mock_result_binding_set( + ki_name="post-typed-ki", + binding_set=[ + { + "person": "", + "kb": "", + } + ], + ) + + result = await kb.post( + [PersonBinding(person=URIRef("http://example.org/test#p1"), name="Bob")], + "post-typed-ki", + result_binding_model=StoredByBinding, + ) + + assert result == [ + StoredByBinding( + person=URIRef("http://example.org/test#p1"), + kb=URIRef("http://example.org/test#kb"), + ) + ] + + +async def test_ask_rejects_binding_model_not_registered_for_ki(kb: KnowledgeBase): + kb.ask_ki( + name="ask-typed-ki", + graph_pattern="?person ex:hasName ?name .", + prefixes={"ex": "http://example.org/test#"}, + binding_model=PersonBinding, + ) + kb.ask_ki(name="ask-raw-ki", graph_pattern="?s ?p ?o .") + await kb.sync_knowledge_interactions() + + with pytest.raises(ValueError, match="does not match"): + await kb.ask([], "ask-typed-ki", binding_model=StoredByBinding) + with pytest.raises(ValueError, match="does not match"): + await kb.ask([], "ask-raw-ki", binding_model=PersonBinding) + + +async def test_post_rejects_result_binding_model_not_registered_for_ki( + kb: KnowledgeBase, +): + kb.post_ki( + name="post-typed-ki", + argument_graph_pattern="?person ex:hasName ?name .", + result_graph_pattern="?person ex:storedBy ?kb .", + prefixes={"ex": "http://example.org/test#"}, + argument_binding_model=PersonBinding, + result_binding_model=StoredByBinding, + ) + await kb.sync_knowledge_interactions() + + # The argument model is not the result model. + with pytest.raises(ValueError, match="does not match"): + await kb.post([], "post-typed-ki", result_binding_model=PersonBinding) diff --git a/tests/test_sync_bridge.py b/tests/test_sync_bridge.py index 190a320..3da60fd 100644 --- a/tests/test_sync_bridge.py +++ b/tests/test_sync_bridge.py @@ -3,7 +3,7 @@ import pytest from knowledge_mapper import ExchangeResult, KnowledgeBase -from knowledge_mapper.ke.models import BindingSet, KnowledgeInteraction +from knowledge_mapper.ke.models import BindingModel, BindingSet, KnowledgeInteraction from knowledge_mapper.testing import TestClient @@ -172,3 +172,53 @@ def sync_handler(binding_set: BindingSet, info: KnowledgeInteraction) -> Binding assert post_result.binding_set == [{"s": "ex:stored"}] assert len(ask_result.exchange_info) == 1 assert len(post_result.exchange_info) == 1 + + +class ThingBinding(BindingModel): + s: str + + +async def test_sync_bridges_pass_binding_model_through( + kb: KnowledgeBase, client: TestClient +): + """The sync bridges forward binding_model / result_binding_model, so a + mismatching model is rejected just like in the async methods.""" + kb.ask_ki(name="lookup", graph_pattern="?s ?p ?o .") + kb.post_ki( + name="push", + argument_graph_pattern="?s ?p ?o .", + result_graph_pattern="?s ?p ?o .", + ) + await kb.register() + + errors: list[Exception] = [] + + @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: KnowledgeInteraction) -> BindingSet: + calls = [ + lambda: kb.ask_sync([{}], "lookup", binding_model=ThingBinding), + lambda: kb.ask_with_info_sync([{}], "lookup", binding_model=ThingBinding), + lambda: kb.post_sync([{}], "push", result_binding_model=ThingBinding), + lambda: kb.post_with_info_sync( + [{}], "push", result_binding_model=ThingBinding + ), + ] + for call in calls: + try: + call() + except ValueError as e: + errors.append(e) + 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(errors) == 4 + assert all("does not match" in str(e) for e in errors)