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/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/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..624f944 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.
@@ -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(
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"