From 47c29c34bcc76c1e31f1630d98d4ea974dd036e8 Mon Sep 17 00:00:00 2001 From: david Date: Thu, 25 Jun 2026 18:19:53 +0200 Subject: [PATCH 1/2] Add `knowledge-mapper run` CLI subcommand (#10) Adds a typer-based CLI entry point so users can start a Python-defined KnowledgeBase without writing asyncio.run boilerplate: knowledge-mapper run path/to/file.py:kb The command: - loads the named KnowledgeBase attribute from the given Python file (the file's parent directory is added to sys.path so sibling imports work just like `python file.py`) - runs the full async lifecycle: connect, register, start_handling_loop - on SIGINT/SIGTERM cancels the handling loop and unregisters + closes the KB before exiting cleanly - still unregisters + closes if the handling loop raises Includes an example (examples/10-cli.py) that defines a KB without any asyncio.run or __main__ block, README/CONTEXT updates, and unit + typer CliRunner integration tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CONTEXT.md | 4 +- README.md | 11 +++ examples/10-cli.py | 40 ++++++++ examples/README.md | 12 +++ pyproject.toml | 4 + src/knowledge_mapper/cli.py | 98 ++++++++++++++++++++ tests/test_cli.py | 177 ++++++++++++++++++++++++++++++++++++ uv.lock | 69 ++++++++++++++ 8 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 examples/10-cli.py create mode 100644 src/knowledge_mapper/cli.py create mode 100644 tests/test_cli.py diff --git a/CONTEXT.md b/CONTEXT.md index 2309652..e7836ec 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -11,7 +11,7 @@ 1. **A Python SDK** — write Python code, use decorators, implement your own handlers. 2. **A settings/config-driven approach** (work in progress) — for simple cases where no custom handler logic is needed, a config file (YAML/JSON) plus a CLI should suffice. -Both approaches are planned and partially implemented. The SDK path is fully functional; the config-driven CLI path is tracked under issue #10. +Both approaches are planned and partially implemented. The SDK path is fully functional, and the `knowledge-mapper run path/to/file.py:kb` CLI starts an SDK-defined KB (issue #10). A future config-driven CLI (loading a `KnowledgeBase` from a YAML/JSON settings file) is tracked separately. --- @@ -418,7 +418,7 @@ The pre-overhaul, config-file-driven mapper implementation has been removed from |---|-------|-------| | [#5](https://github.com/TNO/knowledge-mapper/issues/5) | Make `result_pattern` optional for POST interactions | `PostReactInteractionInfo.result_graph_pattern` is currently required; should be optional | | [#6](https://github.com/TNO/knowledge-mapper/issues/6) | Allow domain knowledge loading via KE client | Extend `Client`/`ClientProtocol` with methods to load domain knowledge into the SC (supported since KE 1.3.1) | -| [#10](https://github.com/TNO/knowledge-mapper/issues/10) | Create simple CLI for starting KM | Entry point for the config-driven approach; part of the "both SDK and config-driven" roadmap | +| [#10](https://github.com/TNO/knowledge-mapper/issues/10) | Create simple CLI for starting KM | ✅ Done — `knowledge-mapper run path/to/file.py:kb` (typer-based, see `src/knowledge_mapper/cli.py`) | | [#11](https://github.com/TNO/knowledge-mapper/issues/11) | Add dependency injection system | Allow handlers to declare dependencies (e.g. DB connections) that are injected at call time | | [#15](https://github.com/TNO/knowledge-mapper/issues/15) | Create default handlers for POST and ASK interactions | ASK/POST KIs are now auto-registered from settings via `builder.build()` with no handler; outgoing-only KIs need no handler | | [#23](https://github.com/TNO/knowledge-mapper/issues/23) | Extract settings-based KI registration out of KnowledgeBase | ✅ Done — moved to `KnowledgeBaseBuilder` in `src/knowledge_base_builder.py` | diff --git a/README.md b/README.md index e3b8f0e..e8beca1 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,16 @@ For local development (from the repository root): pip install -e . ``` +## CLI + +Installing the package also provides a `knowledge-mapper` command. Use `run` to start a `KnowledgeBase` defined in a Python file, replacing the `asyncio.run(...)` boilerplate: + +```bash +knowledge-mapper run my_app.py:kb +``` + +Where `my_app.py` is the Python file and `kb` is the name of the `KnowledgeBase` variable. The command connects to the Knowledge Engine, registers the KB, runs the handling loop, and on `SIGINT` or `SIGTERM` unregisters and closes cleanly. + ## Examples The [`examples/`](./examples/) directory contains runnable examples covering all major features. Each example has inline comments explaining the code. @@ -65,6 +75,7 @@ The [`examples/`](./examples/) directory contains runnable examples covering all | [07-testing/](./examples/07-testing/) | Writing tests with the in-memory `TestClient` | | [08-async_handlers.py](./examples/08-async_handlers.py) | Async and sync REACT handlers side by side | | [09-sparql-store/](./examples/09-sparql-store/) | Connecting a SPARQL store as a knowledge base | +| [10-cli.py](./examples/10-cli.py) | Start a KB via the `knowledge-mapper run` CLI | See the [examples README](./examples/README.md) for prerequisites and setup instructions. diff --git a/examples/10-cli.py b/examples/10-cli.py new file mode 100644 index 0000000..e746e16 --- /dev/null +++ b/examples/10-cli.py @@ -0,0 +1,40 @@ +"""CLI example: define a KnowledgeBase and let ``knowledge-mapper run`` start it. + +This file deliberately contains no ``asyncio.run`` boilerplate or +``if __name__ == "__main__"`` block. The CLI takes care of the full lifecycle — +connect, register, run the handling loop, and unregister on Ctrl+C. + +Run with: + + knowledge-mapper run 10-cli.py:kb + +(from the ``examples/`` directory, with the package installed). Press Ctrl+C +to stop; the CLI handles SIGINT/SIGTERM and shuts the KB down cleanly. +""" + +from shared import get_example_logger + +from knowledge_mapper import KnowledgeBase + +EXAMPLE_NAME = "cli" +logger = get_example_logger(EXAMPLE_NAME) + +kb = KnowledgeBase( + id="http://example.org/knowledge-mapper/cli#kb", + name="example-cli-kb", + description="A KB started via the knowledge-mapper CLI.", + ke_url="http://localhost:8280/rest", +) + + +@kb.answer_ki( + name="example-answer-ki", + graph_pattern=""" + ?question a ex:Question . + ?question ex:hasText ?text . + """, + prefixes={"ex": "http://example.org/knowledge-mapper/cli#"}, +) +def example_answer_ki(binding_set, info): + logger.info("Handling a call to the example answer KI.") + return binding_set diff --git a/examples/README.md b/examples/README.md index 5c77b75..18ddbd0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,6 +15,7 @@ The best way to get started with the Knowledge Mapper is by exploring the exampl | **07-testing/** | Demonstrates how to write tests for your knowledge base using the fake client | | **08-async_handlers.py** | Demonstrates async REACT handlers and how they differ from sync handlers | | **09-sparql-store/** | Connecting a SPARQL store as a knowledge base | +| **10-cli.py** | Start a KB via the `knowledge-mapper run` CLI — no `asyncio.run` boilerplate | ## Prerequisites @@ -61,6 +62,17 @@ python 01-basic.py Most examples will start the knowledge mapper and connect to the Knowledge Engine. Press `Ctrl+C` to stop. +### Running via the CLI + +Example **10-cli.py** is started through the `knowledge-mapper` CLI instead of `python`: + +```bash +cd examples +knowledge-mapper run 10-cli.py:kb +``` + +The CLI handles `connect`, `register`, the handling loop, and graceful shutdown on `Ctrl+C`. + ### Running Tests The `07-testing/` example includes test examples. Run them with: diff --git a/pyproject.toml b/pyproject.toml index c1c1932..b4fa7ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,12 @@ dependencies = [ "pydantic>=2.12.5", "pydantic-settings[yaml]>=2.13.1", "rdflib>=7.6.0", + "typer>=0.15", ] +[project.scripts] +knowledge-mapper = "knowledge_mapper.cli:app" + [dependency-groups] dev = [ "pytest>=9.0.2", diff --git a/src/knowledge_mapper/cli.py b/src/knowledge_mapper/cli.py new file mode 100644 index 0000000..fc826bf --- /dev/null +++ b/src/knowledge_mapper/cli.py @@ -0,0 +1,98 @@ +"""CLI for the Knowledge Mapper. See ``knowledge-mapper --help``.""" + +from __future__ import annotations + +import asyncio +import contextlib +import importlib.util +import signal +import sys +from pathlib import Path +from typing import Any + +import typer + +app = typer.Typer( + help="Knowledge Mapper CLI.", + no_args_is_help=True, +) + + +@app.callback() +def _main() -> None: + """Knowledge Mapper CLI.""" + + +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``. + """ + 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() + + parent = str(file.parent) + if parent not in sys.path: + sys.path.insert(0, parent) + + 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) + module_spec.loader.exec_module(module) + return getattr(module, attr) + + +_SHUTDOWN_SIGNALS = (signal.SIGINT, signal.SIGTERM) + + +async def run_kb(kb: Any) -> None: + """Run the full lifecycle of a knowledge base. + + Connects, registers, and starts the handling loop. On SIGINT/SIGTERM the + loop is cancelled and the KB is unregistered and closed before returning. + """ + await kb.connect() + await kb.register() + + loop = asyncio.get_running_loop() + handling_task = asyncio.create_task(kb.start_handling_loop()) + + def _request_shutdown() -> None: + handling_task.cancel() + + installed: list[int] = [] + for sig in _SHUTDOWN_SIGNALS: + try: + loop.add_signal_handler(sig, _request_shutdown) + installed.append(sig) + except NotImplementedError: + pass + + try: + with contextlib.suppress(asyncio.CancelledError): + await handling_task + finally: + for sig in installed: + loop.remove_signal_handler(sig) + await kb.unregister() + await kb.close() + + +@app.command("run") +def run_command( + spec: str = typer.Argument( + ..., + metavar="PATH:ATTR", + help="Python file and attribute, e.g. 'my_app.py:kb'.", + ), +) -> None: + """Run a KnowledgeBase defined in a Python file.""" + kb = load_kb(spec) + asyncio.run(run_kb(kb)) + + diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..2b3a56e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,177 @@ +"""Tests for the ``knowledge-mapper`` CLI.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from knowledge_mapper.cli import load_kb, run_kb + + +class _FakeKB: + def __init__(self) -> None: + self.calls: list[str] = [] + + async def connect(self) -> None: + self.calls.append("connect") + + async def register(self) -> None: + self.calls.append("register") + + async def start_handling_loop(self) -> None: + self.calls.append("start_handling_loop") + + async def unregister(self) -> None: + self.calls.append("unregister") + + async def close(self) -> None: + self.calls.append("close") + + +def _write_module(tmp_path: Path, body: str) -> Path: + file = tmp_path / "user_app.py" + file.write_text(body) + return file + + +def test_load_kb_returns_named_attribute_from_file(tmp_path: Path): + file = _write_module( + tmp_path, + "class FakeKB:\n pass\n\nkb = FakeKB()\n", + ) + + result = load_kb(f"{file}:kb") + + assert type(result).__name__ == "FakeKB" + + +def test_load_kb_rejects_spec_without_colon(): + with pytest.raises(ValueError, match="path/to/file.py:attr"): + load_kb("no_colon_here.py") + + +def test_load_kb_raises_when_file_missing(tmp_path: Path): + missing = tmp_path / "no_such_file.py" + + with pytest.raises(FileNotFoundError, match=str(missing)): + load_kb(f"{missing}:kb") + + +def test_load_kb_raises_when_attribute_missing(tmp_path: Path): + file = _write_module(tmp_path, "other = 42\n") + + with pytest.raises(AttributeError, match="kb"): + load_kb(f"{file}:kb") + + +async def test_run_kb_invokes_full_lifecycle_in_order(): + kb = _FakeKB() + + await run_kb(kb) + + assert kb.calls == [ + "connect", + "register", + "start_handling_loop", + "unregister", + "close", + ] + + +async def test_run_kb_cleans_up_when_handling_loop_raises(): + class CrashingKB(_FakeKB): + async def start_handling_loop(self) -> None: + self.calls.append("start_handling_loop") + raise RuntimeError("boom") + + kb = CrashingKB() + + with pytest.raises(RuntimeError, match="boom"): + await run_kb(kb) + + assert kb.calls == [ + "connect", + "register", + "start_handling_loop", + "unregister", + "close", + ] + + +async def test_run_kb_shuts_down_gracefully_on_sigint(): + import asyncio + import os + import signal + + class HangingKB(_FakeKB): + async def start_handling_loop(self) -> None: + self.calls.append("start_handling_loop") + asyncio.get_running_loop().call_later( + 0.05, lambda: os.kill(os.getpid(), signal.SIGINT) + ) + await asyncio.Event().wait() + + kb = HangingKB() + await run_kb(kb) + + assert kb.calls == [ + "connect", + "register", + "start_handling_loop", + "unregister", + "close", + ] + + +def test_run_subcommand_executes_full_lifecycle(tmp_path: Path): + from typer.testing import CliRunner + + from knowledge_mapper.cli import app + + log = tmp_path / "calls.log" + kb_file = _write_module( + tmp_path, + f""" +from pathlib import Path + +LOG = Path({str(log)!r}) + +class FakeKB: + async def connect(self): + with LOG.open('a') as f: f.write('connect\\n') + async def register(self): + with LOG.open('a') as f: f.write('register\\n') + async def start_handling_loop(self): + with LOG.open('a') as f: f.write('start\\n') + async def unregister(self): + with LOG.open('a') as f: f.write('unregister\\n') + async def close(self): + with LOG.open('a') as f: f.write('close\\n') + +kb = FakeKB() +""", + ) + + result = CliRunner().invoke(app, ["run", f"{kb_file}:kb"]) + + assert result.exit_code == 0, result.output + assert log.read_text().splitlines() == [ + "connect", + "register", + "start", + "unregister", + "close", + ] + + +def test_load_kb_allows_sibling_imports(tmp_path: Path): + (tmp_path / "sibling.py").write_text("MESSAGE = 'hello'\n") + file = _write_module( + tmp_path, + "from sibling import MESSAGE\n\nkb = MESSAGE\n", + ) + + result = load_kb(f"{file}:kb") + + assert result == "hello" diff --git a/uv.lock b/uv.lock index 1cf9947..3c57b18 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -105,6 +114,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings", extra = ["yaml"] }, { name = "rdflib" }, + { name = "typer" }, ] [package.dev-dependencies] @@ -120,6 +130,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, { name = "rdflib", specifier = ">=7.6.0" }, + { name = "typer", specifier = ">=0.15" }, ] [package.metadata.requires-dev] @@ -129,6 +140,27 @@ dev = [ { name = "setuptools", specifier = ">=82.0.1" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -337,6 +369,19 @@ 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 = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "setuptools" version = "82.0.1" @@ -346,6 +391,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "typer" +version = "0.26.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From cf216f2a09a7b1367750e026b2d0798ff4084d85 Mon Sep 17 00:00:00 2001 From: david Date: Thu, 25 Jun 2026 18:26:31 +0200 Subject: [PATCH 2/2] Reformat --- src/knowledge_mapper/cli.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/knowledge_mapper/cli.py b/src/knowledge_mapper/cli.py index fc826bf..321f813 100644 --- a/src/knowledge_mapper/cli.py +++ b/src/knowledge_mapper/cli.py @@ -30,9 +30,7 @@ def load_kb(spec: str) -> Any: can import sibling modules just like when run directly with ``python``. """ if ":" not in spec: - raise ValueError( - f"Invalid spec {spec!r}: expected 'path/to/file.py:attr'." - ) + raise ValueError(f"Invalid spec {spec!r}: expected 'path/to/file.py:attr'.") path_part, attr = spec.split(":", 1) file = Path(path_part).resolve() @@ -94,5 +92,3 @@ def run_command( """Run a KnowledgeBase defined in a Python file.""" kb = load_kb(spec) asyncio.run(run_kb(kb)) - -