Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions examples/09-sparql-store/data.trig
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

PREFIX ex: <http://example.org/>
PREFIX saref: <https://saref.etsi.org/core/>

ex:device
a saref:Device ;
saref:hasIdentifier "device-001" ;
.
5 changes: 5 additions & 0 deletions examples/09-sparql-store/start.sh
Original file line number Diff line number Diff line change
@@ -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
62 changes: 51 additions & 11 deletions src/knowledge_mapper/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
19 changes: 15 additions & 4 deletions src/knowledge_mapper/kb/knowledge_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
BindingModel,
BindingSet,
KiTypes,
KnowledgeBaseId,
KnowledgeBaseInfo,
KnowledgeInteraction,
KnowledgeInteractionId,
Expand Down Expand Up @@ -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.

Expand All @@ -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.

Expand All @@ -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)

Expand All @@ -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.

Expand All @@ -613,14 +623,15 @@ 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()

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.

Expand All @@ -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()

Expand Down
16 changes: 8 additions & 8 deletions src/knowledge_mapper/ke/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/knowledge_mapper/testing/fake_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
29 changes: 29 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading