From b78280b1cdf84ffb4f2f21cc52361eb0d7d2604d Mon Sep 17 00:00:00 2001 From: david Date: Mon, 29 Jun 2026 15:53:36 +0200 Subject: [PATCH 1/3] Support dotted module specs and package-relative imports in CLI load_kb now accepts both 'path/to/file.py:attr' and 'pkg.module:attr'. When the path form points into a package (parent dir has __init__.py), the file is imported under its fully qualified name so relative imports resolve. Missing paths now raise FileNotFoundError instead of tripping an assertion. --- src/knowledge_mapper/cli.py | 62 ++++++++++++++++++++++++++++++------- tests/test_cli.py | 29 +++++++++++++++++ 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/knowledge_mapper/cli.py b/src/knowledge_mapper/cli.py index 321f813..37e414b 100644 --- a/src/knowledge_mapper/cli.py +++ b/src/knowledge_mapper/cli.py @@ -4,10 +4,12 @@ import asyncio import contextlib +import importlib import importlib.util import signal import sys from pathlib import Path +from types import ModuleType from typing import Any import typer @@ -24,25 +26,63 @@ def _main() -> None: def load_kb(spec: str) -> Any: - """Load a knowledge base instance from a ``path/to/file.py:attr`` spec. - - The file's parent directory is added to ``sys.path`` so the loaded module - can import sibling modules just like when run directly with ``python``. + """Load a knowledge base instance from a ``target:attr`` spec. + + ``target`` may be either: + + * A filesystem path to a ``.py`` file (e.g. ``path/to/file.py``). The + file's parent directory is added to ``sys.path`` so it can import + sibling modules. If the file lives inside a package (its directory + contains ``__init__.py``), the file is imported under its fully + qualified package name so relative imports resolve. + * A dotted module path (e.g. ``my_pkg.main``). The module is imported + via the normal import machinery and must be installed or otherwise + reachable on ``sys.path``. """ if ":" not in spec: - raise ValueError(f"Invalid spec {spec!r}: expected 'path/to/file.py:attr'.") - path_part, attr = spec.split(":", 1) - file = Path(path_part).resolve() + raise ValueError( + f"Invalid spec {spec!r}: expected 'path/to/file.py:attr' " + "or 'pkg.module:attr'." + ) + target, attr = spec.rsplit(":", 1) + + if _looks_like_path(target): + module = _import_from_path(target) + else: + module = importlib.import_module(target) + + return getattr(module, attr) + + +def _looks_like_path(target: str) -> bool: + return target.endswith(".py") or "/" in target or "\\" in target - parent = str(file.parent) - if parent not in sys.path: - sys.path.insert(0, parent) + +def _import_from_path(path_str: str) -> ModuleType: + file = Path(path_str).resolve() + if not file.is_file(): + raise FileNotFoundError(str(file)) + + pkg_parts: list[str] = [] + parent = file.parent + while (parent / "__init__.py").is_file(): + pkg_parts.insert(0, parent.name) + parent = parent.parent + + sys_path_entry = str(parent) + if sys_path_entry not in sys.path: + sys.path.insert(0, sys_path_entry) + + if pkg_parts: + qualified = ".".join([*pkg_parts, file.stem]) + return importlib.import_module(qualified) module_spec = importlib.util.spec_from_file_location(file.stem, file) assert module_spec is not None and module_spec.loader is not None module = importlib.util.module_from_spec(module_spec) + sys.modules[file.stem] = module module_spec.loader.exec_module(module) - return getattr(module, attr) + return module _SHUTDOWN_SIGNALS = (signal.SIGINT, signal.SIGTERM) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2b3a56e..e89da86 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -175,3 +175,32 @@ def test_load_kb_allows_sibling_imports(tmp_path: Path): result = load_kb(f"{file}:kb") assert result == "hello" + + +def test_load_kb_supports_relative_imports_when_file_lives_in_package( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + pkg = tmp_path / "my_pkg" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "sibling.py").write_text("MESSAGE = 'relative'\n") + file = pkg / "main.py" + file.write_text("from .sibling import MESSAGE\n\nkb = MESSAGE\n") + + result = load_kb(f"{file}:kb") + + assert result == "relative" + + +def test_load_kb_accepts_dotted_module_spec( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + pkg = tmp_path / "dotted_pkg" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "main.py").write_text("kb = 'from-dotted'\n") + monkeypatch.syspath_prepend(str(tmp_path)) + + result = load_kb("dotted_pkg.main:kb") + + assert result == "from-dotted" From 58bd2f1b8cc51232a8b960deb2e487662e49dbfe Mon Sep 17 00:00:00 2001 From: david Date: Fri, 3 Jul 2026 14:01:38 +0200 Subject: [PATCH 2/3] Add support for recipients in knowledge base --- src/knowledge_mapper/kb/knowledge_base.py | 19 +++++++++++++++---- src/knowledge_mapper/ke/client.py | 4 ++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/knowledge_mapper/kb/knowledge_base.py b/src/knowledge_mapper/kb/knowledge_base.py index f5579b0..2c57db0 100644 --- a/src/knowledge_mapper/kb/knowledge_base.py +++ b/src/knowledge_mapper/kb/knowledge_base.py @@ -16,6 +16,7 @@ BindingModel, BindingSet, KiTypes, + KnowledgeBaseId, KnowledgeBaseInfo, KnowledgeInteraction, KnowledgeInteractionId, @@ -526,7 +527,10 @@ async def call(self, binding_set: BindingSet, ki_name: str) -> BindingSet: ) async def post( - self, binding_set: Sequence[BindingModel] | BindingSet, ki_name: str + self, + binding_set: Sequence[BindingModel] | BindingSet, + ki_name: str, + recipients: list[KnowledgeBaseId] | None = None, ) -> Sequence[BindingModel] | BindingSet: """Invoke a POST KI by its name. @@ -551,11 +555,15 @@ async def post( kb_id=self.info.id, ki_id=ki_ctx.ke_id, binding_set=ki_ctx.prepare_outgoing(binding_set), + recipients=recipients, ) return ki_ctx.parse_result(post_result.result_binding_set) async def ask( - self, binding_set: Sequence[BindingModel] | BindingSet, ki_name: str + self, + binding_set: Sequence[BindingModel] | BindingSet, + ki_name: str, + recipients: list[KnowledgeBaseId] | None = None, ) -> Sequence[BindingModel] | BindingSet: """Invoke an ASK KI by its name. @@ -580,6 +588,7 @@ async def ask( kb_id=self.info.id, ki_id=ki_ctx.ke_id, binding_set=ki_ctx.prepare_outgoing(binding_set), + recipients=recipients, ) return ki_ctx.parse_result(ask_result.binding_set) @@ -601,6 +610,7 @@ def ask_sync( self, binding_set: Sequence[BindingModel] | BindingSet, ki_name: str, + recipients: list[KnowledgeBaseId] | None = None, ) -> Sequence[BindingModel] | BindingSet: """Blocking bridge to :meth:`ask` for use in sync handlers. @@ -613,7 +623,7 @@ def ask_sync( """ loop = self._require_loop() future = asyncio.run_coroutine_threadsafe( - self.ask(binding_set, ki_name=ki_name), loop + self.ask(binding_set, ki_name=ki_name, recipients=recipients), loop ) return future.result() @@ -621,6 +631,7 @@ def post_sync( self, binding_set: Sequence[BindingModel] | BindingSet, ki_name: str, + recipients: list[KnowledgeBaseId] | None = None, ) -> Sequence[BindingModel] | BindingSet: """Blocking bridge to :meth:`post` for use in sync handlers. @@ -633,7 +644,7 @@ def post_sync( """ loop = self._require_loop() future = asyncio.run_coroutine_threadsafe( - self.post(binding_set, ki_name=ki_name), loop + self.post(binding_set, ki_name=ki_name, recipients=recipients), loop ) return future.result() diff --git a/src/knowledge_mapper/ke/client.py b/src/knowledge_mapper/ke/client.py index e1f3516..128d90e 100644 --- a/src/knowledge_mapper/ke/client.py +++ b/src/knowledge_mapper/ke/client.py @@ -208,7 +208,7 @@ async def ask( kb_id: KnowledgeBaseId, ki_id: KnowledgeInteractionId, binding_set: BindingSet, - recipient_ids: list[KnowledgeBaseId] | None = None, + recipients: list[KnowledgeBaseId] | None = None, ) -> AskResult: """Execute an ASK interaction by sending the given binding set as the response to the KI call and returning the resulting binding set from the KE. @@ -226,7 +226,7 @@ async def post( kb_id: KnowledgeBaseId, ki_id: KnowledgeInteractionId, binding_set: BindingSet, - recipient_ids: list[KnowledgeBaseId] | None = None, + recipients: list[KnowledgeBaseId] | None = None, ) -> PostResult: """Execute a POST interaction by sending the given binding set as the response to the KI call. From 55144615b98c2122b33c76497bf8f71972a7fdf1 Mon Sep 17 00:00:00 2001 From: david Date: Fri, 3 Jul 2026 14:06:24 +0200 Subject: [PATCH 3/3] Fix argument name --- examples/09-sparql-store/data.trig | 8 ++++++++ examples/09-sparql-store/start.sh | 5 +++++ src/knowledge_mapper/ke/client.py | 12 ++++++------ src/knowledge_mapper/testing/fake_client.py | 4 ++-- 4 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 examples/09-sparql-store/data.trig create mode 100755 examples/09-sparql-store/start.sh diff --git a/examples/09-sparql-store/data.trig b/examples/09-sparql-store/data.trig new file mode 100644 index 0000000..5f3b7b1 --- /dev/null +++ b/examples/09-sparql-store/data.trig @@ -0,0 +1,8 @@ + +PREFIX ex: +PREFIX saref: + +ex:device + a saref:Device ; + saref:hasIdentifier "device-001" ; +. \ No newline at end of file diff --git a/examples/09-sparql-store/start.sh b/examples/09-sparql-store/start.sh new file mode 100755 index 0000000..a676222 --- /dev/null +++ b/examples/09-sparql-store/start.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +oxigraph_server load -f /start/data.ttl --location /data + +exec oxigraph_server serve --bind 0.0.0.0:7878 --location /data diff --git a/src/knowledge_mapper/ke/client.py b/src/knowledge_mapper/ke/client.py index 128d90e..624f944 100644 --- a/src/knowledge_mapper/ke/client.py +++ b/src/knowledge_mapper/ke/client.py @@ -473,13 +473,13 @@ async def post( kb_id: KnowledgeBaseId, ki_id: KnowledgeInteractionId, binding_set: BindingSet, - recipient_ids: list[KnowledgeBaseId] | None = None, + recipients: list[KnowledgeBaseId] | None = None, ) -> PostResult: - if recipient_ids is not None: + if recipients is not None: payload = { "bindingSet": binding_set, "recipientSelector": { - "knowledgeBases": recipient_ids, + "knowledgeBases": recipients, }, } else: @@ -504,13 +504,13 @@ async def ask( kb_id: KnowledgeBaseId, ki_id: KnowledgeInteractionId, binding_set: BindingSet, - recipient_ids: list[KnowledgeBaseId] | None = None, + recipients: list[KnowledgeBaseId] | None = None, ) -> AskResult: - if recipient_ids is not None: + if recipients is not None: payload = { "bindingSet": binding_set, "recipientSelector": { - "knowledgeBases": recipient_ids, + "knowledgeBases": recipients, }, } else: diff --git a/src/knowledge_mapper/testing/fake_client.py b/src/knowledge_mapper/testing/fake_client.py index 3a5e91b..b9322d7 100644 --- a/src/knowledge_mapper/testing/fake_client.py +++ b/src/knowledge_mapper/testing/fake_client.py @@ -209,7 +209,7 @@ async def ask( kb_id: KnowledgeBaseId, ki_id: KnowledgeInteractionId, binding_set: BindingSet, - recipient_ids: list[KnowledgeBaseId] | None = None, + recipients: list[KnowledgeBaseId] | None = None, ) -> AskResult: # Look up KI by ID to find its name, then check for a mocked result. ki = next( @@ -247,7 +247,7 @@ async def post( kb_id: KnowledgeBaseId, ki_id: KnowledgeInteractionId, binding_set: BindingSet, - recipient_ids: list[KnowledgeBaseId] | None = None, + recipients: list[KnowledgeBaseId] | None = None, ) -> PostResult: # Look up KI by ID to find its name, then check for a mocked result. ki = next(