diff --git a/.gitignore b/.gitignore index dc1ba37..a362bb8 100644 --- a/.gitignore +++ b/.gitignore @@ -69,5 +69,8 @@ playground /osw_files/ */accounts.pwd.yaml /accounts.pwd.yaml -.ign -.claude + +# Local folders +.ign/ +.claude/ +graphify-out/ diff --git a/README.md b/README.md index 40bad96..3c19612 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,19 @@ More runnable scripts live in [examples/](examples/), and the [Basics tutorial](docs/tutorials/basics.ipynb) walks through the OpenSemanticLab data model. +## CLI and MCP tools + +Installing `osw` also installs an `osw` command line client, and the separate +`osw[mcp]` extra adds an MCP server that exposes a live instance to agent +clients such as Claude Code: + +```bash +osw search ask '[[Category:Item]]' --limit 5 +``` + +Commands, tools and their configuration are described in the +[CLI and MCP guide](https://opensemanticlab.github.io/osw-python/cli-and-mcp/). + ## Contributing Contributions are welcome, see [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/docs/cli-and-mcp.md b/docs/cli-and-mcp.md new file mode 100644 index 0000000..94f170d --- /dev/null +++ b/docs/cli-and-mcp.md @@ -0,0 +1,343 @@ +# CLI and MCP tools + +Besides the Python API, osw ships two adapters that talk to a live instance: +the `osw` command line client, and an MCP server for agent clients such as +Claude Code. Both run the same operations from one shared, SDK-free core +(`osw.service`), so a command and its matching tool behave identically. They +differ in exactly one way: only the CLI accepts filesystem paths. + +## Setup + +Install one of the two; the second includes the first: + +```bash +uv tool install osw # the `osw` command +uv tool install "osw[mcp]" # the same, plus the `osw-mcp` server +``` + +
+Other ways to install + +```bash +pip install "osw[mcp]" # into the active environment +uv add "osw[mcp]" # as a dependency of the current uv project +uvx --from "osw[mcp]" osw-mcp # run the server without installing it +``` + +`uvx` is what the registration examples further down use, so the server needs +no install of its own. + +
+ +`osw[mcp]` is not part of `osw[all]`, see [Design notes](#design-notes). The +other extras are listed in the +[Get Started guide](get-started.md#optional-extras). + +Both adapters need an instance and credentials. The quickest start is a +gitignored `.env` file in your project root: + +```dotenv +OSW_DOMAIN=wiki-dev.open-semantic-lab.org +OSW_USERNAME=your-user +OSW_PASSWORD=your-password +``` + +The CLI finds that file by searching upward from the working directory, so +`osw status` now reports the instance and connection state. The MCP server does +not search: its settings come from the `env` block of its registration, see +[Registering a server](#registering-a-server). Everything that can be set is +listed under [Configuration](#configuration). + +## Command line + +```bash +osw status +osw search ask '[[Category:Item]]' --limit 5 +osw entity get 'Item:OSW1234...' --json | jq . +osw file cat 'File:Example.csv' # inline text +osw file download 'File:Example.csv' --target-dir ./tmp # to disk +``` + +Commands are grouped by subject: + +| Group | Commands | +| --- | --- | +| `entity` | `get`, `put`, `export`, `delete` | +| `file` | `info`, `cat`, `write`, `download`, `upload` | +| `search` | `ask`, `text`, `instances`, `sparql` | +| `slot` | `list`, `get`, `set` | +| `schema` | `get` | +| `instance` | `list` | +| `ledger` | `path` | +| top level | `status` | + +Global options apply to every command: + +- `--instance IRI` picks the instance. Optional: it is only required when + `OSW_DOMAIN` is not set and the configured credential file holds more than + one iri. +- `--json` / `-j` writes machine-readable JSON to stdout and keeps osw's own + progress output on stderr, so it pipes cleanly into `jq`. +- `--read-only` refuses write operations. +- `--verbose` / `-v` shows full tracebacks instead of a one-line message. + +Failures exit non-zero with a short message on stderr and no traceback. + +## MCP server + +`osw[mcp]` ships an [MCP](https://modelcontextprotocol.io) server that exposes a +live OpenSemanticLab instance to MCP clients such as Claude Code. It wraps +`OswExpress` and provides tools to search (semantic / SPARQL / full-text), +introspect category schemas, read entities and every page slot, create/update +and delete entities, and read and write file pages as text. The transport is +stdio; SSE and HTTP are not supported. + +**No filesystem access:** no MCP tool takes or returns a local path. File +content moves inline as text (`get_file_info`, `read_file_text`, +`write_file_text`), and everything path-based lives in the CLI instead +(`osw file download`, `osw file upload`, `osw ledger path`). + +**One server per instance:** each server process is pinned to exactly one OSL +instance for its whole lifetime; there is no tool to switch at runtime. +`OSW_DOMAIN` must be set, either in the server entry's `env` block or in the +`.env` file that entry names. Without it the server refuses to start rather than +register tools that would all fail. + +### Registering a server + +A server entry can carry its settings in two ways: + +- **Directly in the entry's `env` block.** Every variable from the + [reference table](#variable-reference) can be set there, so no `.env` file is + needed at all. +- **In a `.env` file**, named by `OSW_ENV_FILE` in the `env` block. Useful when + several tools share one settings file, or when the client config is committed + and the settings file is not. + +Prefer the `env` block naming `OSW_CRED_FILEPATH` and `OSW_DOMAIN`: the +destination instance is spelled out in the entry itself, so it is visible at a +glance and in a diff rather than one indirection away. The secret stays out of +the client config either way, since a credential file contributes a path and an +instance name and nothing else. Never put `OSW_PASSWORD` inline in a committed +`.mcp.json`. + +```json +{ + "mcpServers": { + "osw": { + "type": "stdio", + "command": "uvx", + "args": ["--from", "osw[mcp]", "osw-mcp"], + "env": { + "OSW_CRED_FILEPATH": "/abs/path/to/accounts.pwd.yaml", + "OSW_DOMAIN": "wiki-dev.open-semantic-lab.org" + } + } + } +} +``` + +At startup the server checks that the credential file has an entry matching +`OSW_DOMAIN` and, if not, names the iris the file does contain (never their +secrets), so a typo surfaces immediately rather than on the first tool call. + +Registering the same entry from a shell is easiest with `add-json`, which takes +it verbatim. Note that a Windows path needs forward slashes or doubled +backslashes to be valid JSON: + +```bash +claude mcp add-json osw '{"type":"stdio","command":"uvx","args":["--from","osw[mcp]","osw-mcp"],"env":{"OSW_CRED_FILEPATH":"/abs/path/to/accounts.pwd.yaml","OSW_DOMAIN":"wiki-dev.open-semantic-lab.org"}}' +``` + +### More than one instance + +Register one server per instance, each pinned to a single `OSW_DOMAIN`. The two +entries below show both styles side by side: `osw-dev` puts everything in a +`.env` file, `osw-prod` names the credential file and the domain directly. One +credential file can serve any number of servers, since it is keyed by iri. + +```json +{ + "mcpServers": { + "osw-dev": { + "type": "stdio", + "command": "uvx", + "args": ["--from", "osw[mcp]", "osw-mcp"], + "env": { "OSW_ENV_FILE": "/abs/path/to/dev.env" } + }, + "osw-prod": { + "type": "stdio", + "command": "uvx", + "args": ["--from", "osw[mcp]", "osw-mcp"], + "env": { + "OSW_CRED_FILEPATH": "/abs/path/to/accounts.pwd.yaml", + "OSW_DOMAIN": "wiki.open-semantic-lab.org", + "OSW_READ_ONLY": "true" + } + } + } +} +``` + +`dev.env` has to pin the instance itself, since the server will not infer one: + +```dotenv +OSW_DOMAIN=wiki-dev.open-semantic-lab.org +OSW_CRED_FILEPATH=/abs/path/to/accounts.pwd.yaml +``` + +The instance is then part of the tool name at every call site +(`mcp__osw-prod__get_entity`), so the destination is visible in the permission +prompt, read-only is settable per instance, and permissions can differ per +instance: + +```json +{ + "permissions": { + "allow": ["mcp__osw-dev"], + "ask": ["mcp__osw-prod"] + } +} +``` + +### Notes and caveats + +- `status` reports the active instance and connection state, never the password. +- **Safe deletes:** the server records every entity it creates or modifies in a + local provenance ledger. It deletes those without extra prompting, but refuses + to delete anything it did not create unless the caller passes + `confirm_external_delete=true`. + +## Configuration + +Both adapters share the settings below. + +### Where settings come from + +Settings are read from the process environment. A `.env` file is one optional +way to fill it, and a real environment variable always wins over the same name +in a file. + +- `OSW_ENV_FILE` set: exactly that file is loaded, and nothing is searched for. +- Unset, **CLI**: searches upward from the working directory, so a `.env` in a + project root applies to every `osw` command run anywhere inside it. +- Unset, **MCP server**: searches nowhere. Its working directory is picked by + the MCP client, so an implicit search would tie the credentials it loads to + how the client happened to be launched. + +Both print the sources they resolved to stderr before connecting: + +```text +[osw] env file : /home/me/project/.env (found from the working directory upward) +[osw] credential file: /abs/path/to/accounts.pwd.yaml +``` + +### Credentials + +Keep credentials in a gitignored file. They are read once per process, into that +process only, and never written back to disk. + +```dotenv +OSW_DOMAIN=wiki-dev.open-semantic-lab.org +OSW_USERNAME=your-user +OSW_PASSWORD=your-password +# optional +OSW_SPARQL_ENDPOINT=https://.../sparql +OSW_READ_ONLY=false # true hides all mutating tools +``` + +Alternatively, authenticate from an osw credential file, so the password is not +duplicated into a second plaintext file: + +```dotenv +OSW_DOMAIN=wiki-dev.open-semantic-lab.org +OSW_CRED_FILEPATH=/abs/path/to/accounts.pwd.yaml +``` + +The file is the YAML format osw's `CredentialManager` already reads, keyed by +iri, so deployments that configure it need no extra setup: + +```yaml +wiki-dev.open-semantic-lab.org: + username: your-user + password: your-password +``` + +A credential file may hold several iris. The CLI selects one automatically if it +is the only one, and otherwise wants `osw --instance `. The MCP server +never selects one, see +[One server per instance](#mcp-server). + +### Variable reference + +The canonical variable names are `OSW_*`. Older `OSW_MCP_*` and `OSL_*` names +stay accepted so existing deployments keep working, and the first name that is +set wins: + +| Canonical | Also accepted | Meaning | +| --- | --- | --- | +| `OSW_DOMAIN` | `OSL_DOMAIN` | Instance to connect to | +| `OSW_USERNAME` | `OSL_USERNAME` | Login user | +| `OSW_PASSWORD` | `OSL_PASSWORD` | Login password | +| `OSW_CRED_FILEPATH` | `OSW_MCP_CRED_FILEPATH`, `OSL_CRED_FILEPATH` | YAML credential file, keyed by iri | +| `OSW_ENV_FILE` | `OSW_MCP_ENV_FILE` | `.env` file to load | +| `OSW_READ_ONLY` | `OSW_MCP_READ_ONLY` | `true` refuses every write | +| `OSW_SPARQL_ENDPOINT` | | Endpoint for `sparql` queries | +| `OSW_STATE_DIR` | `OSW_MCP_STATE_DIR` | Where the provenance ledger is kept | +| `OSW_MAX_RESULTS` | `OSW_MCP_MAX_RESULTS` | Default result cap (100) | +| `OSW_MAX_CHARS` | `OSW_MCP_MAX_CHARS` | Result size cap in characters (100000) | + +### Windows paths in a `.env` file + +Quote them with single quotes, or leave them unquoted. A double-quoted value is +escape-decoded, so `\a` in a path silently becomes a BEL byte that renders as +nothing: + +```dotenv +OSW_CRED_FILEPATH='C:\Users\me\accounts.pwd.yaml' # ok +OSW_CRED_FILEPATH=C:\Users\me\accounts.pwd.yaml # ok +OSW_CRED_FILEPATH="C:\Users\me\accounts.pwd.yaml" # broken: \a is eaten +``` + +## Design notes + +Why the two adapters are shaped the way they are: + +- **No filesystem access on the MCP surface.** MCP does not imply a shared host: + a server can be containerised or remote, so a path argument is either + meaningless or a way to reach a filesystem nobody granted access to. A CLI + runs where the command was typed, under that user's own permissions, and an + agent calling it goes through whatever command permissions already apply. +- **One instance per server process.** Which instance a tool call reaches has to + be readable from the configuration rather than inferred, so the server never + picks one for you, not even when the credential file holds exactly one iri. +- **stdio only.** SSE is deprecated upstream, and HTTP would need a + per-connection auth model this server does not have: it holds one set of wiki + credentials, which every client would share. +- **`osw[mcp]` outside `osw[all]`.** It needs `anyio>=4.9`, which conflicts with + the pin the `osw[workflow]` extra requires for prefect 2.x, so the two cannot + share an environment + ([#139](https://github.com/OpenSemanticLab/osw-python/issues/139)). + Installing the server standalone, for example via `uvx`, avoids the question + entirely. + +## Notes for developers + +To try an unreleased branch against a real client, point `uvx` at the checkout +instead of at PyPI. Everything else about the registration stays the same: + +```bash +uvx --reinstall --from "/abs/path/to/osw-python[mcp]" osw-mcp +``` + +`--reinstall` is what picks up your latest edits, since `uvx` caches the wheel +it builds. In a JSON `args` array, a Windows path needs forward slashes or +doubled backslashes. + +Prefer that over an editable install for the server. `create_or_update_entity` +and `export_entity_jsonld` call `fetch_schema`, which regenerates +`src/osw/model/entity.py` inside the installed package: `uvx` builds a +non-editable wheel, so the write lands in the uv cache, while under +`pip install -e` or `uv sync` it lands in your working tree. The read tools +(`get_entity`, `get_slot`, `get_category_schema`, ...) read raw page slots and +never trigger it. diff --git a/docs/get-started.md b/docs/get-started.md index 0d1292d..e01f2a2 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -31,6 +31,7 @@ | `osw[dataimport]` | Additional tools to import data | | `osw[UI]` | To use a helper UI to work with entity slots | | `osw[all]` | All of the above | +| `osw[mcp]` | [MCP server](cli-and-mcp.md#mcp-server) for agent clients, not part of `osw[all]` | Install multiple extras with `pip install osw[opt1,opt2]`. diff --git a/pyproject.toml b/pyproject.toml index ffa589b..883f321 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,9 @@ dependencies = [ "dask", "tqdm", "pybars3-wheel", + # the osw CLI (src/osw/cli); a base dependency, not an extra, so `pip + # install osw` never ships a broken `osw` console script + "typer", ] [project.urls] @@ -69,6 +72,13 @@ dataimport = [ "openpyxl", ] UI = ["pysimplegui"] +mcp = [ + # official MCP Python SDK; provides MCPServer from mcp.server. + # requires 2.x: 1.x has no MCPServer, and 2.0 removed the vendored FastMCP. + "mcp>=2", + # .env loading for the stdio server (OSW_DOMAIN/OSW_USERNAME/OSW_PASSWORD) + "python-dotenv>=1.0", +] workflow = [ "prefect>=2.20.25,<3.0", # prefect 2.20.25 is the final 2.x release (no backports). Its @@ -79,22 +89,48 @@ workflow = [ "anyio>=4.4.0,<4.7", ] tutorial = ["osw[dataimport]"] +# mcp is deliberately excluded here: it requires anyio>=4.9, which conflicts +# with the workflow extra's anyio cap. Install it explicitly with osw[mcp]. +# See https://github.com/OpenSemanticLab/osw-python/issues/139 all = ["osw[dataimport,DB,UI,S3,wikitext]"] +[project.scripts] +# command-line access to a live OSL instance, built from the same +# osw.service.registry the MCP server uses +osw = "osw.cli.main:app" +# stdio MCP server exposing a live OSL instance to MCP clients (e.g. Claude Code) +osw-mcp = "osw.mcp.server:main" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [dependency-groups] -dev = [ - # test stack +# pytest stack in its own group so it can be installed alongside the mcp +# extra, which conflicts with the dev group (see [tool.uv] below). +# tests/test_service_*.py run in a plain `uv sync` dev env (no mcp extra +# needed). Run the MCP-extra tests (tests/test_mcp_*.py) with: +# uv sync --extra mcp --group test --no-dev +# uv run --extra mcp --group test --no-dev --no-sync python -m pytest tests/test_mcp_*.py +test = [ "pytest", "pytest-cov", "pytest-mock", "pytest-asyncio", - # inherit the capped prefect pin (<3.0); a bare "prefect" here resolved to - # 3.x in CI, whose server API breaks the prefect-2.20-targeted tests - "osw[workflow]", + # not part of the mcp conflict set, so the shared service layer (which reads + # .env lazily) stays importable and testable in a plain `uv sync` env. + "python-dotenv>=1.0", +] +dev = [ + { include-group = "test" }, + # prefect/anyio are listed directly rather than via osw[workflow]: the + # workflow extra is in a uv conflict set (see [tool.uv] below), so a + # self-referential osw[workflow] entry here would only activate when + # --extra workflow is passed, leaving a bare `uv sync` on the anyio that + # breaks prefect 2.20. Keep these pins in sync with the workflow extra. + # Tracked in https://github.com/OpenSemanticLab/osw-python/issues/139 + "prefect>=2.20.25,<3.0", + "anyio>=4.4.0,<4.7", "geopy", "deepl", "sqlalchemy", @@ -291,6 +327,19 @@ insertion_flag = "" [tool.semantic_release.changelog.default_templates] changelog_file = "CHANGELOG.md" +[tool.uv] +# mcp 2.x needs anyio>=4.9; the workflow extra caps anyio<4.7 for prefect 2.20 +# (see the workflow extra above). They cannot share one resolution, so uv is +# told to resolve them in separate splits. Install the MCP server standalone: +# pip install "osw[mcp]". +# The dev group is included too since it carries the same anyio cap directly +# (see the dev group above). Tracked in +# https://github.com/OpenSemanticLab/osw-python/issues/139 +conflicts = [ + [{ extra = "mcp" }, { extra = "workflow" }], + [{ extra = "mcp" }, { group = "dev" }], +] + [tool.ty.environment] python = "./.venv" python-version = "3.10" @@ -300,6 +349,11 @@ python-version = "3.10" # - src/osw/model/entity.py: generated (datamodel-code-generator) models # - examples, scripts: illustrative/maintenance code, not part of the package # - tests: not yet type-clean, tightened in a follow-up +# +# src/osw/mcp is no longer excluded. The mcp extra still cannot be installed +# alongside the dev group (issue #139), so the two SDK imports in server.py +# carry an inline `ty: ignore[unresolved-import]`; everything else there, and +# all of src/osw/service and src/osw/cli, is checked. exclude = [ "src/osw/model/entity.py", "examples", @@ -343,6 +397,9 @@ pybars3-wheel = "pybars" psycopg2 = "psycopg2" openpyxl = "openpyxl" pysimplegui = "PySimpleGUI" +mcp = "mcp" +# python-dotenv imports as `dotenv` +python-dotenv = "dotenv" [tool.deptry.per_rule_ignores] # DEP002: declared but not imported anywhere in src diff --git a/src/osw/cli/__init__.py b/src/osw/cli/__init__.py new file mode 100644 index 0000000..0a237f9 --- /dev/null +++ b/src/osw/cli/__init__.py @@ -0,0 +1,9 @@ +"""osw: a command-line client assembled from the same ``osw.service.registry`` +that ``osw-mcp`` uses. + +Every operation is registered once (see :mod:`osw.service.ops`) and exposed +identically by every adapter; this package's only job is to turn that +registry into a ``typer`` command tree. +""" + +from __future__ import annotations diff --git a/src/osw/cli/main.py b/src/osw/cli/main.py new file mode 100644 index 0000000..932bfb2 --- /dev/null +++ b/src/osw/cli/main.py @@ -0,0 +1,202 @@ +"""Entry point for the ``osw`` CLI. + +Run via the ``osw`` console script or ``python -m osw.cli.main``. The command +tree is assembled once, at import time, by looping over +:func:`osw.service.registry.iter_operations`; building it never touches +credentials or the network. The :class:`~osw.service.context.Context` for a +given invocation is built lazily, inside each command's callback, so +``osw --help`` (and friends) work with no configuration present at all. +""" + +from __future__ import annotations + +import inspect +from typing import Any, Optional, get_type_hints + +import typer + +# Registers the CLI-only, path-taking operations (file download/upload, ledger +# path). Imported here -- and nowhere in osw.mcp -- so a path-taking operation +# can never reach the MCP registry. +import osw.cli.ops + +# Registers every operation in osw.service.registry.REGISTRY as a side effect. +import osw.service.ops # noqa: F401 +from osw.service import config, errors +from osw.service.context import Context, Policy +from osw.service.errors import OpError +from osw.service.params import json_value +from osw.service.registry import Operation, bind, iter_operations +from osw.wtsite import SLOTS + +from .render import render + +app = typer.Typer(no_args_is_help=True, add_completion=False) + + +@app.callback() +def _callback( + ctx: typer.Context, + instance: Optional[str] = typer.Option( + None, + "--instance", + help="Iri of the OSL instance to use for this command, when more " + "than one is configured (e.g. via a credential file).", + ), + as_json: bool = typer.Option( + False, "--json", "-j", help="Emit machine-readable JSON on stdout." + ), + read_only: bool = typer.Option( + False, "--read-only", help="Refuse write operations." + ), + verbose: bool = typer.Option( + False, "--verbose", "-v", help="Show full tracebacks on unexpected errors." + ), +) -> None: + """osw: command-line access to an OpenSemanticLab (OSW) instance. + + Connection settings and credentials come from the environment or a + .env file (see ``osw.service.config``). Pass --instance to pick which + configured instance this invocation talks to; unlike the MCP server, the + CLI is stateless, so the choice only applies to this one command. + """ + # The CLI's working directory is the one the user typed the command in, so + # searching it upward for a .env is what they mean. The MCP server leaves + # this off: its working directory is chosen by the MCP client. + config.set_env_file_discovery(True) + ctx.obj = { + "instance": instance, + "as_json": as_json, + "read_only": read_only, + "verbose": verbose, + } + + +def _op_params(op: Operation) -> list[inspect.Parameter]: + """The op's CLI-facing parameters (its signature, minus ``ctx``). + + Mirrors :func:`osw.service.registry.bind`'s annotation resolution, but + only needs ``op.fn`` -- no ``Context`` -- so it is safe to call at + app-build time. + """ + try: + hints = get_type_hints(op.fn, include_extras=True) + except Exception: + hints = {} + sig = inspect.signature(op.fn) + params = [ + p.replace(annotation=hints.get(p.name, p.annotation)) + for p in list(sig.parameters.values())[1:] # drop ctx + ] + + if op.name == "set_slot": + # set_slot's `content: Union[str, dict, list]` is left unmarked in + # the core (osw.service.ops.slots): typer has no support for + # arbitrary Union types (verified empirically -- building a command + # with this annotation raises AssertionError at app-build time). The + # CLI instead takes `content` as a plain string and coerces it to + # JSON at invocation time in `_run`, but only when the sibling + # `slot` argument's content model is "json" (see SLOTS); a blanket + # JSON parser would silently turn plain-text content like "123" + # into an int. + params = [ + p.replace(annotation=str) if p.name == "content" else p for p in params + ] + + return params + + +def _run(op: Operation, typer_ctx: typer.Context, kwargs: dict[str, Any]) -> None: + opts = typer_ctx.obj or {} + + if op.name == "set_slot": + slot = kwargs.get("slot") + content_model = SLOTS.get(slot, {}).get("content_model") + content = kwargs.get("content") + if content_model == "json" and isinstance(content, str): + kwargs["content"] = json_value(content) + + try: + instance = opts.get("instance") + if instance: + try: + config.set_active_instance(instance) + except ValueError as exc: + raise errors.UnknownInstance(str(exc)) from exc + + # Before load(), so a misconfiguration that makes loading raise still + # reports which files were read. + config.log_config_sources() + settings = config.load(strict=False) + policy = Policy( + capture_stdout=bool(opts.get("as_json")), + errors_as_dicts=False, + allow_writes=not opts.get("read_only"), + allow_interactive=True, + ) + context = Context(settings, policy) + bound = bind(op, context) + result = bound(**kwargs) + except OpError as exc: + typer.echo(f"{exc.type}: {exc}", err=True) + raise typer.Exit(exc.exit_code) + except Exception as exc: + if opts.get("verbose"): + raise + typer.echo(f"{type(exc).__name__}: {exc}", err=True) + raise typer.Exit(1) + + typer.echo(render(result, as_json=bool(opts.get("as_json")))) + + +def _make_command(op: Operation): + """Build the typer command callable for ``op``.""" + op_params = _op_params(op) + ctx_param = inspect.Parameter( + "typer_ctx", + inspect.Parameter.POSITIONAL_OR_KEYWORD, + annotation=typer.Context, + ) + + def command(**kwargs: Any) -> None: + typer_ctx = kwargs.pop("typer_ctx") + _run(op, typer_ctx, kwargs) + + command.__name__ = op.fn.__name__ + command.__doc__ = inspect.getdoc(op.fn) + command.__signature__ = inspect.Signature(parameters=[ctx_param, *op_params]) + annotations = {p.name: p.annotation for p in op_params} + annotations["typer_ctx"] = typer.Context + command.__annotations__ = annotations + return command + + +_groups: dict[str, typer.Typer] = {} + +# One line per command group. Without these ``osw --help`` lists eight bare +# group names with nothing next to them; a group missing an entry still works. +_GROUP_HELP = { + "entity": "Read, write, export and delete entities.", + "file": "Wiki file pages: metadata, inline text, and local transfer.", + "instance": "Inspect the OSL instances this process can connect to.", + "ledger": "The local provenance ledger of pages written from here.", + "schema": "Category JSON Schemas.", + "search": "Query the instance: semantic, full-text or SPARQL.", + "slot": "Read and write individual page slots.", +} + +for _op in iter_operations(surface="cli"): + _command = _make_command(_op) + if _op.group is None: + app.command(name=_op.command)(_command) + else: + _sub = _groups.get(_op.group) + if _sub is None: + _sub = typer.Typer() + _groups[_op.group] = _sub + app.add_typer(_sub, name=_op.group, help=_GROUP_HELP.get(_op.group)) + _sub.command(name=_op.command)(_command) + + +if __name__ == "__main__": + app() diff --git a/src/osw/cli/ops.py b/src/osw/cli/ops.py new file mode 100644 index 0000000..665cbf4 --- /dev/null +++ b/src/osw/cli/ops.py @@ -0,0 +1,167 @@ +"""CLI-only operations that name a filesystem path. + +This is the only module in the codebase allowed to do so: every operation +here declares ``surfaces=frozenset({"cli"})``, so none of it is ever visible +to ``iter_operations(surface="mcp")`` and the registry's path-name validator +never even runs against it (that validator only inspects the ``mcp`` +surface). A path argument is meaningful here because the CLI runs under the +invoking user's own shell permissions; it would be meaningless -- or a +filesystem escape hatch -- on an MCP client that may not share a host with +the server. + +Imported by ``osw.cli.main`` (and nowhere else) before the command-tree loop, +so these commands are registered without ``osw.mcp`` ever importing this +module. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Optional + +from osw.controller.file.wiki import WikiFileController +from osw.core import OverwriteOptions +from osw.service import config, errors +from osw.service.context import Context +from osw.service.ledger import LedgerRecord +from osw.service.registry import operation +from osw.utils.wiki import title_from_full_title +from osw.wtsite import WtSite + + +class _RenamedFile: + """Proxy over a file object that allows overriding its ``.name``. + + ``WikiFileController.put()`` derives the upload's suffix/label from + ``file.name``, but a real ``open()``-returned file object's ``.name`` (its + open-time path) is not a writable attribute. This proxy delegates + everything else to the wrapped file object. + """ + + def __init__(self, fh, name: str) -> None: + self._fh = fh + self.name = name + + def __getattr__(self, item): + return getattr(self._fh, item) + + +def _file_controller(ctx: Context, title: Optional[str] = None) -> WikiFileController: + """Build a ``WikiFileController``, optionally bound to a full title.""" + if title: + return WikiFileController( + osw=ctx.osw, title=title_from_full_title(title), namespace="File" + ) + return WikiFileController(osw=ctx.osw) + + +@operation( + group="file", + cli_name="download", + surfaces=frozenset({"cli"}), + read_only_hint=True, + idempotent_hint=True, +) +def download_file( + ctx: Context, + title: str, + target_dir: Optional[str] = None, + overwrite: bool = False, +) -> dict: + """Download a wiki file to the local filesystem. + + ``title`` is a full ``File:`` page title. Streams the file in chunks so a + large file never lands in memory at once. + """ + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + raise errors.NotFound(f"File '{title}' does not exist.") + + wf = _file_controller(ctx, title) + dest_dir = Path(target_dir) if target_dir else Path.cwd() + dest_dir.mkdir(parents=True, exist_ok=True) + dest_path = dest_dir / wf.title + if dest_path.exists() and not overwrite: + raise FileExistsError( + f"'{dest_path}' already exists. Pass --overwrite to replace it." + ) + stream = wf.get() + try: + with open(dest_path, "wb") as fh: + shutil.copyfileobj(stream, fh) + finally: + stream.close() + return {"title": title, "path": str(dest_path)} + + +@operation( + group="file", + cli_name="upload", + surfaces=frozenset({"cli"}), + writes=True, + destructive_hint=False, + idempotent_hint=True, + records=lambda r: [LedgerRecord(title=r["title"], op="create", slots=["jsondata"])], +) +def upload_file( + ctx: Context, + source_path: str, + target_title: Optional[str] = None, + name: Optional[str] = None, + overwrite: bool = True, +) -> dict: + """Upload a local file to the wiki as a WikiFile page. + + ``source_path`` is a path on the local disk. ``target_title`` is an + optional full ``File:`` page title (otherwise auto-generated). Records + the created page in the provenance ledger. + """ + src = Path(source_path) + if not src.is_file(): + raise errors.NotFound(f"Local file '{source_path}' does not exist.") + + wf = _file_controller(ctx, target_title) + overwrite_opt = OverwriteOptions.true if overwrite else OverwriteOptions.false + with open(src, "rb") as fh: + stream = _RenamedFile(fh, name or src.name) + wf.put(stream, overwrite=overwrite_opt) + + return { + "title": f"{wf.namespace}:{wf.title}", + "url": wf.url, + } + + +@operation( + group="ledger", + cli_name="path", + surfaces=frozenset({"cli"}), + read_only_hint=True, + idempotent_hint=True, +) +def ledger_path(ctx: Context) -> dict: + """Print the local path of the provenance ledger file for the active instance.""" + return {"path": str(ctx.ledger.path)} + + +@operation( + group="instance", + cli_name="list", + surfaces=frozenset({"cli"}), + read_only_hint=True, + idempotent_hint=True, +) +def list_instances(ctx: Context) -> dict: + """List the OSL instances this process can connect to. + + Reports the iris available from the env-configured domain and/or a + configured credential file, and which one (if any) is currently active + for this invocation (see --instance). Never returns usernames, passwords, + or any other credential value. + """ + return { + "iris": config.available_iris(), + "active_iri": config.get_active_iri(), + "active_domain": config.get_active_domain(), + } diff --git a/src/osw/cli/render.py b/src/osw/cli/render.py new file mode 100644 index 0000000..5787f22 --- /dev/null +++ b/src/osw/cli/render.py @@ -0,0 +1,64 @@ +"""Rendering helpers for the ``osw`` CLI. + +Kept deliberately simple: this is not a table library, just enough structure +to make operation results readable on a terminal (or, with ``--json``, +machine-parseable). + +The matching input-side helper, ``json_value``, lives in +:mod:`osw.service.params`: it is referenced from operation signatures, which +must not import an adapter. +""" + +from __future__ import annotations + +import json + + +def render(result: dict, *, as_json: bool) -> str: + """Render an operation's result for the CLI. + + With ``as_json``, a plain ``json.dumps``. Otherwise a compact + human-readable rendering: a ``{"titles": [...], "count": n, "truncated": + bool}``-shaped result prints one title per line plus a count/truncation + footer; any other dict renders as aligned ``key: value`` lines, with + nested structures (dicts/lists) dumped as indented JSON. + """ + if as_json: + return json.dumps(result, indent=2, ensure_ascii=False) + if _is_title_list(result): + return _render_title_list(result) + return _render_dict(result) + + +def _is_title_list(result: dict) -> bool: + return ( + isinstance(result, dict) + and isinstance(result.get("titles"), list) + and "count" in result + ) + + +def _render_title_list(result: dict) -> str: + lines = [str(title) for title in result["titles"]] + count = result.get("count", len(result["titles"])) + footer = f"{count} result{'s' if count != 1 else ''}" + if result.get("truncated"): + footer += " (truncated)" + lines.append(footer) + return "\n".join(lines) + + +def _render_dict(result: dict) -> str: + if not isinstance(result, dict): + return json.dumps(result, indent=2, ensure_ascii=False) + width = max((len(str(key)) for key in result), default=0) + lines = [] + for key, value in result.items(): + label = str(key).ljust(width) + if isinstance(value, (dict, list)): + nested = json.dumps(value, indent=2, ensure_ascii=False) + indented = "\n".join(f" {line}" for line in nested.splitlines()) + lines.append(f"{label}:\n{indented}") + else: + lines.append(f"{label}: {value}") + return "\n".join(lines) diff --git a/src/osw/mcp/__init__.py b/src/osw/mcp/__init__.py new file mode 100644 index 0000000..aca77bc --- /dev/null +++ b/src/osw/mcp/__init__.py @@ -0,0 +1,21 @@ +"""osw-mcp: an MCP server exposing a live OpenSemanticLab instance. + +The server wraps :class:`osw.express.OswExpress` and serves it over the Model +Context Protocol (stdio) so MCP clients such as Claude Code can search, read, +write and manage entities, page slots and files on a live OSL instance. + +``main`` is imported lazily so ``import osw.mcp`` does not require the optional +``mcp`` / ``python-dotenv`` dependencies unless the server is actually started. +""" + +from __future__ import annotations + +__all__ = ["main"] + + +def __getattr__(name: str): + if name == "main": + from .server import main + + return main + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/osw/mcp/__main__.py b/src/osw/mcp/__main__.py new file mode 100644 index 0000000..eef6a18 --- /dev/null +++ b/src/osw/mcp/__main__.py @@ -0,0 +1,8 @@ +"""Allow ``python -m osw.mcp`` to launch the server.""" + +from __future__ import annotations + +from .server import main + +if __name__ == "__main__": + main() diff --git a/src/osw/mcp/server.py b/src/osw/mcp/server.py new file mode 100644 index 0000000..331d888 --- /dev/null +++ b/src/osw/mcp/server.py @@ -0,0 +1,165 @@ +"""Entry point for the osw-mcp stdio server. + +Run via the ``osw-mcp`` console script or ``python -m osw.mcp``. Connection +credentials come from the environment / a ``.env`` file (see +:mod:`osw.service.config`). +""" + +from __future__ import annotations + +import atexit +import inspect +import sys +from typing import Any, Optional + +# ty cannot resolve these: the mcp extra is uninstallable alongside the dev +# group (anyio conflict, issue #139), so it is absent from the env ty runs in. +# The rest of this module is type-checked; drop the ignores once #139 is fixed. +from mcp.server import MCPServer # ty: ignore[unresolved-import] +from mcp.types import ToolAnnotations # ty: ignore[unresolved-import] + +import osw +import osw.service.ops +from osw.service import config +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.registry import Operation, bind, iter_operations + +INSTRUCTIONS = """\ +This server is pinned to exactly one OpenSemanticLab (OSL) instance for its +whole process lifetime; there is no tool to switch instances. Run one server +process per instance (a separate registration, its own env file) if you need +more than one. + +Entity and page titles are full MediaWiki page names, e.g. "Item:OSW1234...", +never a bare id or label. + +Before creating or updating an entity, fetch its category's JSON Schema +(get_category_schema) so the written jsondata validates against it. + +This server has no filesystem access: file content moves inline as text, not +as a path. For anything path-based (uploading/downloading a local file, the +provenance ledger's path), use the `osw` CLI instead. +""" + + +def _annotations(op: Operation) -> Optional[ToolAnnotations]: + """Build ``ToolAnnotations`` from ``op``'s four hints. + + Returns ``None`` when every hint is unset, so a hint-less operation gets + no ``annotations`` at all rather than an all-``None`` object. + + Built by explicit keyword, never ``**dict``: passing an unrecognized + keyword to ``ToolAnnotations`` (verified empirically against the + installed mcp SDK) is silently dropped rather than raising, so a + misspelled field name would otherwise fail with no error and leave the + hint permanently ``None``. + """ + hints = ( + op.read_only_hint, + op.destructive_hint, + op.idempotent_hint, + op.open_world_hint, + ) + if all(hint is None for hint in hints): + return None + return ToolAnnotations( + read_only_hint=op.read_only_hint, + destructive_hint=op.destructive_hint, + idempotent_hint=op.idempotent_hint, + open_world_hint=op.open_world_hint, + ) + + +def _meta(op: Operation, settings: Settings) -> dict[str, Any]: + """Build the MCP ``_meta`` dict for ``op``. + + ``anthropic/maxResultSizeChars`` always has a value: ``op``'s own limit + if it declares one, else the server-wide default. ``requiresUserInteraction`` + is only present (and only ever ``True``) for operations that declare it. + ``op.extra_meta`` is merged last, so it can override either key. + """ + meta: dict[str, Any] = { + "anthropic/maxResultSizeChars": op.max_result_size_chars or settings.max_chars, + } + if op.requires_user_interaction: + meta["anthropic/requiresUserInteraction"] = True + meta.update(op.extra_meta) + return meta + + +def tool_kwargs(op: Operation, settings: Settings) -> dict[str, Any]: + """Keyword arguments for ``mcp.tool(...)`` for one operation.""" + return { + "name": op.name, + "description": inspect.getdoc(op.fn), + "annotations": _annotations(op), + "meta": _meta(op, settings), + } + + +def _build_server() -> tuple[MCPServer, Context]: + """Build the MCPServer and the Context its tools are bound to. + + Loads and validates settings first so a missing-credential misconfiguration + fails fast (before any osw call that could trigger an interactive prompt). + Also fails fast unless a domain was configured *explicitly*: this server is + statically pinned to one OSL instance for its whole lifetime, and which one + that is has to be readable from the configuration rather than inferred. + Deliberately stricter than :func:`config.get_active_domain`, which the CLI + uses: there the instance is resolved per invocation and reported at + startup, and ``--instance`` can override it per command. + """ + # Before get_settings(), so a misconfiguration that makes loading raise + # still reports which files were read. stderr, so it lands in the MCP + # client's server log without touching the JSON-RPC stream on stdout. + config.log_config_sources() + settings = config.get_settings() + domain = settings.domain + if domain is None: + available = ", ".join(config.available_iris()) or "(none)" + raise RuntimeError( + "No OSL instance configured. Set OSW_DOMAIN in this server's env " + "block, or in the .env file named by OSW_ENV_FILE. The server " + "never picks an instance for you, not even when a credential file " + "holds exactly one iri, because which instance a tool call reaches " + f"must be readable from the configuration. Available: {available}." + ) + ctx = Context( + settings, + Policy( + capture_stdout=True, + errors_as_dicts=True, + allow_writes=not settings.read_only, + allow_interactive=False, + ), + ) + mcp = MCPServer("osw", instructions=INSTRUCTIONS, version=osw.__version__) + for op in iter_operations(surface="mcp", include_writes=not settings.read_only): + mcp.tool(**tool_kwargs(op, settings))(bind(op, ctx)) + return mcp, ctx + + +def create_server() -> MCPServer: + """Build the MCPServer, registering tools per the read-only setting.""" + mcp, _ctx = _build_server() + return mcp + + +def main() -> None: + """Console-script entry point: build the server and serve over stdio.""" + try: + mcp, ctx = _build_server() + except Exception as exc: + print(f"[osw-mcp] failed to start: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + + atexit.register(ctx.close) + try: + mcp.run(transport="stdio") + finally: + ctx.close() + + +if __name__ == "__main__": + main() diff --git a/src/osw/service/__init__.py b/src/osw/service/__init__.py new file mode 100644 index 0000000..33dac85 --- /dev/null +++ b/src/osw/service/__init__.py @@ -0,0 +1,6 @@ +"""osw.service: SDK-free shared core used by both ``osw-mcp`` and the ``osw`` CLI. + +Nothing in this package may import the ``mcp`` SDK or ``osw.cli``. +""" + +from __future__ import annotations diff --git a/src/osw/service/config.py b/src/osw/service/config.py new file mode 100644 index 0000000..14dabac --- /dev/null +++ b/src/osw/service/config.py @@ -0,0 +1,505 @@ +"""Configuration for the osw-mcp server. + +Loads settings from the environment (optionally via a ``.env`` file) and +validates that connection credentials are present *before* the server ever +touches the osw library. This matters because ``OswExpress`` / ``SmwSparqlClient`` +fall back to an interactive ``input()`` / ``getpass`` prompt when credentials are +missing, which would hang a stdio MCP server (it would read the JSON-RPC stream +as a password). We therefore fail fast with a clear error instead. +""" + +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional +from urllib.parse import urlparse + +import yaml + +from osw.auth import CredentialManager + +_TRUTHY = {"1", "true", "yes", "on"} + +# Environment variable names. Each tuple lists the canonical ``OSW_*`` name +# first, followed by every alias that must keep working. ``OSW_CRED_FILEPATH`` +# is canonical (rather than an ``OSW_MCP_``-prefixed name) because +# ``osw.express`` already reads that exact name (see ``src/osw/express.py``, +# search for ``CRED_FILEPATH``); the ``OSW_MCP_`` prefix used elsewhere was a +# gratuitous divergence from that. ``OSL_*`` names are accepted as legacy +# fallbacks, matching osw itself. +ENV_DOMAIN = ("OSW_DOMAIN", "OSL_DOMAIN") +ENV_USERNAME = ("OSW_USERNAME", "OSL_USERNAME") +ENV_PASSWORD = ("OSW_PASSWORD", "OSL_PASSWORD") +ENV_CRED_FILEPATH = ("OSW_CRED_FILEPATH", "OSW_MCP_CRED_FILEPATH", "OSL_CRED_FILEPATH") +ENV_SPARQL_ENDPOINT = ("OSW_SPARQL_ENDPOINT",) +ENV_READ_ONLY = ("OSW_READ_ONLY", "OSW_MCP_READ_ONLY") +ENV_STATE_DIR = ("OSW_STATE_DIR", "OSW_MCP_STATE_DIR") +ENV_MAX_RESULTS = ("OSW_MAX_RESULTS", "OSW_MCP_MAX_RESULTS") +ENV_MAX_CHARS = ("OSW_MAX_CHARS", "OSW_MCP_MAX_CHARS") +ENV_FILE = ("OSW_ENV_FILE", "OSW_MCP_ENV_FILE") + + +def _first_env(names: tuple[str, ...]) -> Optional[str]: + """Return the first non-empty environment value among ``names``.""" + for name in names: + value = os.getenv(name) + if value: + return value + return None + + +@dataclass(frozen=True) +class Settings: + """Resolved, validated server settings.""" + + # domain is optional: with a usable credential file, no domain need be + # configured via the environment; the active instance is then chosen from + # the credential file (auto-selected, or picked with the CLI's --instance). + domain: Optional[str] + # username/password are optional: a configured credential file is an + # alternative source of credentials (see ENV_CRED_FILEPATH). + username: Optional[str] = None + # kept only to build the SPARQL client; never returned by any tool + password: Optional[str] = field(default=None, repr=False) + cred_filepath: Optional[str] = None + sparql_endpoint: Optional[str] = None + read_only: bool = False + state_dir: Optional[str] = None + max_results: int = 100 + max_chars: int = 100_000 + + def redacted(self) -> dict: + """A dict view safe for logging / the status tool (no password).""" + return { + "domain": self.domain, + "username": self.username, + "read_only": self.read_only, + "sparql_endpoint_configured": bool(self.sparql_endpoint), + "cred_filepath_configured": bool(self.cred_filepath), + } + + +def _int_env(names: tuple[str, ...], default: int) -> int: + raw = _first_env(names) + if raw is None or raw.strip() == "": + return default + try: + return int(raw) + except ValueError: + # Name the variable that was actually set (not necessarily the + # canonical one), so the operator can find what to fix. + name = next((n for n in names if os.getenv(n) == raw), names[0]) + raise RuntimeError( + f"Environment variable {name}={raw!r} is not a valid integer." + ) + + +def _escape_hint(value: str) -> str: + """Extra error text when ``value`` holds a control character, else "". + + A double-quoted value in a ``.env`` file goes through escape decoding, so + a Windows path like ``"C:\\dir\\accounts.yaml"`` silently loses its ``\\a`` + to a BEL byte. The result renders as nothing in a terminal, which makes the + resulting "does not exist" message look like it is naming the right path. + """ + if not any(ord(char) < 32 for char in value): + return "" + return ( + f" The configured path contains a control character ({value!r}). A " + "double-quoted value in a .env file is escape-decoded, so a Windows " + r"path loses sequences like \a, \b, \f, \n, \r, \t and \v. Use single " + "quotes, no quotes, forward slashes, or doubled backslashes." + ) + + +def _cred_file_iris(cred_filepath: str) -> list[str]: + """Return the top-level iri keys in a credential YAML file, best effort.""" + try: + with open(cred_filepath, encoding="utf-8") as stream: + data = yaml.safe_load(stream) + except (OSError, yaml.YAMLError): + return [] + if not data: + return [] + return sorted(str(key) for key in data.keys()) + + +def _derive_domain(iri: str) -> str: + """Derive a bare domain from ``iri`` (a bare domain or a full URL). + + ``OswExpress`` requires a bare domain and validates it with a regex, but + credential-file iris may be either a bare domain (``wiki.example.org``) or + a full URL (``https://wiki.example.org/w/``). + """ + if "://" in iri: + netloc = urlparse(iri).netloc + else: + netloc = iri.split("/", 1)[0] + return netloc.rstrip(".") + + +def _verify_cred_file_has_domain(cred_filepath: str, domain: str) -> None: + """Verify that the credential file has an entry matching ``domain``. + + Uses ``CredentialManager.get_credential`` with ``fallback="none"`` so this + never prompts interactively and never performs a network login; it only + checks that a matching credential entry already exists in the file. + + Raises + ------ + RuntimeError + If no credential entry matches ``domain``, naming the iris the file + does contain (never their secrets) so the operator can fix it. + """ + cred_mngr = CredentialManager(cred_filepath=cred_filepath) + credential = cred_mngr.get_credential( + CredentialManager.CredentialConfig( + iri=domain, fallback=CredentialManager.CredentialFallback.none + ) + ) + if credential is None: + available = ", ".join(_cred_file_iris(cred_filepath)) or "(none)" + raise RuntimeError( + f"Credential file '{cred_filepath}' has no entry matching domain " + f"'{domain}'. Iris found in the file: {available}. Add an entry " + "for the domain, or configure OSW_USERNAME/OSW_PASSWORD instead." + ) + + +# Whether to look for a .env file when none is configured explicitly. Off by +# default, so a process only reads a file it was pointed at: the MCP server's +# working directory is chosen by the MCP client, so searching it would make +# which credentials get loaded depend on how the client was launched. The CLI +# turns it on (see osw.cli.main), where the working directory is the one the +# user typed the command in. +_discover_env_file: bool = False + +# Where the .env file actually came from, for the startup banner. One of +# "explicit", "discovered", "none" (searched, nothing found) or "not searched". +_env_file_path: Optional[str] = None +_env_file_origin: str = "not searched" + + +def set_env_file_discovery(enabled: bool) -> None: + """Enable or disable the implicit ``.env`` search (default: disabled). + + Must be called before settings are first loaded, since the file is read + exactly once per process; a call that would *change* the setting after + that raises rather than silently having no effect. Re-asserting the + current value is always allowed, so an adapter can call this on every + command without tracking whether it already did. + """ + global _discover_env_file + if enabled != _discover_env_file and _settings is not None: + raise RuntimeError( + "set_env_file_discovery() must be called before settings are " + "loaded; they are already cached for this process." + ) + _discover_env_file = enabled + + +def _load_env_file() -> None: + """Load a .env file if one is configured or (when enabled) discoverable. + + dotenv is optional (it ships with the ``mcp`` extra). An *explicitly* + configured env file with dotenv missing is an error, because the operator + asked for something that cannot happen. An implicit search is skipped + silently. + + The implicit search starts at the current working directory and walks + upward. ``dotenv.load_dotenv()`` with no arguments would instead walk up + from the *calling module's* directory, which is this file: under an + editable install that is the osw checkout and under a normal install it is + site-packages. Neither is what a user standing in a project directory + means by "the .env file", hence the explicit ``usecwd=True``. + """ + global _env_file_path, _env_file_origin + path = _first_env(ENV_FILE) + try: + import dotenv + except ImportError: + if path is None: + return + name = next((n for n in ENV_FILE if os.getenv(n) == path), ENV_FILE[0]) + raise RuntimeError( + f"{name} is set (to '{path}') but python-dotenv is not installed. " + "Install the osw[mcp] extra, or the `test` dependency group, to " + "use an env file." + ) + if path: + dotenv.load_dotenv(path) + _env_file_path, _env_file_origin = path, "explicit" + return + if not _discover_env_file: + return + found = dotenv.find_dotenv(usecwd=True) + if not found: + _env_file_origin = "none" + return + dotenv.load_dotenv(found) + _env_file_path, _env_file_origin = found, "discovered" + + +def log_config_sources(stream=None) -> None: + """Print where configuration was read from, one line per source. + + Loads the ``.env`` file first if that has not happened yet, and reads the + environment directly rather than a ``Settings``. Both so this can run + *before* settings are loaded: a misconfiguration makes loading raise, and + that is exactly when knowing which files were read matters most. + + Always writes to ``stderr``: under MCP ``stdout`` carries the JSON-RPC + stream, and under ``osw --json`` it carries the result payload. + """ + _load_env_file() + out = sys.stderr if stream is None else stream + described = { + "explicit": f"{_env_file_path} (from {ENV_FILE[0]})", + "discovered": f"{_env_file_path} (found from the working directory upward)", + "none": "none found (searched from the working directory upward)", + "not searched": f"not configured (set {ENV_FILE[0]} to use one)", + }[_env_file_origin] + print(f"[osw] env file : {described}", file=out) + cred_filepath = _first_env(ENV_CRED_FILEPATH) + if cred_filepath: + print(f"[osw] credential file: {cred_filepath}", file=out) + + +def load(strict: bool = True) -> Settings: + """Load and validate settings from the environment. + + Loads a ``.env`` file first: the path in ``OSW_ENV_FILE`` (or its + ``OSW_MCP_ENV_FILE`` alias) if set, otherwise a search from the current + working directory upward, but only when ``set_env_file_discovery(True)`` + has enabled it (the CLI does; the MCP server does not). + + Credentials can come from either ``OSW_USERNAME``/``OSW_PASSWORD`` (or + their ``OSL_*`` aliases) or from a credential file configured via + ``OSW_CRED_FILEPATH`` (or its ``OSW_MCP_CRED_FILEPATH`` / ``OSL_CRED_FILEPATH`` + aliases). When a credential file is configured, it is validated here to + actually contain an entry for the configured domain. + + Parameters + ---------- + strict: + When ``True`` (the default), missing required credentials (no domain + + username/password and no usable credential file) raise + ``RuntimeError``. When ``False``, that specific check is skipped and a + best-effort ``Settings`` is returned instead, with whatever was found + (fields may be ``None``) -- useful for a status command that wants to + report "not configured" rather than crash. Every other error still + raises regardless of ``strict``: a configured credential file that + does not exist, a configured credential file with no entry matching a + configured domain, an unparseable integer environment variable, and a + missing ``python-dotenv`` for an explicitly configured env file. + + Raises + ------ + RuntimeError + If domain is missing and no usable credential file is configured, if + neither a usable credential file nor username/password are + configured (only when ``strict`` is ``True``), if a configured + credential file does not exist, or if a configured credential file has + no entry matching a configured domain. This keeps the osw interactive + credential prompt from ever being reached. + """ + _load_env_file() + + domain = _first_env(ENV_DOMAIN) + username = _first_env(ENV_USERNAME) + password = _first_env(ENV_PASSWORD) + cred_filepath = _first_env(ENV_CRED_FILEPATH) + + cred_file_usable = False + if cred_filepath: + if not Path(cred_filepath).is_file(): + raise RuntimeError( + f"Configured credential file '{cred_filepath}' does not exist. " + "Set OSW_CRED_FILEPATH (or its OSW_MCP_CRED_FILEPATH / " + "OSL_CRED_FILEPATH aliases) to a valid path, or remove it and " + "configure OSW_USERNAME/OSW_PASSWORD instead." + + _escape_hint(cred_filepath) + ) + cred_file_usable = True + + # A usable credential file makes the domain optional: which instance to + # use is then chosen later (auto-selected, or via the CLI's --instance). + checks = [] + if not cred_file_usable: + checks.append((ENV_DOMAIN, domain)) + checks.append((ENV_USERNAME, username)) + checks.append((ENV_PASSWORD, password)) + missing = [names[0] for names, value in checks if not value] + if missing and strict: + raise RuntimeError( + "Missing required OSW credential environment variables: " + + ", ".join(missing) + + ". Set them in your environment or a .env file " + "(pointed to by OSW_ENV_FILE / OSW_MCP_ENV_FILE), or configure a " + "credential file via OSW_CRED_FILEPATH (or its OSW_MCP_CRED_FILEPATH " + "/ OSL_CRED_FILEPATH aliases). The server refuses to start without " + "them to avoid an interactive credential prompt that would hang " + "the stdio transport." + ) + + if cred_file_usable and domain: + _verify_cred_file_has_domain(cred_filepath, domain) + + return Settings( + domain=domain, + username=username, + password=password, + cred_filepath=cred_filepath, + sparql_endpoint=_first_env(ENV_SPARQL_ENDPOINT), + read_only=(_first_env(ENV_READ_ONLY) or "").lower() in _TRUTHY, + state_dir=_first_env(ENV_STATE_DIR), + max_results=_int_env(ENV_MAX_RESULTS, 100), + max_chars=_int_env(ENV_MAX_CHARS, 100_000), + ) + + +_settings: Optional[Settings] = None + + +def get_settings() -> Settings: + """Return cached settings, loading (and validating) them on first use.""" + global _settings + if _settings is None: + _settings = load() + return _settings + + +def reset() -> None: + """Drop cached settings and the active-instance selection (used by tests).""" + global _settings, _active_iri, _active_resolved + global _discover_env_file, _env_file_path, _env_file_origin + _settings = None + _active_iri = None + _active_resolved = False + _discover_env_file = False + _env_file_path = None + _env_file_origin = "not searched" + + +# -- active-instance state --------------------------------------------------- +# +# A server can be configured with several candidate instances (an +# env-configured domain and/or the iris in a credential file). Exactly one of +# them is "active" at a time; tools connect to whichever one is active. The +# active instance is auto-selected on first access (see ``_auto_select_iri``) +# and can be changed via ``set_active_instance`` (the CLI's ``--instance`` +# flag; the MCP server is pinned to one instance and never switches). + +_active_iri: Optional[str] = None +_active_resolved: bool = False + + +def _auto_select_iri() -> Optional[str]: + """Auto-select the active iri, or return ``None`` if none can be chosen. + + 1. A domain configured via the environment is always the active instance. + 2. Otherwise, if a credential file is configured and contains exactly one + iri, that iri is the active instance. + 3. Otherwise there is no active instance until ``set_active_instance`` is + called (e.g. via the CLI's ``--instance`` flag). + """ + settings = get_settings() + if settings.domain: + return settings.domain + if settings.cred_filepath: + iris = _cred_file_iris(settings.cred_filepath) + if len(iris) == 1: + return iris[0] + return None + + +def available_iris() -> list[str]: + """Return every iri this server can connect to. + + Combines the env-configured domain (if any) with the iris found in a + configured credential file (if any), without duplicates. Never includes + usernames, passwords, or any other credential value. + """ + settings = get_settings() + iris: list[str] = [] + if settings.domain: + iris.append(settings.domain) + if settings.cred_filepath: + for iri in _cred_file_iris(settings.cred_filepath): + if iri not in iris: + iris.append(iri) + return iris + + +def get_active_iri() -> Optional[str]: + """Return the active instance iri, auto-selecting it on first access.""" + global _active_iri, _active_resolved + if not _active_resolved: + _active_iri = _auto_select_iri() + _active_resolved = True + return _active_iri + + +def get_active_domain() -> Optional[str]: + """Return the bare domain of the active instance, or ``None`` if unset.""" + iri = get_active_iri() + if iri is None: + return None + return _derive_domain(iri) + + +def set_active_instance(iri: str) -> None: + """Set the active instance to ``iri``. + + Raises + ------ + ValueError + If ``iri`` is not one of :func:`available_iris`, naming the iris that + are available so the caller can pick a valid one. + """ + global _active_iri, _active_resolved + available = available_iris() + if iri not in available: + raise ValueError( + f"Unknown instance '{iri}'. Available: " + + (", ".join(available) or "(none)") + ) + _active_iri = iri + _active_resolved = True + + +def get_active_credentials() -> tuple[Optional[str], Optional[str]]: + """Return the username/password to use for the currently active instance. + + Resolution order: + + 1. If a credential file is configured, look up the active iri via + ``CredentialManager.get_credential`` with ``fallback=CredentialFallback.none`` + (never prompts interactively, never performs a network login). A + ``UserPwdCredential`` match yields its username/password. A match of any + other credential kind (e.g. ``OAuth1Credential``, which has no + username/password) yields ``(None, None)``. + 2. Otherwise (no credential file configured, or no match found in it), + fall back to ``settings.username`` / ``settings.password``. + 3. If neither source yields anything, returns ``(None, None)``. + + Never raises and never prompts, so this is always safe to call from a + stdio MCP tool. + """ + settings = get_settings() + active_iri = get_active_iri() + if settings.cred_filepath and active_iri: + cred_mngr = CredentialManager(cred_filepath=settings.cred_filepath) + credential = cred_mngr.get_credential( + CredentialManager.CredentialConfig( + iri=active_iri, fallback=CredentialManager.CredentialFallback.none + ) + ) + if credential is not None: + if isinstance(credential, CredentialManager.UserPwdCredential): + return credential.username, credential.password + return None, None + return settings.username, settings.password diff --git a/src/osw/service/context.py b/src/osw/service/context.py new file mode 100644 index 0000000..997bfbb --- /dev/null +++ b/src/osw/service/context.py @@ -0,0 +1,163 @@ +"""Per-instance execution context shared by every osw.service adapter. + +Holds the connection state (``osw``, ``ledger``, the lock) on an object rather +than in module-level globals, so a single process can hold more than one +connected instance and tests can inject a fake ``osw``/``ledger`` instead of +monkeypatching a module. + +The osw library prints progress to ``stdout`` (e.g. "Connecting to ..."). On +the MCP stdio transport ``stdout`` is the JSON-RPC channel, so +:meth:`Context.guard` redirects it to ``stderr`` for the duration of each osw +call -- but only when ``policy.capture_stdout`` is set. A plain CLI run wants +that progress output visible, so its policy leaves stdout alone. +""" + +from __future__ import annotations + +import sys +import threading +from contextlib import contextmanager, redirect_stdout +from dataclasses import dataclass +from typing import Optional + +from osw.auth import CredentialManager +from osw.express import OswExpress +from osw.service import config, errors +from osw.service.config import Settings +from osw.service.ledger import Ledger +from osw.wtsite import WtSite + + +@dataclass(frozen=True) +class Policy: + """How an adapter wants operations to behave.""" + + capture_stdout: bool = False # stdout is the JSON-RPC channel (MCP) or --json + errors_as_dicts: bool = False # a model needs a result; a shell needs an exit code + allow_writes: bool = True + allow_interactive: bool = False # a prompt would eat the JSON-RPC stream + + +class Context: + """Everything a bound operation needs to run against one OSL instance. + + ``osw`` and ``ledger`` are built lazily on first access; tests may instead + pre-set them (via the constructor or by assigning the attribute directly) + to inject a fake without monkeypatching a module. + """ + + def __init__( + self, + settings: Settings, + policy: Optional[Policy] = None, + *, + osw: Optional[OswExpress] = None, + ledger: Optional[Ledger] = None, + ) -> None: + self.settings = settings + self.policy = policy if policy is not None else Policy() + self._osw = osw + self._ledger = ledger + self._lock = threading.RLock() + + def _require_active_domain(self) -> str: + """Return the active instance's domain, or raise a clear, actionable error.""" + domain = config.get_active_domain() + if domain is None: + available = ", ".join(config.available_iris()) or "(none)" + raise errors.NotConfigured( + "No OSL instance selected. For a server process, set " + "OSW_DOMAIN (or OSW_ENV_FILE to point at a .env file that " + "sets it); for the CLI, pass --instance . " + f"Available: {available}." + ) + return domain + + @property + def osw(self) -> OswExpress: + """The shared ``OswExpress``, connecting on first use. + + Credentials come from either of two sources, both already validated + by :func:`osw.service.config.load`: + + * ``OSW_USERNAME`` / ``OSW_PASSWORD`` (or their ``OSL_*`` aliases), + read by osw from the environment; or + * a credential file (``settings.cred_filepath``), wrapped in a + ``CredentialManager`` and passed to ``OswExpress`` explicitly. + """ + if self._osw is None: + domain = self._require_active_domain() + if self.settings.cred_filepath: + cred_mngr = CredentialManager(cred_filepath=self.settings.cred_filepath) + self._osw = OswExpress(domain=domain, cred_mngr=cred_mngr) + else: + self._osw = OswExpress(domain=domain) + return self._osw + + @osw.setter + def osw(self, value: Optional[OswExpress]) -> None: + self._osw = value + + @property + def ledger(self) -> Ledger: + """The shared provenance ledger, keyed on the active instance's domain.""" + if self._ledger is None: + domain = self._require_active_domain() + self._ledger = Ledger(domain=domain, state_dir=self.settings.state_dir) + return self._ledger + + @ledger.setter + def ledger(self, value: Optional[Ledger]) -> None: + self._ledger = value + + @contextmanager + def guard(self): + """Serialize access to this context's instance for the call's duration. + + Redirects ``stdout`` to ``stderr`` only when ``policy.capture_stdout`` + is set (a plain CLI run wants osw's progress output visible). + """ + with self._lock: + if self.policy.capture_stdout: + with redirect_stdout(sys.stderr): + yield + else: + yield + + def limit(self, n: Optional[int]) -> int: + """Return ``n`` if given and truthy, else the configured default.""" + return n or self.settings.max_results + + def page(self, title: str): + """Return the page for ``title``. + + Raises :class:`osw.service.errors.NotFound` if it does not exist. + """ + page = self.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + raise errors.NotFound(f"Page '{title}' does not exist.") + return page + + def require_write(self, op_name: str) -> None: + """Raise if this context's policy disallows writes.""" + if not self.policy.allow_writes: + raise errors.ReadOnly( + f"Operation '{op_name}' is not permitted: writes are disabled " + "(set OSW_READ_ONLY=false to allow)." + ) + + def reset(self) -> None: + """Drop the held connection and ledger so the next access rebuilds them.""" + with self._lock: + if self._osw is not None: + try: + with redirect_stdout(sys.stderr): + self._osw.close_connection() + except Exception as exc: + print(f"[osw] error closing connection: {exc!r}", file=sys.stderr) + self._osw = None + self._ledger = None + + def close(self) -> None: + """Close the connection (e.g. on adapter shutdown).""" + self.reset() diff --git a/src/osw/service/errors.py b/src/osw/service/errors.py new file mode 100644 index 0000000..3c8d5a1 --- /dev/null +++ b/src/osw/service/errors.py @@ -0,0 +1,129 @@ +"""Stable-shaped operation errors shared by every osw.service adapter. + +Every operation failure is an :class:`OpError` subclass carrying a wire +``type`` string (the shape an MCP client sees, unchanged from the +hand-written error dicts the tool bodies returned before this module +existed) and an ``exit_code`` (the process exit status a CLI adapter uses). + +Exit codes are grouped by category, not unique per subclass: + +* ``1`` -- generic / unexpected error (the ``OpError`` base default). +* ``2`` -- not found: a page/entity expected to exist does not + (:class:`NotFound`). +* ``3`` -- invalid input: an argument is malformed, does not validate, or + does not resolve (:class:`SchemaError`, :class:`ClassNotFound`, + :class:`ValidationError`, :class:`UnknownInstance`, :class:`InvalidSlot`, + :class:`InvalidContent`, :class:`SlotMissing`, :class:`BinaryContent`). +* ``4`` -- refused/blocked: disallowed by a provenance or safety guard + (:class:`ExternalDeleteBlocked`, :class:`ReadOnly`). +* ``5`` -- not configured: required configuration is missing + (:class:`NotConfigured`). +""" + +from __future__ import annotations + +from typing import Optional + + +class OpError(Exception): + """Base for operation failures with a stable wire shape and a CLI exit code.""" + + type: str = "Error" + exit_code: int = 1 + + def __init__(self, message: str, *, extra: Optional[dict] = None) -> None: + super().__init__(message) + self.extra: dict = dict(extra) if extra else {} + + def payload(self) -> dict: + """The dict an MCP client receives. Must match today's shape exactly.""" + return {**self.extra, "error": str(self), "type": self.type} + + +class NotFound(OpError): + """A page or entity that was expected to exist does not.""" + + type = "NotFound" + exit_code = 2 + + +class SchemaError(OpError): + """A category's schema could not be fetched.""" + + type = "SchemaError" + exit_code = 3 + + +class ClassNotFound(OpError): + """No generated model class could be resolved for a category.""" + + type = "ClassNotFound" + exit_code = 3 + + +class ValidationError(OpError): + """A ``jsondata`` payload does not validate against its category.""" + + type = "ValidationError" + exit_code = 3 + + +class ExternalDeleteBlocked(OpError): + """A delete was refused because the page was not created by this server.""" + + type = "ExternalDeleteBlocked" + exit_code = 4 + + +class ReadOnly(OpError): + """A write was refused because writes are disabled for this context.""" + + type = "ReadOnly" + exit_code = 4 + + +class UnknownInstance(OpError): + """A requested instance iri is not among the configured/available ones.""" + + type = "UnknownInstance" + exit_code = 3 + + +class NotConfigured(OpError): + """Required configuration is missing (e.g. an active instance, a SPARQL + endpoint).""" + + type = "NotConfigured" + exit_code = 5 + + +class InvalidSlot(OpError): + """A slot key is not one of the valid ``osw.wtsite.SLOTS`` keys.""" + + type = "InvalidSlot" + exit_code = 3 + + +class InvalidContent(OpError): + """A slot's content does not match its content model (json/wikitext).""" + + type = "InvalidContent" + exit_code = 3 + + +class SlotMissing(OpError): + """A slot does not exist on a page and ``create_if_missing`` is false.""" + + type = "SlotMissing" + exit_code = 3 + + +class BinaryContent(OpError): + """A file's bytes do not decode under the requested text encoding. + + Raised by ``read_file_text`` when the requested file is not text; the mcp + surface cannot return raw bytes, so the caller must use the CLI instead. + """ + + type = "BinaryContent" + exit_code = 3 diff --git a/src/osw/service/ledger.py b/src/osw/service/ledger.py new file mode 100644 index 0000000..94ec49b --- /dev/null +++ b/src/osw/service/ledger.py @@ -0,0 +1,155 @@ +"""Provenance ledger for the osw-mcp server. + +The server records every page it *creates or modifies* through its own mutating +tools. Deleting a tracked page is allowed automatically; deleting a page the +server never touched requires an explicit ``confirm_external_delete`` override. + +The ledger is a small JSON file (never credentials) stored in an OS-appropriate +state directory, namespaced by domain so multiple instances do not collide. +""" + +from __future__ import annotations + +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import List, Optional + +from pydantic import BaseModel + +LEDGER_VERSION = 1 + + +def _default_state_dir() -> Path: + """Return an OS-appropriate per-user state directory (no extra dependency).""" + if sys.platform.startswith("win"): + base = os.getenv("LOCALAPPDATA") or os.path.expanduser("~\\AppData\\Local") + elif sys.platform == "darwin": + base = os.path.expanduser("~/Library/Application Support") + else: + base = os.getenv("XDG_STATE_HOME") or os.path.expanduser("~/.local/state") + return Path(base) / "osw-mcp" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _safe_domain(domain: str) -> str: + """Turn a domain into a filesystem-safe filename fragment.""" + return "".join(c if c.isalnum() or c in "-._" else "_" for c in domain) + + +class LedgerRecord(BaseModel): + """One ledger entry an operation wants written after a successful write. + + Mirrors the keyword arguments of :meth:`Ledger.record`, minus ``tool``, + which ``bind()`` fills in from the operation name. + """ + + title: str + op: str # the verb: "create", "update", "create_or_update" + change_id: Optional[str] = None + slots: Optional[List[str]] = None + uuid: Optional[str] = None + namespace: Optional[str] = None + + +class Ledger: + """A JSON-backed record of pages created/modified by this server.""" + + def __init__(self, domain: str, state_dir: Optional[str] = None): + self.domain = domain + base = Path(state_dir) if state_dir else _default_state_dir() + self.path = base / f"ledger-{_safe_domain(domain)}.json" + + # -- persistence ------------------------------------------------------- + def _load(self) -> dict: + if not self.path.is_file(): + return {"version": LEDGER_VERSION, "domain": self.domain, "entries": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + # A corrupt ledger must not take the server down; start fresh but + # warn so the operator can investigate. + print( + f"[osw-mcp] ledger at {self.path} unreadable ({exc}); " + "starting a new one.", + file=sys.stderr, + ) + return {"version": LEDGER_VERSION, "domain": self.domain, "entries": {}} + data.setdefault("entries", {}) + return data + + def _save(self, data: dict) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_name(self.path.name + f".{os.getpid()}.tmp") + tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + os.replace(tmp, self.path) # atomic on POSIX and Windows + + # -- public API -------------------------------------------------------- + def record( + self, + title: str, + *, + op: str, + tool: str, + uuid: Optional[str] = None, + namespace: Optional[str] = None, + change_id: Optional[str] = None, + slots: Optional[List[str]] = None, + ) -> None: + """Upsert a create/update record for ``title`` (idempotent, merging).""" + data = self._load() + entry = data["entries"].get(title) + now = _now() + if entry is None: + entry = { + "title": title, + "uuid": uuid, + "namespace": namespace, + "first_created_at": now, + "last_modified_at": now, + "change_ids": [], + "ops": [], + "tools": [], + "slots_written": [], + "deleted_at": None, + } + data["entries"][title] = entry + entry["last_modified_at"] = now + entry["deleted_at"] = None # a re-created/edited page is tracked again + if uuid and not entry.get("uuid"): + entry["uuid"] = uuid + if namespace and not entry.get("namespace"): + entry["namespace"] = namespace + if change_id and change_id not in entry["change_ids"]: + entry["change_ids"].append(change_id) + entry["ops"].append(op) + if tool not in entry["tools"]: + entry["tools"].append(tool) + for slot in slots or []: + if slot not in entry["slots_written"]: + entry["slots_written"].append(slot) + self._save(data) + + def is_tracked(self, title: str) -> bool: + """True if ``title`` was created/modified by this server and not deleted.""" + entry = self._load()["entries"].get(title) + return entry is not None and entry.get("deleted_at") is None + + def mark_deleted(self, title: str) -> None: + """Mark ``title`` as deleted (kept for audit, not purged).""" + data = self._load() + entry = data["entries"].get(title) + if entry is not None: + entry["deleted_at"] = _now() + self._save(data) + + def entry_count(self) -> int: + """Number of currently-tracked (non-deleted) entries.""" + return sum( + 1 for e in self._load()["entries"].values() if e.get("deleted_at") is None + ) diff --git a/src/osw/service/ops/__init__.py b/src/osw/service/ops/__init__.py new file mode 100644 index 0000000..9c5c644 --- /dev/null +++ b/src/osw/service/ops/__init__.py @@ -0,0 +1,18 @@ +"""Operation implementations, one module per group. + +Importing this package registers every operation in +:data:`osw.service.registry.REGISTRY`. It imports nothing from ``osw.mcp``, +``osw.cli`` or the ``mcp`` SDK, so this package (and by extension +``osw.service``) stays importable without the optional ``mcp`` extra and never +depends on an adapter. ``typer`` is a base dependency, so op modules may import +it directly to mark up a parameter's CLI form (see ``create_or_update_entity``'s +``jsondata`` and :mod:`osw.service.params`); pydantic ignores ``Annotated`` +metadata it does not recognise, so the MCP JSON schema is unaffected. + +Import order fixes the order adapters see, so it is also the order tools are +registered on the MCP server and commands are listed in ``osw --help``. +""" + +from __future__ import annotations + +from . import entities, files, schema, search, slots, status diff --git a/src/osw/service/ops/entities.py b/src/osw/service/ops/entities.py new file mode 100644 index 0000000..a6229d6 --- /dev/null +++ b/src/osw/service/ops/entities.py @@ -0,0 +1,208 @@ +"""Entity operations: read entity JSON, export JSON-LD, create/update, delete.""" + +from __future__ import annotations + +import sys +from typing import Annotated, Optional + +import typer + +import osw.model.entity as model_entity +from osw.core import OSW, AddOverwriteClassOptions, OverwriteOptions +from osw.service import config, errors +from osw.service.context import Context +from osw.service.ledger import LedgerRecord +from osw.service.params import json_value +from osw.service.registry import operation +from osw.service.serialization import maybe_truncate, to_jsonable +from osw.wtsite import WtSite + +_OVERWRITE = { + "true": OverwriteOptions.true, + "false": OverwriteOptions.false, + "only empty": OverwriteOptions.only_empty, + "replace remote": AddOverwriteClassOptions.replace_remote, + "keep existing": AddOverwriteClassOptions.keep_existing, +} + + +def _parse_overwrite(value: str): + key = str(value).lower().strip() + if key not in _OVERWRITE: + raise ValueError( + f"Invalid overwrite '{value}'. Valid options: {list(_OVERWRITE)}" + ) + return _OVERWRITE[key] + + +def _resolve_category_class(category: str): + """Find the generated model class whose ``type`` default targets ``category``. + + Avoids guessing the datamodel-code-generator class name; matches on the + ``type`` default (e.g. ``["Category:OSW..."]``) instead. + """ + for obj in vars(model_entity).values(): + if not isinstance(obj, type) or not hasattr(obj, "__fields__"): + continue + field = obj.__fields__.get("type") + default = getattr(field, "default", None) if field is not None else None + if default and category in default: + return obj + return None + + +@operation(group="entity", cli_name="get", read_only_hint=True, idempotent_hint=True) +def get_entity(ctx: Context, title: str) -> dict: + """Return an entity's stored JSON data (its ``jsondata`` slot). + + ``title`` is a full page name, e.g. ``Item:OSW123...``. Reading the slot + directly does not modify any local files. + """ + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + return {"title": title, "exists": False, "jsondata": None} + content, truncated = maybe_truncate( + page.get_slot_content("jsondata"), ctx.settings.max_chars + ) + return { + "title": title, + "exists": True, + "jsondata": content, + "url": page.get_url(), + "truncated": truncated, + } + + +@operation(group="entity", cli_name="export", read_only_hint=True, idempotent_hint=True) +def export_entity_jsonld( + ctx: Context, title: str, mode: str = "expand", build_rdf: bool = False +) -> dict: + """Export an entity as JSON-LD (and optionally RDF/Turtle). + + ``mode`` is one of expand | flatten | compact | frame. Note: this loads + the entity with schema auto-fetch, which regenerates the local generated + model module as a side effect. + """ + result = ctx.osw.load_entity( + OSW.LoadEntityParam(titles=[title], autofetch_schema=True) + ) + entities = result.entities + if not isinstance(entities, list): + entities = [entities] + if not entities: + raise errors.NotFound(f"Entity '{title}' not found.") + export = ctx.osw.export_jsonld( + OSW.ExportJsonLdParams(entities=entities, mode=mode, build_rdf_graph=build_rdf) + ) + out = {"jsonld": to_jsonable(export.documents[0]) if export.documents else None} + if build_rdf and export.graph is not None: + out["rdf_turtle"] = export.graph.serialize(format="turtle") + return out + + +@operation( + group="entity", + cli_name="put", + writes=True, + destructive_hint=False, + idempotent_hint=True, + records=lambda r: [ + LedgerRecord( + title=t, op="create_or_update", change_id=r["change_id"], slots=["jsondata"] + ) + for t in r["titles"] + ], +) +def create_or_update_entity( + ctx: Context, + category: str, + jsondata: Annotated[dict, typer.Option(parser=json_value)], + namespace: Optional[str] = None, + overwrite: str = "keep existing", + comment: Optional[str] = None, +) -> dict: + """Create or update an entity of ``category`` from a ``jsondata`` payload. + + ``category`` is a full category page name (e.g. ``Category:Item``); use + ``get_category_schema`` to learn the valid fields first. ``overwrite`` + controls update behavior: one of true | false | only empty | + replace remote | keep existing. Records the resulting page(s) in the + provenance ledger so they can be deleted without extra confirmation. + """ + fetch = ctx.osw.fetch_schema( + OSW.FetchSchemaParam(schema_title=category, mode="append") + ) + if fetch.error_messages: + raise errors.SchemaError("; ".join(fetch.error_messages)) + cls = _resolve_category_class(category) + if cls is None: + raise errors.ClassNotFound( + f"Could not resolve a model class for '{category}' after " + "fetching its schema. Check the category page name." + ) + try: + entity = cls(**jsondata) + except Exception as exc: + raise errors.ValidationError( + f"jsondata does not validate against {category}: {exc}" + ) + store = ctx.osw.store_entity( + OSW.StoreEntityParam( + entities=[entity], + namespace=namespace, + overwrite=_parse_overwrite(overwrite), + edit_comment=comment, + bot_edit=True, + ) + ) + titles = list(store.pages.keys()) + domain = config.get_active_domain() + return { + "titles": titles, + "change_id": store.change_id, + "urls": [f"https://{domain}/wiki/{t}" for t in titles], + } + + +@operation( + group="entity", + cli_name="delete", + writes=True, + destructive_hint=True, + requires_user_interaction=True, +) +def delete_entity( + ctx: Context, + title: str, + confirm_external_delete: bool = False, + comment: Optional[str] = None, +) -> dict: + """Delete a page by full title, guarded by provenance. + + Pages this server created/modified (tracked in the ledger) are deleted + without extra confirmation. Deleting any other page requires + ``confirm_external_delete=true``. + """ + tracked = ctx.ledger.is_tracked(title) + if not tracked and not confirm_external_delete: + raise errors.ExternalDeleteBlocked( + f"Refusing to delete '{title}': it was not created by this " + "MCP server. Re-run with confirm_external_delete=true to " + "override.", + extra={"title": title}, + ) + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + raise errors.NotFound( + f"Page '{title}' does not exist.", + extra={"title": title, "deleted": False}, + ) + if not tracked: + print( + f"[osw-mcp] WARNING: deleting externally-created page " + f"'{title}' (confirm_external_delete=True)", + file=sys.stderr, + ) + page.delete(comment or "[osw-mcp] delete") + ctx.ledger.mark_deleted(title) + return {"title": title, "deleted": True} diff --git a/src/osw/service/ops/files.py b/src/osw/service/ops/files.py new file mode 100644 index 0000000..50f31af --- /dev/null +++ b/src/osw/service/ops/files.py @@ -0,0 +1,153 @@ +"""Path-free wiki file content operations: info, read, write. + +``WikiFileController.get()`` returns a live stream and ``.put()`` accepts one +(see ``osw.controller.file.wiki``), so these operations never touch the local +filesystem: content moves between the wiki and the caller entirely in +memory, in bounded chunks. Path-taking counterparts (download to disk, upload +from disk) live in ``osw.cli.ops``, the only module allowed to name a path. +""" + +from __future__ import annotations + +import codecs +from io import BytesIO +from typing import Optional + +from osw.controller.file.wiki import WikiFileController +from osw.core import OverwriteOptions +from osw.service import errors +from osw.service.context import Context +from osw.service.ledger import LedgerRecord +from osw.service.registry import operation +from osw.utils.wiki import title_from_full_title +from osw.wtsite import WtSite + + +def _file_controller(ctx: Context, title: str) -> WikiFileController: + """Build a ``WikiFileController`` bound to ``title`` (a full ``File:`` title).""" + return WikiFileController( + osw=ctx.osw, title=title_from_full_title(title), namespace="File" + ) + + +@operation( + group="file", + cli_name="info", + read_only_hint=True, + idempotent_hint=True, +) +def get_file_info(ctx: Context, title: str) -> dict: + """Return a wiki file's metadata: url, existence, size and media type. + + ``title`` is a full ``File:`` page title. Reads only the headers of the + same download stream ``read_file_text`` uses; the file's content is + never pulled into memory. + """ + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + return { + "title": title, + "exists": False, + "url": None, + "size": None, + "media_type": None, + } + + wf = _file_controller(ctx, title) + stream = wf.get() + try: + size = stream.headers.get("Content-Length") + media_type = stream.headers.get("Content-Type") + finally: + stream.close() + return { + "title": title, + "exists": True, + "url": wf.url, + "size": int(size) if size is not None else None, + "media_type": media_type, + } + + +@operation( + group="file", + cli_name="cat", + read_only_hint=True, + idempotent_hint=True, +) +def read_file_text( + ctx: Context, title: str, encoding: str = "utf-8", limit: Optional[int] = None +) -> dict: + """Read a wiki file's content as text, returned inline in the result. + + Reads at most ``limit`` (or the server's configured max_chars) bytes plus + one, so an oversized file is never pulled fully into memory; truncation + is reported in the result rather than silently dropping content. If the + bytes do not decode under ``encoding``, use ``osw file download`` instead + to fetch the file to disk. + """ + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + raise errors.NotFound(f"File '{title}' does not exist.") + + cap = limit if limit is not None else ctx.settings.max_chars + wf = _file_controller(ctx, title) + stream = wf.get() + try: + raw = stream.read(cap + 1) + finally: + stream.close() + truncated = len(raw) > cap + if truncated: + raw = raw[:cap] + try: + # Decoded incrementally, with final=False when the read was capped: + # `cap` counts bytes, so truncating can split a multi-byte character. + # A plain bytes.decode() would raise on that trailing fragment and a + # perfectly valid text file would be reported as binary. final=False + # buffers the fragment (and so discards it) while still raising on + # bytes that are genuinely undecodable. + content = codecs.getincrementaldecoder(encoding)().decode(raw, not truncated) + except UnicodeDecodeError as exc: + raise errors.BinaryContent( + f"File '{title}' is not valid {encoding} text; use " + "`osw file download` instead to fetch it to disk." + ) from exc + return { + "title": title, + "content": content, + "encoding": encoding, + "truncated": truncated, + } + + +@operation( + group="file", + cli_name="write", + writes=True, + destructive_hint=False, + idempotent_hint=True, + records=lambda r: [LedgerRecord(title=r["title"], op="create", slots=["jsondata"])], +) +def write_file_text( + ctx: Context, + title: str, + content: str, + name: Optional[str] = None, + overwrite: bool = True, +) -> dict: + """Write text content to a wiki file page, creating or overwriting it. + + ``title`` is a full ``File:`` page title. ``name`` sets the uploaded + file's base name (defaults to the bare filename portion of ``title``). + Records the page in the provenance ledger. + """ + wf = _file_controller(ctx, title) + stream = BytesIO(content.encode("utf-8")) + stream.name = name or title_from_full_title(title) + overwrite_opt = OverwriteOptions.true if overwrite else OverwriteOptions.false + wf.put(stream, overwrite=overwrite_opt) + return { + "title": f"{wf.namespace}:{wf.title}", + "url": wf.url, + } diff --git a/src/osw/service/ops/schema.py b/src/osw/service/ops/schema.py new file mode 100644 index 0000000..363feb1 --- /dev/null +++ b/src/osw/service/ops/schema.py @@ -0,0 +1,38 @@ +"""Schema introspection: fetch a category's JSON Schema so the model can build +valid entities before writing them.""" + +from __future__ import annotations + +from osw.service.context import Context +from osw.service.registry import operation +from osw.service.serialization import maybe_truncate +from osw.wtsite import WtSite + + +@operation( + group="schema", + cli_name="get", + read_only_hint=True, + idempotent_hint=True, + max_result_size_chars=200_000, +) +def get_category_schema(ctx: Context, category: str) -> dict: + """Return the JSON Schema of a category (its ``jsonschema`` slot). + + ``category`` is a full category page name, e.g. ``Category:Item``. The + schema is read directly from the page slot, which - unlike fetching and + generating models - does not modify any local files. Use the returned + schema to construct a valid ``jsondata`` payload for + ``create_or_update_entity``. + """ + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[category])).pages[0] + if not page.exists: + return {"category": category, "exists": False, "schema": None} + schema = page.get_slot_content("jsonschema") + content, truncated = maybe_truncate(schema, ctx.settings.max_chars) + return { + "category": category, + "exists": True, + "schema": content, + "truncated": truncated, + } diff --git a/src/osw/service/ops/search.py b/src/osw/service/ops/search.py new file mode 100644 index 0000000..09ae11a --- /dev/null +++ b/src/osw/service/ops/search.py @@ -0,0 +1,110 @@ +"""Search and query operations: semantic (SMW ask), full-text, instances, SPARQL.""" + +from __future__ import annotations + +from typing import Optional + +from osw.core import OSW +from osw.service import config, errors +from osw.service.context import Context +from osw.service.registry import operation +from osw.service.serialization import cap_list, to_jsonable +from osw.sparql_client_smw import SmwSparqlClient +from osw.wtsite import WtSite + + +@operation( + group="search", + cli_name="ask", + read_only_hint=True, + idempotent_hint=True, +) +def search_entities(ctx: Context, ask_query: str, limit: Optional[int] = None) -> dict: + """Run a Semantic MediaWiki 'ask' query and return matching page titles. + + The query uses SMW ask syntax, e.g. ``[[Category:Item]]`` or + ``[[Category:Item]][[Keyword::sensor]]``. Returns full page titles. + """ + lim = ctx.limit(limit) + titles = ctx.osw.site.semantic_search( + WtSite.SearchParam(query=ask_query, limit=lim) + ) + capped, total, truncated = cap_list(titles, lim) + return {"titles": capped, "count": total, "truncated": truncated} + + +@operation( + group="search", + cli_name="text", + read_only_hint=True, + idempotent_hint=True, +) +def full_text_search(ctx: Context, text: str, limit: Optional[int] = None) -> dict: + """Prefix/full-text search for pages whose title matches ``text``.""" + lim = ctx.limit(limit) + titles = ctx.osw.site.prefix_search(WtSite.SearchParam(query=text, limit=lim)) + capped, total, truncated = cap_list(titles, lim) + return {"titles": capped, "count": total, "truncated": truncated} + + +@operation( + group="search", + cli_name="instances", + read_only_hint=True, + idempotent_hint=True, +) +def list_instances_of_category( + ctx: Context, category: str, limit: Optional[int] = None +) -> dict: + """List full page titles of all instances of a category. + + ``category`` is a full category page name, e.g. ``Category:Item``. + """ + lim = ctx.limit(limit) + titles = ctx.osw.query_instances( + OSW.QueryInstancesParam(categories=category, limit=lim) + ) + capped, total, truncated = cap_list(titles, lim) + return {"titles": capped, "count": total, "truncated": truncated} + + +@operation( + group="search", + cli_name="sparql", + read_only_hint=True, + idempotent_hint=True, + open_world_hint=True, + max_result_size_chars=200_000, +) +def sparql_query( + ctx: Context, query: str, endpoint: Optional[str] = None, limit: int = 500 +) -> dict: + """Run a raw SPARQL query against the instance's SPARQL endpoint. + + The endpoint defaults to ``OSW_SPARQL_ENDPOINT``; pass ``endpoint`` to + override. Returns ``{vars, bindings, count, truncated}``. + """ + ep = endpoint or ctx.settings.sparql_endpoint + if not ep: + raise errors.NotConfigured( + "SPARQL endpoint not configured. Set OSW_SPARQL_ENDPOINT " + "or pass the 'endpoint' argument." + ) + + username, password = config.get_active_credentials() + client = SmwSparqlClient( + endpoint=ep, + domain=config.get_active_domain(), + auth="basic", + user=username, + password=password, + ) + raw = client.sparqlQuery(query) + bindings = raw.get("results", {}).get("bindings", []) + capped, total, truncated = cap_list(bindings, limit) + return { + "vars": raw.get("head", {}).get("vars", []), + "bindings": to_jsonable(capped), + "count": total, + "truncated": truncated, + } diff --git a/src/osw/service/ops/slots.py b/src/osw/service/ops/slots.py new file mode 100644 index 0000000..bd05512 --- /dev/null +++ b/src/osw/service/ops/slots.py @@ -0,0 +1,140 @@ +"""Full multi-slot page access: list slots, read a slot, write a slot. + +OSW pages are multi-slot MediaWiki pages. The valid slot keys and their content +models come from :data:`osw.wtsite.SLOTS` (main, jsondata, jsonschema, header, +footer, template, header_template, footer_template, data_template, +schema_template). +""" + +from __future__ import annotations + +from typing import Optional, Union + +from osw.service import errors +from osw.service.context import Context +from osw.service.ledger import LedgerRecord +from osw.service.registry import operation +from osw.service.serialization import maybe_truncate +from osw.wtsite import SLOTS, WtSite + + +@operation( + group="slot", + cli_name="list", + read_only_hint=True, + idempotent_hint=True, +) +def list_page_slots(ctx: Context, title: str) -> dict: + """List the slots present on a page with their content models.""" + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + return { + "title": title, + "exists": False, + "slots": [], + "valid_slot_keys": list(SLOTS), + } + slots = [] + for key in page._slots: + content = page.get_slot_content(key) + slots.append({ + "key": key, + "content_model": page.get_slot_content_model(key), + "empty": content in (None, "", {}, []), + }) + return { + "title": title, + "exists": True, + "slots": slots, + "valid_slot_keys": list(SLOTS), + } + + +@operation( + group="slot", + cli_name="get", + read_only_hint=True, + idempotent_hint=True, +) +def get_slot(ctx: Context, title: str, slot: str) -> dict: + """Return the content of a single slot of a page. + + ``slot`` must be one of the valid slot keys (see ``list_page_slots``). + """ + if slot not in SLOTS: + raise errors.InvalidSlot(f"Unknown slot '{slot}'. Valid slots: {list(SLOTS)}") + + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists or slot not in page._slots: + return {"title": title, "slot": slot, "exists": False, "content": None} + content, truncated = maybe_truncate( + page.get_slot_content(slot), ctx.settings.max_chars + ) + return { + "title": title, + "slot": slot, + "exists": True, + "content_model": page.get_slot_content_model(slot), + "content": content, + "truncated": truncated, + } + + +@operation( + group="slot", + cli_name="set", + writes=True, + destructive_hint=False, + idempotent_hint=True, + records=lambda r: ( + [LedgerRecord(title=r["title"], op="update", slots=[r["slot"]])] + if r.get("changed") + else [] + ), +) +def set_slot( + ctx: Context, + title: str, + slot: str, + # Deliberately left without a typer marker: typer has no support for + # arbitrary Union types (verified empirically), and whether this is JSON + # depends on the sibling `slot` argument's content model, so a single + # static parser would be wrong. osw.cli.main handles the CLI coercion + # explicitly, after both arguments are known. + content: Union[str, dict, list], + comment: Optional[str] = None, + create_if_missing: bool = True, +) -> dict: + """Write the content of a single slot and save the page. + + JSON slots (jsondata, jsonschema) require an object/array; wikitext slots + require a string. Records the page in the provenance ledger. + """ + if slot not in SLOTS: + raise errors.InvalidSlot(f"Unknown slot '{slot}'. Valid slots: {list(SLOTS)}") + content_model = SLOTS[slot]["content_model"] + if content_model == "json" and not isinstance(content, (dict, list)): + raise errors.InvalidContent( + f"Slot '{slot}' is JSON; content must be an object or array." + ) + if content_model == "wikitext" and not isinstance(content, str): + raise errors.InvalidContent( + f"Slot '{slot}' is wikitext; content must be a string." + ) + + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if slot not in page._slots: + if not create_if_missing: + raise errors.SlotMissing( + f"Slot '{slot}' does not exist on '{title}' and " + "create_if_missing is false." + ) + page.create_slot(slot, content_model) + page.set_slot_content(slot, content) + page.edit(comment=comment or f"[osw-mcp] set_slot {slot}", bot_edit=True) + return { + "title": title, + "slot": slot, + "changed": True, + "url": page.get_url(), + } diff --git a/src/osw/service/ops/status.py b/src/osw/service/ops/status.py new file mode 100644 index 0000000..90073bb --- /dev/null +++ b/src/osw/service/ops/status.py @@ -0,0 +1,59 @@ +"""Status / whoami operation: report connection and configuration (no secrets).""" + +from __future__ import annotations + +import sys + +from osw.service import config +from osw.service.context import Context +from osw.service.registry import operation + + +def _osw_version(): + try: + from importlib.metadata import version + + return version("osw") + except Exception: + return None + + +@operation(group=None, read_only_hint=True, idempotent_hint=True) +def status(ctx: Context) -> dict: + """Report the active instance, user, mode and ledger info. + + Performs a light connectivity check, but only when an instance is + selected. Never returns the password. + """ + settings = ctx.settings + active_iri = config.get_active_iri() + active_domain = config.get_active_domain() + info = { + **settings.redacted(), + "active_iri": active_iri, + "active_domain": active_domain, + } + if active_iri is None: + available = ", ".join(config.available_iris()) or "(none)" + info["connected"] = False + info["message"] = ( + "No OSL instance selected. For a server process, set OSW_DOMAIN " + "(or OSW_ENV_FILE to point at a .env file that sets it); for the " + f"CLI, pass --instance . Available: {available}." + ) + return info + ledger = ctx.ledger + info["ledger_entry_count"] = ledger.entry_count() + info["osw_version"] = _osw_version() + try: + with ctx.guard(): + _ = ctx.osw + info["connected"] = True + except Exception as exc: + print( + f"[osw-mcp] status connection check failed: {exc!r}", + file=sys.stderr, + ) + info["connected"] = False + info["connection_error"] = str(exc) + return info diff --git a/src/osw/service/params.py b/src/osw/service/params.py new file mode 100644 index 0000000..92eaf44 --- /dev/null +++ b/src/osw/service/params.py @@ -0,0 +1,49 @@ +"""Parsers for operation parameters whose CLI form differs from their Python type. + +An operation declares its parameter surface once, so a parameter typed ``dict`` +needs a way to say how a shell should spell it. typer reads that from +``Annotated[..., typer.Option(parser=...)]`` metadata on the parameter, and +pydantic ignores metadata it does not recognise, so attaching a parser here +leaves the MCP JSON schema untouched. + +This module lives in ``osw.service`` rather than ``osw.cli`` so the dependency +runs adapter -> core: an op module must never import an adapter. typer is a base +dependency, so importing it here costs nothing extra. ``typer.BadParameter`` is +used deliberately -- click discards the message of a plain ``ValueError`` raised +from a ``parser=`` callback and reports only the offending value. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +import typer + + +def json_value(raw: str) -> Any: + """Typer parser for structured (JSON) CLI parameters. + + Accepts a JSON literal, ``@path/to/file.json`` (read the file's + contents), or ``-`` (read from stdin). + """ + if raw == "-": + source = "stdin" + text = sys.stdin.read() + elif raw.startswith("@"): + path = raw[1:] + source = path + try: + text = Path(path).read_text(encoding="utf-8") + except OSError as exc: + raise typer.BadParameter(f"Could not read '{path}': {exc}") + else: + source = "argument" + text = raw + + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise typer.BadParameter(f"Invalid JSON ({source}): {exc}") diff --git a/src/osw/service/registry.py b/src/osw/service/registry.py new file mode 100644 index 0000000..81fe473 --- /dev/null +++ b/src/osw/service/registry.py @@ -0,0 +1,198 @@ +"""Operation registry: one decorated function exposed identically by every +osw.service adapter (MCP, CLI, ...). + +An :class:`Operation` pairs a plain function -- whose first parameter is a +:class:`~osw.service.context.Context` and whose remaining parameters are its +public parameter surface -- with the metadata each adapter needs (MCP tool +annotations, CLI grouping, ledger recording). Adding an operation means +writing one decorated function; no adapter needs editing. + +This module imports nothing from the ``mcp`` SDK, ``typer``, or ``osw.cli``. +""" + +from __future__ import annotations + +import inspect +import sys +from typing import Any, Callable, Iterator, Literal, Optional, get_type_hints + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from osw.service.context import Context +from osw.service.errors import OpError +from osw.service.ledger import LedgerRecord + +PATH_LIKE_NAMES = frozenset({ + "path", + "paths", + "filepath", + "file_path", + "dir", + "directory", + "target_dir", + "target_path", + "source_path", + "dest", + "destination", + "output_path", + "outfile", + "local_path", +}) + + +class Operation(BaseModel): + """One osw operation, exposed identically by every adapter. + + ``fn``'s first parameter is a Context; its remaining parameters *are* the + public parameter surface. The MCP SDK derives its JSON schema from them and + typer derives its CLI options from them, so adding an operation means + writing one decorated function and editing no adapter. + """ + + model_config = ConfigDict(frozen=True, extra="forbid", arbitrary_types_allowed=True) + + name: str + fn: Callable[..., dict] + group: Optional[str] = None # CLI first level, e.g. "entity" + cli_name: Optional[str] = None # CLI second level; defaults to name + summary: str = "" + writes: bool = False + surfaces: frozenset[Literal["mcp", "cli"]] = frozenset({"mcp", "cli"}) + # ledger hook: given the fn's result, returns the entries to record after + # a successful write. ``tool`` is not part of ``LedgerRecord``; ``bind()`` + # fills it in from the operation name. + records: Optional[Callable[[dict], list[LedgerRecord]]] = None + + # MCP tool annotations: the spec's four hints, explicit and typed rather + # than a dict. The adapter maps these onto ToolAnnotations, so this + # module still imports nothing from the mcp SDK. + read_only_hint: Optional[bool] = None + destructive_hint: Optional[bool] = None + idempotent_hint: Optional[bool] = None + open_world_hint: Optional[bool] = None + + # MCP _meta: open-ended by spec, so the two keys we use are typed and + # anything else goes through the escape hatch. + requires_user_interaction: bool = False + max_result_size_chars: Optional[int] = None + extra_meta: dict[str, Any] = Field(default_factory=dict) + + @property + def command(self) -> str: + """The CLI second-level command name.""" + return self.cli_name or self.name + + @model_validator(mode="after") + def _validate(self) -> Operation: + params = list(inspect.signature(self.fn).parameters.values()) + if not params: + raise ValueError(f"{self.name}: fn must take at least one parameter (ctx).") + if params[0].name != "ctx": + raise ValueError( + f"{self.name}: fn's first parameter must be named 'ctx', got " + f"{params[0].name!r}." + ) + if self.records is not None and not self.writes: + raise ValueError( + f"{self.name}: records is set but writes is False; it would never fire." + ) + if not (self.fn.__doc__ and self.fn.__doc__.strip()): + raise ValueError( + f"{self.name}: fn must have a non-empty docstring; it becomes " + "the MCP tool description and the CLI help." + ) + if "mcp" in self.surfaces: + offending = [p.name for p in params[1:] if p.name in PATH_LIKE_NAMES] + if offending: + raise ValueError( + f"{self.name}: parameter(s) {', '.join(offending)} look " + "like filesystem paths and may not be exposed on the mcp " + "surface; no path may reach an MCP client." + ) + return self + + +REGISTRY: dict[str, Operation] = {} + + +def operation(**kwargs: Any) -> Callable[[Callable[..., dict]], Callable[..., dict]]: + """Decorate ``fn`` as an :class:`Operation`, registering it in :data:`REGISTRY`. + + Returns ``fn`` unchanged so it stays directly callable and unit-testable. + """ + + def deco(fn: Callable[..., dict]) -> Callable[..., dict]: + name = kwargs.get("name") or fn.__name__ + if name in REGISTRY: + raise ValueError( + f"{name}: an operation with this name is already registered." + ) + fields = {**kwargs, "name": name, "fn": fn} + REGISTRY[name] = Operation(**fields) + return fn + + return deco + + +def iter_operations( + *, surface: str, include_writes: bool = True +) -> Iterator[Operation]: + """Yield registered operations available on ``surface``, in registration order.""" + for op in REGISTRY.values(): + if surface not in op.surfaces: + continue + if op.writes and not include_writes: + continue + yield op + + +def bind(op: Operation, ctx: Context) -> Callable[..., dict]: + """Apply ``ctx`` to ``op.fn`` and hide it from the resulting signature.""" + + def bound(*args: Any, **kwargs: Any) -> dict: + try: + if op.writes: + ctx.require_write(op.name) + with ctx.guard(): + result = op.fn(ctx, *args, **kwargs) + if op.writes and op.records is not None: + for rec in op.records(result): + ctx.ledger.record( + rec.title, tool=op.name, **rec.model_dump(exclude={"title"}) + ) + return result + except Exception as exc: + if not ctx.policy.errors_as_dicts: + raise + print(f"[osw] {op.name} failed: {exc!r}", file=sys.stderr) + if isinstance(exc, OpError): + return exc.payload() + return {"error": str(exc), "type": type(exc).__name__} + + # Resolve annotations here, against the op module's globals. `bound` lives in + # this module, so a consumer calling get_type_hints() on it would otherwise + # try to resolve `from __future__ import annotations` strings against the + # wrong namespace. include_extras keeps Annotated[...] metadata intact. + try: + hints = get_type_hints(op.fn, include_extras=True) + except Exception: # unresolvable forward ref: leave the strings in place + hints = {} + + sig = inspect.signature(op.fn) + params = [ + p.replace(annotation=hints.get(p.name, p.annotation)) + for p in list(sig.parameters.values())[1:] # drop ctx + ] + annotations = dict(getattr(op.fn, "__annotations__", {})) + annotations.update(hints) + annotations.pop("ctx", None) + + bound.__name__ = op.fn.__name__ + bound.__qualname__ = op.fn.__qualname__ + bound.__doc__ = op.fn.__doc__ + bound.__signature__ = sig.replace( + parameters=params, + return_annotation=hints.get("return", sig.return_annotation), + ) + bound.__annotations__ = annotations + return bound diff --git a/src/osw/service/serialization.py b/src/osw/service/serialization.py new file mode 100644 index 0000000..780a169 --- /dev/null +++ b/src/osw/service/serialization.py @@ -0,0 +1,51 @@ +"""JSON-safety and truncation helpers for tool return values. + +Tool results are sent over the wire as JSON and shown to a model, so they must +be JSON-serializable and reasonably small. These helpers cap list lengths and +large text/JSON blobs, flagging when truncation occurred so the caller can +narrow the query. +""" + +from __future__ import annotations + +import json +from typing import Any, List, Tuple + + +def to_jsonable(obj: Any) -> Any: + """Best-effort conversion of ``obj`` into a JSON-serializable structure. + + Falls back to ``str`` for anything json cannot encode (dates, Paths, etc.). + """ + return json.loads(json.dumps(obj, default=str, ensure_ascii=False)) + + +def cap_list(items: List[Any], limit: int) -> Tuple[List[Any], int, bool]: + """Cap a list to ``limit`` entries. + + Returns ``(capped_items, total_count, truncated)``. + """ + items = list(items) + total = len(items) + if limit is not None and total > limit: + return items[:limit], total, True + return items, total, False + + +def maybe_truncate(value: Any, max_chars: int) -> Tuple[Any, bool]: + """Truncate ``value`` if its JSON/text form exceeds ``max_chars``. + + For strings, the string is truncated directly. For other structures, the + value is returned unchanged when small enough, otherwise a truncated JSON + string of it is returned. Returns ``(value_or_truncated, truncated)``. + """ + if value is None: + return None, False + if isinstance(value, str): + if len(value) > max_chars: + return value[:max_chars], True + return value, False + encoded = json.dumps(value, default=str, ensure_ascii=False) + if len(encoded) > max_chars: + return encoded[:max_chars], True + return to_jsonable(value), False diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py new file mode 100644 index 0000000..a120631 --- /dev/null +++ b/tests/integration/test_mcp_server.py @@ -0,0 +1,82 @@ +"""Integration tests for the osw-mcp server against a live OSL instance. + +Excluded from the default run (tests/integration is ignored). Provide live +credentials to run: + + uv run pytest tests/integration/test_mcp_server.py -o addopts="" \ + --wiki_domain --wiki_username --wiki_password + +The wiki_* fixtures self-skip when credentials are absent. +""" + +import pytest + +pytest.importorskip("mcp", reason="requires the osw[mcp] extra") +pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") + +import osw.service.ops # noqa: F401 (registers the operations) +from osw.service import config +from osw.service.context import Context, Policy +from osw.service.registry import bind, iter_operations + + +@pytest.fixture +def mcp_tools(wiki_domain, wiki_username, wiki_password, tmp_path, monkeypatch): + empty = tmp_path / "empty.env" + empty.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_MCP_ENV_FILE", str(empty)) + monkeypatch.setenv("OSW_DOMAIN", wiki_domain) + monkeypatch.setenv("OSW_USERNAME", wiki_username) + monkeypatch.setenv("OSW_PASSWORD", wiki_password) + monkeypatch.setenv("OSW_MCP_STATE_DIR", str(tmp_path / "state")) + config.reset() + + ctx = Context( + config.get_settings(), + Policy( + capture_stdout=True, + errors_as_dicts=True, + allow_writes=True, + allow_interactive=False, + ), + ) + tools = { + op.name: bind(op, ctx) + for op in iter_operations(surface="mcp", include_writes=True) + } + + yield tools + + ctx.close() + config.reset() + + +def test_status_connects(mcp_tools): + result = mcp_tools["status"]() + assert result["connected"] is True + assert "password" not in result + + +def test_search_schema_and_read(mcp_tools): + found = mcp_tools["search_entities"](ask_query="[[Category:Item]]", limit=5) + assert "titles" in found + + category_schema = mcp_tools["get_category_schema"](category="Category:Item") + assert "exists" in category_schema + + if found["titles"]: + title = found["titles"][0] + entity = mcp_tools["get_entity"](title=title) + assert entity["title"] == title + assert entity["exists"] is True + + page_slots = mcp_tools["list_page_slots"](title=title) + assert page_slots["exists"] is True + assert any(s["key"] == "jsondata" for s in page_slots["slots"]) + + +def test_delete_guard_blocks_untracked(mcp_tools): + # A page the server never created must be refused without confirmation; + # this returns before any network delete, so it never mutates the instance. + result = mcp_tools["delete_entity"](title="Item:OSWdoesnotexistguardcheck") + assert result["type"] == "ExternalDeleteBlocked" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d932458 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,429 @@ +"""Unit tests for the osw CLI (src/osw/cli). + +Runs in the plain dev env (no mcp extra needed): the CLI never imports the +mcp SDK. No network is touched -- ``osw.service.context.OswExpress`` is +patched wherever a test actually reaches a command's body. +""" + +from __future__ import annotations + +import io +import json +from unittest.mock import MagicMock + +import pytest +import typer +import yaml +from typer.testing import CliRunner + +import osw.cli.main as cli_main +from osw.cli.main import app +from osw.cli.render import render +from osw.core import OverwriteOptions +from osw.service import config +from osw.service.params import json_value +from osw.service.registry import iter_operations + +_ALL_VARS = [ + "OSW_DOMAIN", + "OSL_DOMAIN", + "OSW_USERNAME", + "OSL_USERNAME", + "OSW_PASSWORD", + "OSL_PASSWORD", + "OSW_CRED_FILEPATH", + "OSW_MCP_CRED_FILEPATH", + "OSL_CRED_FILEPATH", + "OSW_SPARQL_ENDPOINT", + "OSW_READ_ONLY", + "OSW_MCP_READ_ONLY", + "OSW_STATE_DIR", + "OSW_MCP_STATE_DIR", + "OSW_MAX_RESULTS", + "OSW_MCP_MAX_RESULTS", + "OSW_MAX_CHARS", + "OSW_MCP_MAX_CHARS", + "OSW_ENV_FILE", + "OSW_MCP_ENV_FILE", +] + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch, tmp_path): + """No real credentials, no real .env file, no leaked active instance.""" + for var in _ALL_VARS: + monkeypatch.delenv(var, raising=False) + empty = tmp_path / "empty.env" + empty.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_ENV_FILE", str(empty)) + config.reset() + yield + config.reset() + + +@pytest.fixture +def configured_env(monkeypatch, tmp_path): + """Just enough configuration for config.load(strict=False) to succeed.""" + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "u") + monkeypatch.setenv("OSW_PASSWORD", "p") + monkeypatch.setenv("OSW_STATE_DIR", str(tmp_path / "state")) + config.reset() + + +@pytest.fixture +def runner(): + return CliRunner(mix_stderr=False) + + +def _error_lines(stderr: str) -> list[str]: + """``stderr`` minus the ``[osw]`` config banner every command prints.""" + return [ + line for line in stderr.strip().splitlines() if not line.startswith("[osw] ") + ] + + +def _fake_osw_with_page(exists=True): + page = MagicMock() + page.exists = exists + fake_osw = MagicMock() + fake_osw.site.get_page.return_value.pages = [page] + return fake_osw, page + + +# -- help works with no configuration present -------------------------------- +@pytest.mark.parametrize( + "args", + [["--help"], ["entity", "--help"], ["entity", "get", "--help"]], +) +def test_help_works_with_no_config_present(runner, args): + result = runner.invoke(app, args) + assert result.exit_code == 0, result.stderr + + +# -- lazy Context ------------------------------------------------------------- +def test_context_is_not_built_at_import_or_help_time(monkeypatch, runner): + """Building the app / answering --help must never construct a Context.""" + calls = [] + orig_init = cli_main.Context.__init__ + + def spy_init(self, *args, **kwargs): + calls.append((args, kwargs)) + return orig_init(self, *args, **kwargs) + + monkeypatch.setattr(cli_main.Context, "__init__", spy_init) + + result = runner.invoke(app, ["entity", "get", "--help"]) + + assert result.exit_code == 0 + assert calls == [] + + +# -- command tree --------------------------------------------------------------- +def test_every_cli_operation_is_registered_at_its_expected_path(): + click_app = typer.main.get_command(app) + for op in iter_operations(surface="cli"): + if op.group is None: + assert op.command in click_app.commands, op.command + else: + assert op.group in click_app.commands, op.group + group_cmd = click_app.commands[op.group] + assert op.command in group_cmd.commands, (op.group, op.command) + + +# -- successful command / rendering -------------------------------------------- +def test_successful_command_renders_to_stdout(runner, configured_env, monkeypatch): + fake_osw, page = _fake_osw_with_page() + page.get_slot_content.return_value = {"label": [{"text": "X"}]} + page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1" + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + result = runner.invoke(app, ["entity", "get", "Item:OSW1"]) + + assert result.exit_code == 0, result.stderr + assert "Item:OSW1" in result.stdout + assert "exists" in result.stdout + + +def test_json_flag_emits_parseable_json(runner, configured_env, monkeypatch): + fake_osw, page = _fake_osw_with_page() + page.get_slot_content.return_value = {"label": [{"text": "X"}]} + page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1" + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + result = runner.invoke(app, ["--json", "entity", "get", "Item:OSW1"]) + + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload == { + "title": "Item:OSW1", + "exists": True, + "jsondata": {"label": [{"text": "X"}]}, + "url": "https://wiki.example.org/wiki/Item:OSW1", + "truncated": False, + } + + +# -- OpError exit codes / clean error output ------------------------------------ +def test_op_error_exits_with_its_exit_code_and_no_traceback(runner, configured_env): + result = runner.invoke(app, ["search", "sparql", "SELECT * WHERE {?s ?p ?o}"]) + + assert result.exit_code == 5 + assert _error_lines(result.stderr) == [ + "NotConfigured: SPARQL endpoint not configured. Set " + "OSW_SPARQL_ENDPOINT or pass the 'endpoint' argument." + ] + assert "Traceback" not in result.stderr + assert "Traceback" not in result.stdout + + +# -- --read-only ---------------------------------------------------------------- +def test_read_only_blocks_a_write_command(runner, configured_env): + result = runner.invoke( + app, + [ + "--read-only", + "entity", + "put", + "Category:Item", + "--jsondata", + '{"label": [{"text": "x"}]}', + ], + ) + + assert result.exit_code == 4 + assert _error_lines(result.stderr)[0].startswith("ReadOnly:") + assert "Traceback" not in result.stderr + + +# -- set_slot's slot-dependent content coercion --------------------------------- +# `content` is typed Union[str, dict, list] in the core and typer cannot express +# a Union, so osw.cli.main coerces it after both arguments are known, consulting +# the sibling `slot` argument's content model. Both directions matter: a JSON +# slot given a raw string fails with InvalidContent, and a wikitext slot must not +# have "123" silently parsed into an int. +def test_set_slot_parses_content_for_a_json_slot(runner, configured_env, monkeypatch): + fake_osw, page = _fake_osw_with_page() + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + result = runner.invoke(app, ["slot", "set", "Item:OSW1", "jsondata", '{"a": 1}']) + + assert result.exit_code == 0, result.stderr + page.set_slot_content.assert_called_once_with("jsondata", {"a": 1}) + + +def test_set_slot_leaves_wikitext_content_a_string(runner, configured_env, monkeypatch): + fake_osw, page = _fake_osw_with_page() + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + result = runner.invoke(app, ["slot", "set", "Item:OSW1", "main", "123"]) + + assert result.exit_code == 0, result.stderr + page.set_slot_content.assert_called_once_with("main", "123") + + +# -- json_value ----------------------------------------------------------------- +def test_json_value_parses_a_literal(): + assert json_value('{"a": 1}') == {"a": 1} + + +def test_json_value_reads_a_file(tmp_path): + path = tmp_path / "data.json" + path.write_text('{"a": 1}', encoding="utf-8") + assert json_value(f"@{path}") == {"a": 1} + + +def test_json_value_rejects_malformed_json(): + with pytest.raises(typer.BadParameter): + json_value("not-json") + + +# -- render ----------------------------------------------------------------- +def test_render_json_is_parseable(): + result = {"a": 1, "b": [1, 2]} + assert json.loads(render(result, as_json=True)) == result + + +def test_render_title_list_prints_titles_and_footer(): + result = {"titles": ["Item:OSW1", "Item:OSW2"], "count": 2, "truncated": False} + rendered = render(result, as_json=False) + lines = rendered.splitlines() + assert lines[0] == "Item:OSW1" + assert lines[1] == "Item:OSW2" + assert "2" in lines[2] + + +def test_render_dict_shows_key_value_lines(): + result = {"title": "Item:OSW1", "exists": True} + rendered = render(result, as_json=False) + assert "title" in rendered + assert "Item:OSW1" in rendered + assert "exists" in rendered + + +# -- CLI-only path-taking file commands (osw.cli.ops) --------------------------- +# These are the only operations in the codebase allowed to name a path; they +# are exercised here rather than in tests/test_service_ops_files.py. +def test_download_file_writes_to_tmp_path( + runner, configured_env, monkeypatch, tmp_path +): + fake_osw, _page = _fake_osw_with_page(exists=True) + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + wf = MagicMock() + wf.title = "OSWabc123.txt" + wf.get.return_value = io.BytesIO(b"hello world") + monkeypatch.setattr("osw.cli.ops.WikiFileController", MagicMock(return_value=wf)) + + result = runner.invoke( + app, + ["file", "download", "File:OSWabc123.txt", "--target-dir", str(tmp_path)], + ) + + assert result.exit_code == 0, result.stderr + written = tmp_path / "OSWabc123.txt" + assert written.read_bytes() == b"hello world" + + +def test_download_file_missing_page_raises_not_found( + runner, configured_env, monkeypatch, tmp_path +): + fake_osw, _page = _fake_osw_with_page(exists=False) + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + result = runner.invoke( + app, + ["file", "download", "File:doesnotexist.txt", "--target-dir", str(tmp_path)], + ) + + assert result.exit_code == 2 # NotFound + assert "NotFound" in result.stderr + + +def test_upload_file_reads_from_tmp_path(runner, configured_env, monkeypatch, tmp_path): + fake_osw, _page = _fake_osw_with_page(exists=True) + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + src = tmp_path / "photo.png" + src.write_bytes(b"binarydata") + + wf = MagicMock() + wf.namespace = "File" + wf.title = "OSWxyz.png" + wf.url = "https://wiki.example.org/wiki/File:OSWxyz.png" + monkeypatch.setattr("osw.cli.ops.WikiFileController", MagicMock(return_value=wf)) + captured = {} + wf.put.side_effect = lambda stream, **kwargs: captured.update( + name=stream.name, content=stream.read(), kwargs=kwargs + ) + + result = runner.invoke(app, ["file", "upload", str(src)]) + + assert result.exit_code == 0, result.stderr + wf.put.assert_called_once() + assert captured["name"] == "photo.png" + assert captured["content"] == b"binarydata" + assert captured["kwargs"] == {"overwrite": OverwriteOptions.true} + + +def test_upload_file_honors_name_and_no_overwrite( + runner, configured_env, monkeypatch, tmp_path +): + fake_osw, _page = _fake_osw_with_page(exists=True) + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + src = tmp_path / "photo.png" + src.write_bytes(b"binarydata") + + wf = MagicMock() + wf.namespace = "File" + wf.title = "OSWxyz.png" + wf.url = "https://wiki.example.org/wiki/File:OSWxyz.png" + monkeypatch.setattr("osw.cli.ops.WikiFileController", MagicMock(return_value=wf)) + captured = {} + wf.put.side_effect = lambda stream, **kwargs: captured.update( + name=stream.name, kwargs=kwargs + ) + + result = runner.invoke( + app, + ["file", "upload", str(src), "--name", "renamed.png", "--no-overwrite"], + ) + + assert result.exit_code == 0, result.stderr + assert captured["name"] == "renamed.png" + assert captured["kwargs"] == {"overwrite": OverwriteOptions.false} + + +def test_upload_file_missing_source_raises_not_found(runner, configured_env, tmp_path): + result = runner.invoke(app, ["file", "upload", str(tmp_path / "nope.png")]) + + assert result.exit_code == 2 # NotFound + assert "NotFound" in result.stderr + + +# -- ledger path ------------------------------------------------------------------ +def test_ledger_path_prints_the_ledger_file_path(runner, configured_env): + result = runner.invoke(app, ["ledger", "path"]) + + assert result.exit_code == 0, result.stderr + assert "path" in result.stdout + + +# -- instance list / --instance --------------------------------------------------- +def test_instance_list_never_leaks_credentials(runner, monkeypatch, tmp_path): + cred_file = tmp_path / "accounts.yaml" + cred_file.write_text( + yaml.safe_dump({ + "wiki-a.example.org": {"username": "alice", "password": "supersecret"}, + }), + encoding="utf-8", + ) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file)) + config.reset() + + result = runner.invoke(app, ["instance", "list"]) + + assert result.exit_code == 0, result.stderr + assert "wiki-a.example.org" in result.stdout + assert "supersecret" not in result.stdout + assert "alice" not in result.stdout + + +def test_instance_flag_sets_active_instance(runner, monkeypatch, tmp_path): + cred_file = tmp_path / "accounts.yaml" + cred_file.write_text( + yaml.safe_dump({ + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }), + encoding="utf-8", + ) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file)) + config.reset() + + result = runner.invoke( + app, ["--instance", "wiki-b.example.org", "--json", "instance", "list"] + ) + + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["active_iri"] == "wiki-b.example.org" + assert payload["active_domain"] == "wiki-b.example.org" + + +def test_instance_flag_unknown_iri_exits_cleanly(runner, monkeypatch, tmp_path): + cred_file = tmp_path / "accounts.yaml" + cred_file.write_text( + yaml.safe_dump({"wiki-a.example.org": {"username": "a", "password": "b"}}), + encoding="utf-8", + ) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file)) + config.reset() + + result = runner.invoke(app, ["--instance", "nope.example.org", "instance", "list"]) + + assert result.exit_code == 3 # UnknownInstance + assert result.stderr.strip().startswith("UnknownInstance:") + assert "wiki-a.example.org" in result.stderr + assert "Traceback" not in result.stderr diff --git a/tests/test_mcp_registration.py b/tests/test_mcp_registration.py new file mode 100644 index 0000000..7b0fd4f --- /dev/null +++ b/tests/test_mcp_registration.py @@ -0,0 +1,111 @@ +"""Unit tests for osw.mcp.server's Operation -> mcp.tool() kwargs mapping +(``_annotations``, ``_meta``, ``tool_kwargs``). + +Pure unit tests, offline, no network, no live wiki. Server-level +registration-shape tests (which tools end up on a real ``MCPServer``) live in +``tests/test_mcp_server.py``. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("mcp", reason="requires the osw[mcp] extra") + +from mcp.types import ToolAnnotations + +from osw.mcp.server import _annotations, _meta, tool_kwargs +from osw.service.config import Settings +from osw.service.registry import Operation + + +def _op(**kwargs) -> Operation: + def fn(ctx) -> dict: + """A test operation.""" + return {} + + fields = {"name": "an_op", "fn": fn, **kwargs} + return Operation(**fields) + + +def _settings(**kwargs) -> Settings: + fields = {"domain": "wiki.example.org", **kwargs} + return Settings(**fields) + + +# -- _annotations ------------------------------------------------------------- +def test_annotations_maps_every_hint_onto_its_named_field(): + op = _op( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, + ) + + annotations = _annotations(op) + + assert isinstance(annotations, ToolAnnotations) + # Assert on the real attributes (not a dict), so a misspelled field name + # in _annotations -- silently absorbed by ToolAnnotations' extra-field + # tolerance -- leaves these ``None`` and the test fails. + assert annotations.read_only_hint is True + assert annotations.destructive_hint is False + assert annotations.idempotent_hint is True + assert annotations.open_world_hint is False + + +def test_annotations_none_when_no_hint_is_set(): + op = _op() + + assert _annotations(op) is None + + +# -- _meta ---------------------------------------------------------------------- +def test_meta_falls_back_to_settings_max_chars(): + op = _op() + settings = _settings(max_chars=12_345) + + meta = _meta(op, settings) + + assert meta["anthropic/maxResultSizeChars"] == 12_345 + assert "anthropic/requiresUserInteraction" not in meta + + +def test_meta_honours_op_max_result_size_chars(): + op = _op(max_result_size_chars=999) + settings = _settings(max_chars=12_345) + + meta = _meta(op, settings) + + assert meta["anthropic/maxResultSizeChars"] == 999 + + +def test_meta_sets_requires_user_interaction_only_when_declared(): + plain = _meta(_op(), _settings()) + interactive = _meta(_op(requires_user_interaction=True), _settings()) + + assert "anthropic/requiresUserInteraction" not in plain + assert interactive["anthropic/requiresUserInteraction"] is True + + +def test_meta_extra_meta_merges_last(): + op = _op(extra_meta={"anthropic/maxResultSizeChars": 1, "custom": "x"}) + settings = _settings(max_chars=100) + + meta = _meta(op, settings) + + assert meta["anthropic/maxResultSizeChars"] == 1 + assert meta["custom"] == "x" + + +# -- tool_kwargs ------------------------------------------------------------------ +def test_tool_kwargs_uses_name_and_docstring(): + op = _op() + settings = _settings() + + kwargs = tool_kwargs(op, settings) + + assert kwargs["name"] == "an_op" + assert kwargs["description"] == "A test operation." + assert kwargs["annotations"] is None + assert "anthropic/maxResultSizeChars" in kwargs["meta"] diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..d84c772 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,161 @@ +"""Registration-shape tests for the osw-mcp server: which tools end up +registered on a real ``MCPServer``, not what any individual tool body does +(see ``tests/test_service_ops_*.py`` for that) and not the pure +``Operation`` -> ``mcp.tool()`` kwargs mapping (see +``tests/test_mcp_registration.py`` for that). + +These are fully offline: no network, no live wiki. +""" + +from __future__ import annotations + +import asyncio + +import pytest +import yaml + +pytest.importorskip("mcp", reason="requires the osw[mcp] extra") +pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") + +from osw.mcp import server +from osw.service import config +from osw.service.registry import iter_operations + +_ALL_VARS = [ + "OSW_DOMAIN", + "OSL_DOMAIN", + "OSW_USERNAME", + "OSL_USERNAME", + "OSW_PASSWORD", + "OSL_PASSWORD", + "OSW_CRED_FILEPATH", + "OSW_MCP_CRED_FILEPATH", + "OSL_CRED_FILEPATH", + "OSW_READ_ONLY", + "OSW_MCP_READ_ONLY", + "OSW_MCP_ENV_FILE", +] + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch, tmp_path): + for var in _ALL_VARS: + monkeypatch.delenv(var, raising=False) + # Point dotenv at an empty file so it never picks up a real .env on disk. + empty = tmp_path / "empty.env" + empty.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_MCP_ENV_FILE", str(empty)) + config.reset() + yield + config.reset() + + +def _configure(monkeypatch, *, read_only: bool = False) -> None: + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "u") + monkeypatch.setenv("OSW_PASSWORD", "p") + monkeypatch.setenv("OSW_READ_ONLY", "true" if read_only else "false") + config.reset() + + +def _tool_names(mcp) -> set[str]: + tools = asyncio.run(mcp.list_tools()) + return {t.name for t in tools} + + +def test_every_mcp_surface_op_is_registered_and_no_others(monkeypatch): + _configure(monkeypatch) + + names = _tool_names(server.create_server()) + + expected = {op.name for op in iter_operations(surface="mcp", include_writes=True)} + assert expected # the comparison below must not pass vacuously + assert names == expected + + +def test_jsondata_schema_unchanged_by_cli_typer_marker(monkeypatch): + """A typer marker in a core signature must not alter the MCP JSON schema. + + ``create_or_update_entity``'s ``jsondata`` carries an + ``Annotated[dict, typer.Option(parser=json_value)]`` marker so the CLI + knows how to spell it. That only works because pydantic ignores + Annotated metadata it does not recognise; if that ever stops holding, + the schema shipped to a model silently changes. + """ + _configure(monkeypatch) + + tools = asyncio.run(server.create_server().list_tools()) + tool = next(t for t in tools if t.name == "create_or_update_entity") + + assert tool.input_schema["properties"]["jsondata"]["type"] == "object" + + +def test_read_only_server_omits_writes_full_server_includes_them(monkeypatch): + _configure(monkeypatch, read_only=True) + names_read_only = _tool_names(server.create_server()) + + _configure(monkeypatch, read_only=False) + names_full = _tool_names(server.create_server()) + + assert "get_entity" in names_read_only # a reader survives read-only mode + assert "create_or_update_entity" not in names_read_only + assert "delete_entity" not in names_read_only + assert "create_or_update_entity" in names_full + assert "delete_entity" in names_full + + +def test_annotations_and_meta_reach_the_sdk_for_a_representative_op(monkeypatch): + _configure(monkeypatch) + + tools = {t.name: t for t in asyncio.run(server.create_server().list_tools())} + + tool = tools["delete_entity"] + assert tool.annotations is not None + assert tool.annotations.destructive_hint is True + assert tool.meta["anthropic/requiresUserInteraction"] is True + assert "anthropic/maxResultSizeChars" in tool.meta + + +def test_no_instance_switching_tools_registered(monkeypatch): + _configure(monkeypatch) + + names = _tool_names(server.create_server()) + + # Assert something WAS registered first: the two absence checks below + # would otherwise pass on an empty list. + assert "get_entity" in names + assert "list_instances" not in names + assert "select_instance" not in names + + +def _write_cred_file(tmp_path, iris): + cred_file = tmp_path / "accounts.yaml" + cred_file.write_text( + yaml.safe_dump({iri: {"username": "a", "password": "b"} for iri in iris}), + encoding="utf-8", + ) + return cred_file + + +def test_create_server_raises_when_no_domain_is_configured(monkeypatch, tmp_path): + # A credential file with more than one iri makes settings valid (no + # OSW_DOMAIN/OSW_USERNAME/OSW_PASSWORD required) but names no instance. + cred_file = _write_cred_file(tmp_path, ["wiki-a.example.org", "wiki-b.example.org"]) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + config.reset() + + with pytest.raises(RuntimeError, match="No OSL instance configured"): + server.create_server() + + +def test_create_server_does_not_auto_select_a_single_iri(monkeypatch, tmp_path): + # config.get_active_domain() *would* resolve this one (the CLI relies on + # that), but the server must not: which instance its tools reach has to be + # readable from the configuration, not inferred from the credential file. + cred_file = _write_cred_file(tmp_path, ["wiki-only.example.org"]) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + config.reset() + assert config.get_active_domain() == "wiki-only.example.org" + + with pytest.raises(RuntimeError, match="No OSL instance configured"): + server.create_server() diff --git a/tests/test_no_paths_on_mcp_surface.py b/tests/test_no_paths_on_mcp_surface.py new file mode 100644 index 0000000..8d5eb07 --- /dev/null +++ b/tests/test_no_paths_on_mcp_surface.py @@ -0,0 +1,97 @@ +"""Guard tests: no filesystem path may ever reach the MCP surface. + +Runs in the plain dev env (no mcp extra needed): importing ``osw.cli.ops`` +(to register the CLI-only, path-taking operations, so the negative check +below cannot pass vacuously) and ``osw.service.ops`` touches neither the +``mcp`` SDK nor the network. Only ``test_mcp_server_never_imports_cli`` +needs the ``mcp`` extra (it imports ``osw.mcp.server`` itself), and +self-skips without it. +""" + +from __future__ import annotations + +import inspect +import subprocess +import sys +from unittest.mock import MagicMock + +import pytest + +# Registers every operation, including the CLI-only path-taking ones, so +# osw.service.registry.REGISTRY is fully populated for the checks below. +import osw.cli.ops +import osw.service.ops # noqa: F401 +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.registry import PATH_LIKE_NAMES, REGISTRY, bind, iter_operations + +_CLI_ONLY_PATH_OPS = {"download_file", "upload_file"} + + +def _params(fn): + """The op's parameters, minus ``ctx``.""" + return list(inspect.signature(fn).parameters.values())[1:] + + +def test_no_mcp_operation_names_a_path(): + mcp_ops = list(iter_operations(surface="mcp")) + assert mcp_ops, "expected at least one operation on the mcp surface" + + for op in mcp_ops: + offending = [p.name for p in _params(op.fn) if p.name in PATH_LIKE_NAMES] + assert not offending, f"{op.name}: path-like parameter(s) {offending}" + + # The assertion above must not pass vacuously: the CLI-only download/ + # upload operations DO name a path, and must NOT appear on the mcp + # surface. + mcp_names = {op.name for op in mcp_ops} + assert not (_CLI_ONLY_PATH_OPS & mcp_names) + + cli_ops_by_name = {op.name: op for op in iter_operations(surface="cli")} + for name in _CLI_ONLY_PATH_OPS: + assert name in cli_ops_by_name, f"expected {name!r} to be registered" + op = cli_ops_by_name[name] + param_names = {p.name for p in _params(op.fn)} + assert param_names & PATH_LIKE_NAMES, ( + f"{name}: expected at least one path-like parameter" + ) + assert "mcp" not in op.surfaces + + +def test_bound_operations_do_not_expose_ctx(): + ctx = Context( + Settings(domain="wiki.example.org", username="u", password="p"), + Policy(), + osw=MagicMock(), + ledger=MagicMock(), + ) + assert REGISTRY, "expected the registry to be populated" + for op in REGISTRY.values(): + bound = bind(op, ctx) + assert "ctx" not in inspect.signature(bound).parameters + + +def test_mcp_server_never_imports_cli(): + pytest.importorskip("mcp", reason="requires the osw[mcp] extra") + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys\n" + "import osw.mcp.server\n" + "leaked = [m for m in sys.modules if m == 'osw.cli' " + "or m.startswith('osw.cli.')]\n" + "print('LEAKED:' + ','.join(leaked) if leaked else 'CLEAN')\n", + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + # Importing osw prints unrelated hints (e.g. about the wikitext extra) on + # stdout, so match the sentinel line rather than the whole stream. + sentinel = [ + line + for line in result.stdout.splitlines() + if line.startswith(("CLEAN", "LEAKED:")) + ] + assert sentinel == ["CLEAN"], result.stdout + result.stderr diff --git a/tests/test_service_config.py b/tests/test_service_config.py new file mode 100644 index 0000000..44b7b2c --- /dev/null +++ b/tests/test_service_config.py @@ -0,0 +1,533 @@ +"""Unit tests for osw.service.config (fail-fast credential validation).""" + +import sys + +import pytest +import yaml + +from osw.service import config + +_ALL_VARS = [ + "OSW_DOMAIN", + "OSL_DOMAIN", + "OSW_USERNAME", + "OSL_USERNAME", + "OSW_PASSWORD", + "OSL_PASSWORD", + "OSW_CRED_FILEPATH", + "OSW_MCP_CRED_FILEPATH", + "OSL_CRED_FILEPATH", + "OSW_SPARQL_ENDPOINT", + "OSW_READ_ONLY", + "OSW_MCP_READ_ONLY", + "OSW_STATE_DIR", + "OSW_MCP_STATE_DIR", + "OSW_MAX_RESULTS", + "OSW_MCP_MAX_RESULTS", + "OSW_MAX_CHARS", + "OSW_MCP_MAX_CHARS", + "OSW_ENV_FILE", + "OSW_MCP_ENV_FILE", +] + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch, tmp_path): + for var in _ALL_VARS: + monkeypatch.delenv(var, raising=False) + # Point dotenv at an empty file so it never picks up a real .env on disk + empty = tmp_path / "empty.env" + empty.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_MCP_ENV_FILE", str(empty)) + config.reset() + yield + config.reset() + + +def test_missing_credentials_raise(monkeypatch): + with pytest.raises(RuntimeError) as exc: + config.load() + # message names the missing vars so the operator can fix it + assert "OSW_DOMAIN" in str(exc.value) + assert "OSW_USERNAME" in str(exc.value) + assert "OSW_PASSWORD" in str(exc.value) + + +def test_missing_credentials_do_not_prompt(monkeypatch): + # If load() ever fell through to input()/getpass, this would hang; a raise + # proves it fails fast instead. + def _boom(*_a, **_k): + raise AssertionError("interactive prompt must never be reached") + + monkeypatch.setattr("builtins.input", _boom) + with pytest.raises(RuntimeError): + config.load() + + +def test_valid_credentials_parse(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MCP_READ_ONLY", "TRUE") + monkeypatch.setenv("OSW_MCP_MAX_RESULTS", "42") + settings = config.load() + assert settings.domain == "wiki.example.org" + assert settings.username == "alice" + assert settings.read_only is True + assert settings.max_results == 42 + # password must not appear in the redacted view + assert "password" not in settings.redacted() + assert "secret" not in repr(settings) + + +def test_osl_fallback(monkeypatch): + monkeypatch.setenv("OSL_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSL_USERNAME", "bob") + monkeypatch.setenv("OSL_PASSWORD", "pw") + settings = config.load() + assert settings.domain == "wiki.example.org" + assert settings.username == "bob" + + +def test_env_file_override(monkeypatch, tmp_path): + env = tmp_path / "creds.env" + env.write_text( + "OSW_DOMAIN=fromfile.example.org\nOSW_USERNAME=fileuser\nOSW_PASSWORD=filepw\n", + encoding="utf-8", + ) + monkeypatch.setenv("OSW_MCP_ENV_FILE", str(env)) + settings = config.load() + assert settings.domain == "fromfile.example.org" + assert settings.username == "fileuser" + + +def test_invalid_int_raises(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MCP_MAX_RESULTS", "notanumber") + with pytest.raises(RuntimeError): + config.load() + + +def _write_cred_file(path, data): + path.write_text(yaml.safe_dump(data), encoding="utf-8") + return path + + +def test_cred_file_configured_and_present_no_env_credentials(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + settings = config.load() + assert settings.domain == "wiki.example.org" + assert settings.cred_filepath == str(cred_file) + assert settings.username is None + assert settings.password is None + + +def test_cred_file_missing_raises(monkeypatch, tmp_path): + missing = tmp_path / "does-not-exist.yaml" + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(missing)) + with pytest.raises(RuntimeError) as exc: + config.load() + assert str(missing) in str(exc.value) + + +def test_missing_username_password_without_cred_file_raises(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + with pytest.raises(RuntimeError) as exc: + config.load() + assert "OSW_USERNAME" in str(exc.value) + assert "OSW_PASSWORD" in str(exc.value) + assert "OSW_DOMAIN" not in str(exc.value) + + +def test_username_password_still_work_with_no_cred_file(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + settings = config.load() + assert settings.domain == "wiki.example.org" + assert settings.username == "alice" + assert settings.cred_filepath is None + + +def test_redacted_never_contains_password_or_credential_value(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki.example.org": {"username": "alice", "password": "supersecret"}}, + ) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + settings = config.load() + redacted = settings.redacted() + assert "password" not in redacted + assert "supersecret" not in str(redacted) + assert redacted["cred_filepath_configured"] is True + + +def test_cred_file_missing_domain_entry_raises(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"other.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + with pytest.raises(RuntimeError) as exc: + config.load() + assert "other.example.org" in str(exc.value) + assert "wiki.example.org" in str(exc.value) + + +def test_cred_file_without_domain_is_legal(monkeypatch, tmp_path): + # With a usable credential file, a missing domain is no longer an error: + # which instance to use is chosen later (auto-selected or via + # select_instance). + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + { + "wiki-a.example.org": {"username": "alice", "password": "secret"}, + "wiki-b.example.org": {"username": "bob", "password": "secret2"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + settings = config.load() + assert settings.domain is None + assert settings.cred_filepath == str(cred_file) + + +def test_cred_file_without_domain_skips_domain_verification(monkeypatch, tmp_path): + # No domain configured means there is nothing to verify at startup, even + # though the file does not contain an entry named after any particular + # domain the caller might later select. + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki-a.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + settings = config.load() + assert settings.domain is None + + +# -- canonical OSW_* names -------------------------------------------------- + + +def test_canonical_cred_filepath(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file)) + settings = config.load() + assert settings.cred_filepath == str(cred_file) + + +def test_canonical_read_only(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_READ_ONLY", "true") + settings = config.load() + assert settings.read_only is True + + +def test_canonical_state_dir(monkeypatch, tmp_path): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + state_dir = str(tmp_path / "state") + monkeypatch.setenv("OSW_STATE_DIR", state_dir) + settings = config.load() + assert settings.state_dir == state_dir + + +def test_canonical_max_results(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MAX_RESULTS", "7") + settings = config.load() + assert settings.max_results == 7 + + +def test_canonical_max_chars(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MAX_CHARS", "12345") + settings = config.load() + assert settings.max_chars == 12345 + + +def test_canonical_env_file(monkeypatch, tmp_path): + env = tmp_path / "creds.env" + env.write_text( + "OSW_DOMAIN=fromfile.example.org\nOSW_USERNAME=fileuser\nOSW_PASSWORD=filepw\n", + encoding="utf-8", + ) + monkeypatch.delenv("OSW_MCP_ENV_FILE", raising=False) + monkeypatch.setenv("OSW_ENV_FILE", str(env)) + settings = config.load() + assert settings.domain == "fromfile.example.org" + assert settings.username == "fileuser" + + +# -- OSW_MCP_* aliases not already covered above ---------------------------- + + +def test_alias_state_dir(monkeypatch, tmp_path): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + state_dir = str(tmp_path / "state") + monkeypatch.setenv("OSW_MCP_STATE_DIR", state_dir) + settings = config.load() + assert settings.state_dir == state_dir + + +def test_alias_max_chars(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MCP_MAX_CHARS", "54321") + settings = config.load() + assert settings.max_chars == 54321 + + +# -- canonical wins when both canonical and alias are set -------------------- + + +def test_canonical_wins_over_alias(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki.example.org": {"username": "alice", "password": "secret"}}, + ) + other_cred_file = _write_cred_file( + tmp_path / "other.yaml", + {"wiki.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file)) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(other_cred_file)) + monkeypatch.setenv("OSW_READ_ONLY", "true") + monkeypatch.setenv("OSW_MCP_READ_ONLY", "false") + monkeypatch.setenv("OSW_MAX_RESULTS", "1") + monkeypatch.setenv("OSW_MCP_MAX_RESULTS", "2") + monkeypatch.setenv("OSW_MAX_CHARS", "10") + monkeypatch.setenv("OSW_MCP_MAX_CHARS", "20") + state_dir = str(tmp_path / "state") + other_state_dir = str(tmp_path / "other-state") + monkeypatch.setenv("OSW_STATE_DIR", state_dir) + monkeypatch.setenv("OSW_MCP_STATE_DIR", other_state_dir) + + settings = config.load() + + assert settings.cred_filepath == str(cred_file) + assert settings.read_only is True + assert settings.max_results == 1 + assert settings.max_chars == 10 + assert settings.state_dir == state_dir + + +def test_canonical_env_file_wins_over_alias(monkeypatch, tmp_path): + canonical_env = tmp_path / "canonical.env" + canonical_env.write_text("OSW_DOMAIN=canonical.example.org\n", encoding="utf-8") + alias_env = tmp_path / "alias.env" + alias_env.write_text("OSW_DOMAIN=alias.example.org\n", encoding="utf-8") + monkeypatch.setenv("OSW_ENV_FILE", str(canonical_env)) + monkeypatch.setenv("OSW_MCP_ENV_FILE", str(alias_env)) + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + + settings = config.load() + + assert settings.domain == "canonical.example.org" + + +# -- strict=False ------------------------------------------------------------ + + +def test_load_not_strict_returns_settings_without_raising(monkeypatch): + settings = config.load(strict=False) + assert settings.domain is None + assert settings.username is None + assert settings.password is None + + +def test_load_not_strict_still_raises_on_invalid_int(monkeypatch): + monkeypatch.setenv("OSW_MAX_RESULTS", "notanumber") + with pytest.raises(RuntimeError): + config.load(strict=False) + + +def test_load_not_strict_still_raises_on_missing_cred_file(monkeypatch, tmp_path): + missing = tmp_path / "does-not-exist.yaml" + monkeypatch.setenv("OSW_CRED_FILEPATH", str(missing)) + with pytest.raises(RuntimeError) as exc: + config.load(strict=False) + assert str(missing) in str(exc.value) + + +# -- _load_env_file / optional dotenv ---------------------------------------- + + +def test_load_env_file_raises_when_configured_and_dotenv_missing(monkeypatch, tmp_path): + env = tmp_path / "creds.env" + env.write_text("OSW_DOMAIN=wiki.example.org\n", encoding="utf-8") + monkeypatch.setenv("OSW_ENV_FILE", str(env)) + monkeypatch.setitem(sys.modules, "dotenv", None) + with pytest.raises(RuntimeError) as exc: + config._load_env_file() + assert "OSW_ENV_FILE" in str(exc.value) + assert "python-dotenv" in str(exc.value) + + +def test_load_env_file_silent_when_not_configured_and_dotenv_missing( + monkeypatch, +): + monkeypatch.delenv("OSW_MCP_ENV_FILE", raising=False) + monkeypatch.delenv("OSW_ENV_FILE", raising=False) + monkeypatch.setitem(sys.modules, "dotenv", None) + # must not raise + config._load_env_file() + + +# -- implicit .env discovery ---------------------------------------------------- +def test_no_implicit_env_search_by_default(monkeypatch, tmp_path): + """Discovery is off unless an adapter opts in, so a stray .env in the + working directory cannot decide which instance a server connects to.""" + monkeypatch.delenv("OSW_MCP_ENV_FILE", raising=False) + monkeypatch.delenv("OSW_ENV_FILE", raising=False) + (tmp_path / ".env").write_text( + "OSW_DOMAIN=from-cwd.example.org\n", encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + + config._load_env_file() + + assert config._first_env(config.ENV_DOMAIN) is None + assert config._env_file_origin == "not searched" + + +def test_implicit_env_search_starts_at_the_working_directory(monkeypatch, tmp_path): + """The search must start at the CWD, not at this module's directory. + + ``dotenv.load_dotenv()`` with no arguments walks up from the *calling + module's* file, which is osw/service/config.py: under an editable install + that is the osw checkout, so it would silently load the checkout's own + .env no matter where the user is standing. + """ + monkeypatch.delenv("OSW_MCP_ENV_FILE", raising=False) + monkeypatch.delenv("OSW_ENV_FILE", raising=False) + nested = tmp_path / "project" / "sub" + nested.mkdir(parents=True) + (tmp_path / "project" / ".env").write_text( + "OSW_DOMAIN=from-cwd.example.org\n", encoding="utf-8" + ) + monkeypatch.chdir(nested) + config.set_env_file_discovery(True) + + config._load_env_file() + + assert config._first_env(config.ENV_DOMAIN) == "from-cwd.example.org" + assert config._env_file_origin == "discovered" + + +def test_explicit_env_file_wins_over_discovery(monkeypatch, tmp_path): + explicit = tmp_path / "explicit.env" + explicit.write_text("OSW_DOMAIN=explicit.example.org\n", encoding="utf-8") + (tmp_path / ".env").write_text( + "OSW_DOMAIN=from-cwd.example.org\n", encoding="utf-8" + ) + monkeypatch.setenv("OSW_ENV_FILE", str(explicit)) + monkeypatch.chdir(tmp_path) + config.set_env_file_discovery(True) + + config._load_env_file() + + assert config._first_env(config.ENV_DOMAIN) == "explicit.example.org" + assert config._env_file_origin == "explicit" + + +def test_set_env_file_discovery_raises_only_on_a_late_change(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "u") + monkeypatch.setenv("OSW_PASSWORD", "p") + config.get_settings() # populates the cache + + config.set_env_file_discovery(False) # re-asserting the current value is fine + + with pytest.raises(RuntimeError, match="before settings are loaded"): + config.set_env_file_discovery(True) + + +# -- .env escape footgun -------------------------------------------------------- +def test_missing_cred_file_flags_an_escape_mangled_path(monkeypatch): + r"""A double-quoted Windows path in .env loses \a to a BEL byte. + + The mangled path then renders as if it were the path the user typed, so + the plain "does not exist" message looks wrong rather than informative. + """ + # What dotenv produces for OSW_CRED_FILEPATH="C:\dir\accounts.yaml": + # the \a is decoded to BEL, which prints as nothing. + mangled = "C:" + chr(92) + "dir" + chr(7) + "ccounts.yaml" + monkeypatch.setenv("OSW_CRED_FILEPATH", mangled) + + with pytest.raises(RuntimeError) as exc: + config.load() + + assert "control character" in str(exc.value) + assert "single quotes" in str(exc.value) + + +def test_missing_cred_file_without_control_chars_has_no_escape_hint( + monkeypatch, tmp_path +): + monkeypatch.setenv("OSW_CRED_FILEPATH", str(tmp_path / "nope.yaml")) + + with pytest.raises(RuntimeError) as exc: + config.load() + + assert "does not exist" in str(exc.value) + assert "control character" not in str(exc.value) + + +# -- startup banner ------------------------------------------------------------- +def test_log_config_sources_reports_env_and_cred_file(monkeypatch, tmp_path, capsys): + cred = tmp_path / "accounts.yaml" + cred.write_text( + yaml.safe_dump({"wiki.example.org": {"username": "u", "password": "p"}}), + encoding="utf-8", + ) + env = tmp_path / "creds.env" + env.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_ENV_FILE", str(env)) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred)) + config._load_env_file() + + config.log_config_sources() + + captured = capsys.readouterr() + # stdout is the JSON-RPC stream under MCP and the result payload under + # `osw --json`, so the banner must never appear there. + assert captured.out == "" + assert str(env) in captured.err + assert str(cred) in captured.err + + +def test_log_config_sources_omits_cred_file_when_unconfigured(monkeypatch, capsys): + config._load_env_file() + + config.log_config_sources() + + captured = capsys.readouterr() + assert "env file" in captured.err + assert "credential file" not in captured.err diff --git a/tests/test_service_context.py b/tests/test_service_context.py new file mode 100644 index 0000000..63f89a2 --- /dev/null +++ b/tests/test_service_context.py @@ -0,0 +1,190 @@ +"""Unit tests for osw.service.context (Policy defaults and Context helpers). + +A fake ``osw`` object is injected directly into ``Context`` so these tests +never touch the network. +""" + +import sys +from unittest.mock import MagicMock + +import pytest +import yaml + +from osw.service import config, errors +from osw.service.config import Settings +from osw.service.context import Context, Policy + +_ALL_VARS = [ + "OSW_DOMAIN", + "OSL_DOMAIN", + "OSW_USERNAME", + "OSL_USERNAME", + "OSW_PASSWORD", + "OSL_PASSWORD", + "OSW_CRED_FILEPATH", + "OSW_MCP_CRED_FILEPATH", + "OSL_CRED_FILEPATH", +] + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch, tmp_path): + for var in _ALL_VARS: + monkeypatch.delenv(var, raising=False) + empty = tmp_path / "empty.env" + empty.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_MCP_ENV_FILE", str(empty)) + config.reset() + yield + config.reset() + + +def _settings(**overrides) -> Settings: + defaults = dict(domain="wiki.example.org", username="u", password="p") + defaults.update(overrides) + return Settings(**defaults) + + +def _osw_with_page(exists: bool): + page = MagicMock() + page.exists = exists + osw = MagicMock() + osw.site.get_page.return_value.pages = [page] + return osw, page + + +# -- Policy ------------------------------------------------------------- +def test_policy_defaults(): + policy = Policy() + assert policy.capture_stdout is False + assert policy.errors_as_dicts is False + assert policy.allow_writes is True + assert policy.allow_interactive is False + + +# -- osw / ledger injection ---------------------------------------------- +def test_osw_can_be_preset_via_constructor(): + fake = object() + ctx = Context(_settings(), osw=fake) + assert ctx.osw is fake + + +def test_osw_can_be_preset_via_attribute(): + ctx = Context(_settings()) + fake = object() + ctx.osw = fake + assert ctx.osw is fake + + +def test_ledger_can_be_preset_via_constructor(): + fake = object() + ctx = Context(_settings(), ledger=fake) + assert ctx.ledger is fake + + +def test_ledger_can_be_preset_via_attribute(): + ctx = Context(_settings()) + fake = object() + ctx.ledger = fake + assert ctx.ledger is fake + + +def test_osw_property_raises_not_configured_when_no_active_domain( + monkeypatch, tmp_path +): + cred_file = tmp_path / "accounts.yaml" + cred_file.write_text( + yaml.safe_dump({ + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }), + encoding="utf-8", + ) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file)) + config.reset() # two iris in the file: no auto-selection is possible + + ctx = Context(_settings(domain=None, username=None, password=None)) + + with pytest.raises(errors.NotConfigured) as exc_info: + _ = ctx.osw + assert "OSW_DOMAIN" in str(exc_info.value) + assert "--instance" in str(exc_info.value) + + +# -- limit ---------------------------------------------------------------- +def test_limit_falls_back_to_settings_max_results(): + ctx = Context(_settings(), osw=object()) + assert ctx.limit(None) == ctx.settings.max_results + assert ctx.limit(5) == 5 + + +# -- page ------------------------------------------------------------------- +def test_page_returns_existing_page(): + osw, page = _osw_with_page(True) + ctx = Context(_settings(), osw=osw) + assert ctx.page("Item:OSW1") is page + + +def test_page_raises_not_found_for_missing_page(): + osw, _page = _osw_with_page(False) + ctx = Context(_settings(), osw=osw) + with pytest.raises(errors.NotFound): + ctx.page("Item:OSW1") + + +# -- require_write ------------------------------------------------------ +def test_require_write_raises_when_writes_disallowed(): + ctx = Context(_settings(), Policy(allow_writes=False), osw=object()) + with pytest.raises(errors.ReadOnly) as exc_info: + ctx.require_write("create_or_update_entity") + assert "create_or_update_entity" in str(exc_info.value) + assert "OSW_READ_ONLY" in str(exc_info.value) + assert exc_info.value.type == "ReadOnly" + assert exc_info.value.exit_code == 4 + + +def test_require_write_allows_when_writes_allowed(): + ctx = Context(_settings(), Policy(allow_writes=True), osw=object()) + ctx.require_write("create_or_update_entity") # must not raise + + +# -- guard ------------------------------------------------------------------ +def test_guard_redirects_stdout_when_capture_stdout_true(): + ctx = Context(_settings(), Policy(capture_stdout=True), osw=object()) + original_stdout = sys.stdout + with ctx.guard(): + assert sys.stdout is sys.stderr + assert sys.stdout is not original_stdout + assert sys.stdout is original_stdout + + +def test_guard_leaves_stdout_alone_when_capture_stdout_false(): + ctx = Context(_settings(), Policy(capture_stdout=False), osw=object()) + original_stdout = sys.stdout + with ctx.guard(): + assert sys.stdout is original_stdout + + +# -- reset / close -------------------------------------------------------- +def test_reset_closes_connection_and_drops_osw_and_ledger(): + fake_osw = MagicMock() + ctx = Context(_settings(), osw=fake_osw, ledger=MagicMock()) + ctx.reset() + fake_osw.close_connection.assert_called_once() + assert ctx._osw is None + assert ctx._ledger is None + + +def test_reset_survives_close_connection_error(): + fake_osw = MagicMock() + fake_osw.close_connection.side_effect = RuntimeError("boom") + ctx = Context(_settings(), osw=fake_osw) + ctx.reset() # must not raise + assert ctx._osw is None + + +def test_close_calls_reset(): + fake_osw = MagicMock() + ctx = Context(_settings(), osw=fake_osw) + ctx.close() + fake_osw.close_connection.assert_called_once() diff --git a/tests/test_service_errors.py b/tests/test_service_errors.py new file mode 100644 index 0000000..966fa96 --- /dev/null +++ b/tests/test_service_errors.py @@ -0,0 +1,128 @@ +"""Unit tests for osw.service.errors. + +Each ``OpError`` subclass must reproduce, key-for-key and value-for-value, the +error dict shape the MCP tools returned before the move to ``osw.service``. +""" + +from osw.service import errors + + +def test_not_found_matches_export_entity_jsonld_shape(): + title = "Item:OSW1" + exc = errors.NotFound(f"Entity '{title}' not found.") + assert exc.payload() == { + "error": f"Entity '{title}' not found.", + "type": "NotFound", + } + assert exc.exit_code == 2 + + +def test_not_found_matches_delete_entity_hybrid_shape(): + title = "Item:OSW1" + exc = errors.NotFound( + f"Page '{title}' does not exist.", + extra={"title": title, "deleted": False}, + ) + assert exc.payload() == { + "title": title, + "deleted": False, + "error": f"Page '{title}' does not exist.", + "type": "NotFound", + } + + +def test_external_delete_blocked_matches_delete_entity_shape(): + title = "Item:OSWx" + message = ( + f"Refusing to delete '{title}': it was not created by this " + "MCP server. Re-run with confirm_external_delete=true to override." + ) + exc = errors.ExternalDeleteBlocked(message, extra={"title": title}) + assert exc.payload() == { + "title": title, + "error": message, + "type": "ExternalDeleteBlocked", + } + assert exc.exit_code == 4 + + +def test_schema_error_matches_create_or_update_entity_shape(): + exc = errors.SchemaError("boom1; boom2") + assert exc.payload() == {"error": "boom1; boom2", "type": "SchemaError"} + assert exc.exit_code == 3 + + +def test_class_not_found_matches_create_or_update_entity_shape(): + category = "Category:Item" + message = ( + f"Could not resolve a model class for '{category}' after " + "fetching its schema. Check the category page name." + ) + exc = errors.ClassNotFound(message) + assert exc.payload() == {"error": message, "type": "ClassNotFound"} + assert exc.exit_code == 3 + + +def test_validation_error_matches_create_or_update_entity_shape(): + category = "Category:Item" + message = f"jsondata does not validate against {category}: bad field" + exc = errors.ValidationError(message) + assert exc.payload() == {"error": message, "type": "ValidationError"} + assert exc.exit_code == 3 + + +def test_unknown_instance_matches_select_instance_shape(): + message = "Unknown instance 'bogus'. Available: wiki.example.org" + exc = errors.UnknownInstance(message) + assert exc.payload() == {"error": message, "type": "UnknownInstance"} + assert exc.exit_code == 3 + + +def test_not_configured_matches_sparql_query_shape(): + message = ( + "SPARQL endpoint not configured. Set OSW_SPARQL_ENDPOINT " + "or pass the 'endpoint' argument." + ) + exc = errors.NotConfigured(message) + assert exc.payload() == {"error": message, "type": "NotConfigured"} + assert exc.exit_code == 5 + + +def test_invalid_slot_matches_slots_shape(): + valid = ["main", "jsondata"] + message = f"Unknown slot 'bogus'. Valid slots: {valid}" + exc = errors.InvalidSlot(message) + assert exc.payload() == {"error": message, "type": "InvalidSlot"} + assert exc.exit_code == 3 + + +def test_invalid_content_matches_set_slot_shape(): + message = "Slot 'jsondata' is JSON; content must be an object or array." + exc = errors.InvalidContent(message) + assert exc.payload() == {"error": message, "type": "InvalidContent"} + assert exc.exit_code == 3 + + +def test_slot_missing_matches_set_slot_shape(): + message = ( + "Slot 'header' does not exist on 'Item:OSW1' and create_if_missing is false." + ) + exc = errors.SlotMissing(message) + assert exc.payload() == {"error": message, "type": "SlotMissing"} + assert exc.exit_code == 3 + + +def test_read_only_matches_require_write_shape(): + message = ( + "Operation 'create_or_update_entity' is not permitted: writes are " + "disabled (set OSW_READ_ONLY=false to allow)." + ) + exc = errors.ReadOnly(message) + assert exc.payload() == {"error": message, "type": "ReadOnly"} + assert exc.exit_code == 4 + + +def test_base_op_error_defaults(): + exc = errors.OpError("generic failure") + assert exc.payload() == {"error": "generic failure", "type": "Error"} + assert exc.exit_code == 1 diff --git a/tests/test_service_instances.py b/tests/test_service_instances.py new file mode 100644 index 0000000..043e775 --- /dev/null +++ b/tests/test_service_instances.py @@ -0,0 +1,245 @@ +"""Unit tests for multi-instance selection in osw.service (config + Context). + +These are fully offline: no network, no live wiki. They also need no MCP SDK: +osw.service is deliberately SDK-free, so unlike tests/test_mcp_*.py these run +in the default dev environment. +""" + +import pytest +import yaml + +from osw.service import config, errors +from osw.service.context import Context, Policy +from osw.service.registry import Operation, bind + +_ALL_VARS = [ + "OSW_DOMAIN", + "OSL_DOMAIN", + "OSW_USERNAME", + "OSL_USERNAME", + "OSW_PASSWORD", + "OSL_PASSWORD", + "OSW_MCP_CRED_FILEPATH", + "OSL_CRED_FILEPATH", + "OSW_SPARQL_ENDPOINT", + "OSW_MCP_READ_ONLY", + "OSW_MCP_STATE_DIR", + "OSW_MCP_MAX_RESULTS", + "OSW_MCP_MAX_CHARS", + "OSW_MCP_ENV_FILE", +] + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch, tmp_path): + for var in _ALL_VARS: + monkeypatch.delenv(var, raising=False) + # Point dotenv at an empty file so it never picks up a real .env on disk + empty = tmp_path / "empty.env" + empty.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_MCP_ENV_FILE", str(empty)) + config.reset() + yield + config.reset() + + +def _write_cred_file(path, data): + path.write_text(yaml.safe_dump(data), encoding="utf-8") + return path + + +# -- auto-selection --------------------------------------------------------- +def test_auto_select_from_configured_domain(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + + assert config.get_active_iri() == "wiki.example.org" + assert config.get_active_domain() == "wiki.example.org" + + +def test_auto_select_single_iri_cred_file(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki-dev.open-semantic-lab.org": {"username": "a", "password": "b"}}, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + + assert config.get_active_iri() == "wiki-dev.open-semantic-lab.org" + assert config.get_active_domain() == "wiki-dev.open-semantic-lab.org" + + +def test_no_auto_select_with_multiple_iris(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + { + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + + assert config.get_active_iri() is None + assert config.get_active_domain() is None + + +# -- set_active_instance / select_instance ---------------------------------- +def test_set_active_instance_valid(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + { + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + + config.set_active_instance("wiki-b.example.org") + + assert config.get_active_iri() == "wiki-b.example.org" + assert config.get_active_domain() == "wiki-b.example.org" + + +def test_set_active_instance_unknown_iri_raises(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki-a.example.org": {"username": "a", "password": "b"}}, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + + with pytest.raises(ValueError) as exc: + config.set_active_instance("does-not-exist.example.org") + assert "wiki-a.example.org" in str(exc.value) + + +# -- Context.osw / bind() without an active instance ------------------------- +def test_get_osw_raises_when_no_instance_selected(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + { + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + ctx = Context(config.get_settings(), Policy()) + + with pytest.raises(errors.NotConfigured) as exc: + _ = ctx.osw + assert "No OSL instance selected" in str(exc.value) + assert "wiki-a.example.org" in str(exc.value) + assert "wiki-b.example.org" in str(exc.value) + + +def test_run_guarded_surfaces_no_instance_selected_as_structured_dict( + monkeypatch, tmp_path +): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + { + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + + def _touch_osw(ctx) -> dict: + """Test-only op: access ctx.osw to trigger active-domain resolution.""" + _ = ctx.osw + return {"ok": True} + + op = Operation(name="_touch_osw", fn=_touch_osw) + ctx = Context(config.get_settings(), Policy(errors_as_dicts=True)) + + result = bind(op, ctx)() + + assert result["type"] == "NotConfigured" + assert "No OSL instance selected" in result["error"] + + +# -- domain derivation helper ------------------------------------------------- +def test_derive_domain_from_bare_domain(): + assert ( + config._derive_domain("wiki-dev.open-semantic-lab.org") + == "wiki-dev.open-semantic-lab.org" + ) + + +def test_derive_domain_from_full_url(): + assert ( + config._derive_domain("https://wiki-dev.open-semantic-lab.org/w/") + == "wiki-dev.open-semantic-lab.org" + ) + + +# -- Context.reset() drops the ledger ----------------------------------------- +def test_reset_drops_ledger_for_new_domain_after_switching(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + { + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + monkeypatch.setenv("OSW_MCP_STATE_DIR", str(tmp_path / "state")) + config.set_active_instance("wiki-a.example.org") + ctx = Context(config.get_settings(), Policy()) + + ledger_a = ctx.ledger + assert "wiki-a.example.org" in str(ledger_a.path) + + config.set_active_instance("wiki-b.example.org") + ctx.reset() + ledger_b = ctx.ledger + + assert "wiki-b.example.org" in str(ledger_b.path) + assert ledger_a.path != ledger_b.path + + +# -- get_active_credentials --------------------------------------------------- +def test_get_active_credentials_from_cred_file(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki-a.example.org": {"username": "alice", "password": "s3cret"}}, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + + assert config.get_active_iri() == "wiki-a.example.org" + assert config.get_active_credentials() == ("alice", "s3cret") + + +def test_get_active_credentials_follows_instance_switch(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + { + "wiki-a.example.org": {"username": "alice", "password": "a-pw"}, + "wiki-b.example.org": {"username": "bob", "password": "b-pw"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + config.set_active_instance("wiki-a.example.org") + assert config.get_active_credentials() == ("alice", "a-pw") + + config.set_active_instance("wiki-b.example.org") + + assert config.get_active_credentials() == ("bob", "b-pw") + + +def test_get_active_credentials_falls_back_to_env(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + + assert config.get_active_credentials() == ("alice", "secret") + + +def test_get_active_credentials_returns_none_none_without_raising(monkeypatch): + # A domain-only, cred-file-less, credential-less settings object cannot be + # produced through config.load() itself (it would raise); construct it + # directly to exercise the "nothing resolves" path of get_active_credentials. + monkeypatch.setattr( + config, "get_settings", lambda: config.Settings(domain="wiki.example.org") + ) + + assert config.get_active_credentials() == (None, None) diff --git a/tests/test_service_ledger.py b/tests/test_service_ledger.py new file mode 100644 index 0000000..d284bc7 --- /dev/null +++ b/tests/test_service_ledger.py @@ -0,0 +1,78 @@ +"""Unit tests for the osw.service provenance ledger.""" + +from osw.service.ledger import Ledger + + +def _ledger(tmp_path): + return Ledger(domain="wiki.example.org", state_dir=str(tmp_path)) + + +def test_record_and_is_tracked(tmp_path): + ledger = _ledger(tmp_path) + assert ledger.is_tracked("Item:OSW1") is False + ledger.record("Item:OSW1", op="create", tool="create_or_update_entity") + assert ledger.is_tracked("Item:OSW1") is True + assert ledger.path.is_file() + + +def test_mark_deleted_untracks(tmp_path): + ledger = _ledger(tmp_path) + ledger.record("Item:OSW1", op="create", tool="create_or_update_entity") + ledger.mark_deleted("Item:OSW1") + assert ledger.is_tracked("Item:OSW1") is False + + +def test_record_merges_and_dedups(tmp_path): + ledger = _ledger(tmp_path) + ledger.record( + "Item:OSW1", + op="create", + tool="create_or_update_entity", + change_id="c1", + slots=["jsondata"], + ) + ledger.record( + "Item:OSW1", + op="update", + tool="set_slot", + change_id="c1", + slots=["main", "jsondata"], + ) + data = ledger._load()["entries"]["Item:OSW1"] + assert data["ops"] == ["create", "update"] + assert data["tools"] == ["create_or_update_entity", "set_slot"] + assert data["change_ids"] == ["c1"] # deduped + assert sorted(data["slots_written"]) == ["jsondata", "main"] # deduped + + +def test_recreate_after_delete_retracks(tmp_path): + ledger = _ledger(tmp_path) + ledger.record("Item:OSW1", op="create", tool="create_or_update_entity") + ledger.mark_deleted("Item:OSW1") + assert ledger.is_tracked("Item:OSW1") is False + ledger.record("Item:OSW1", op="create", tool="create_or_update_entity") + assert ledger.is_tracked("Item:OSW1") is True + + +def test_entry_count_excludes_deleted(tmp_path): + ledger = _ledger(tmp_path) + ledger.record("Item:OSW1", op="create", tool="t") + ledger.record("Item:OSW2", op="create", tool="t") + ledger.mark_deleted("Item:OSW1") + assert ledger.entry_count() == 1 + + +def test_corrupt_ledger_starts_fresh(tmp_path): + ledger = _ledger(tmp_path) + ledger.path.parent.mkdir(parents=True, exist_ok=True) + ledger.path.write_text("{not valid json", encoding="utf-8") + # is_tracked must not raise on a corrupt file + assert ledger.is_tracked("Item:OSW1") is False + ledger.record("Item:OSW1", op="create", tool="t") + assert ledger.is_tracked("Item:OSW1") is True + + +def test_persistence_across_instances(tmp_path): + _ledger(tmp_path).record("Item:OSW1", op="create", tool="t") + # a fresh Ledger over the same dir sees the persisted entry + assert _ledger(tmp_path).is_tracked("Item:OSW1") is True diff --git a/tests/test_service_ops.py b/tests/test_service_ops.py new file mode 100644 index 0000000..255f1a2 --- /dev/null +++ b/tests/test_service_ops.py @@ -0,0 +1,96 @@ +"""Unit tests preserving the MCP-wrapper-level assertions from the old +``osw.mcp.tools`` test suite (``test_mcp_tools.py``, now removed). + +Every operation body assertion from that file already lives in +``tests/test_service_ops_.py`` (called directly, the way this module's +sibling ``test_service_ops_files.py`` does), and the generic ``bind()`` / +error-payload mechanics live in ``tests/test_service_registry.py`` and +``tests/test_service_errors.py``. What is kept here is the handful of +assertions that only made sense through the ``bind()`` wrapper -- e.g. an +``OpError`` becoming a structured dict rather than raising -- exercised +against the real, registered operations (not a synthetic ``fn``), so nothing +here duplicates that coverage. +""" + +from unittest.mock import MagicMock + +import osw.service.ops # noqa: F401 (registers the operations) +from osw.service import registry +from osw.service.config import Settings +from osw.service.context import Context, Policy + + +def _settings() -> Settings: + return Settings(domain="wiki.example.org", username="u", password="p") + + +def _osw_with_page(exists=True): + page = MagicMock() + page.exists = exists + osw = MagicMock() + osw.site.get_page.return_value.pages = [page] + return osw, page + + +def _bound(name: str, ctx: Context): + return registry.bind(registry.REGISTRY[name], ctx) + + +# -- delete_entity: bind() turns its guard/hybrid errors into dicts --------- +def test_delete_untracked_is_blocked_as_dict(): + osw, page = _osw_with_page() + ledger = MagicMock() + ledger.is_tracked.return_value = False + ctx = Context(_settings(), Policy(errors_as_dicts=True), osw=osw, ledger=ledger) + + result = _bound("delete_entity", ctx)(title="Item:OSWx") + + assert result["type"] == "ExternalDeleteBlocked" + osw.site.get_page.assert_not_called() # never even fetched the page + page.delete.assert_not_called() + + +def test_delete_nonexistent_page_returns_hybrid_dict(): + """delete_entity's NotFound carries {"title", "deleted": False} extras; + bind() must merge them with {"error", "type"} rather than dropping either + half of the shape.""" + osw, page = _osw_with_page(exists=False) + ledger = MagicMock() + ledger.is_tracked.return_value = True + ctx = Context(_settings(), Policy(errors_as_dicts=True), osw=osw, ledger=ledger) + + result = _bound("delete_entity", ctx)(title="Item:OSWz") + + assert result == { + "title": "Item:OSWz", + "deleted": False, + "error": "Page 'Item:OSWz' does not exist.", + "type": "NotFound", + } + page.delete.assert_not_called() + + +def test_delete_tracked_is_allowed_as_dict(): + osw, page = _osw_with_page() + ledger = MagicMock() + ledger.is_tracked.return_value = True + ctx = Context(_settings(), Policy(errors_as_dicts=True), osw=osw, ledger=ledger) + + result = _bound("delete_entity", ctx)(title="Item:OSWx") + + assert result == {"title": "Item:OSWx", "deleted": True} + page.delete.assert_called_once() + + +# -- real registry write flags, no mcp SDK required -------------------------- +def test_read_only_mcp_surface_omits_entity_writes(): + """A read-only server must not register create_or_update_entity/delete_entity, + but must still register the reader; checked against the real registry + (not a synthetic op) so a mis-flagged ``writes=`` on a real operation + would be caught here too.""" + names = { + op.name for op in registry.iter_operations(surface="mcp", include_writes=False) + } + assert "get_entity" in names + assert "create_or_update_entity" not in names + assert "delete_entity" not in names diff --git a/tests/test_service_ops_entities.py b/tests/test_service_ops_entities.py new file mode 100644 index 0000000..f099f4b --- /dev/null +++ b/tests/test_service_ops_entities.py @@ -0,0 +1,237 @@ +"""Unit tests for osw.service.ops.entities (Operation.fn called directly). + +Importing ``osw.service.ops.entities`` registers its operations in +``osw.service.registry.REGISTRY`` at import time, so this module must not +clear the registry the way ``test_service_registry.py`` does. +""" + +from unittest.mock import MagicMock + +import pytest + +from osw.service import errors, registry +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.ledger import LedgerRecord +from osw.service.ops import entities + + +def _settings() -> Settings: + return Settings(domain="wiki.example.org", username="u", password="p") + + +def _osw_with_page(exists=True): + page = MagicMock() + page.exists = exists + osw = MagicMock() + osw.site.get_page.return_value.pages = [page] + return osw, page + + +# -- get_entity -------------------------------------------------------------- +def test_get_entity_missing_page_returns_not_exists(): + osw, _ = _osw_with_page(exists=False) + ctx = Context(_settings(), Policy(), osw=osw) + + result = entities.get_entity(ctx, title="Item:OSW1") + + assert result == {"title": "Item:OSW1", "exists": False, "jsondata": None} + + +def test_get_entity_reads_jsondata_slot(): + osw, page = _osw_with_page() + page.get_slot_content.return_value = {"label": [{"text": "X"}]} + page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1" + ctx = Context(_settings(), Policy(), osw=osw) + + result = entities.get_entity(ctx, title="Item:OSW1") + + assert result["exists"] is True + assert result["jsondata"] == {"label": [{"text": "X"}]} + page.get_slot_content.assert_called_with("jsondata") + + +# -- export_entity_jsonld ----------------------------------------------------- +def test_export_entity_jsonld_returns_jsonld(): + osw = MagicMock() + osw.load_entity.return_value = MagicMock(entities=[MagicMock()]) + osw.export_jsonld.return_value = MagicMock( + documents=[{"@id": "Item:OSW1"}], graph=None + ) + ctx = Context(_settings(), Policy(), osw=osw) + + result = entities.export_entity_jsonld(ctx, title="Item:OSW1") + + assert result == {"jsonld": {"@id": "Item:OSW1"}} + + +def test_export_entity_jsonld_not_found_raises(): + osw = MagicMock() + osw.load_entity.return_value = MagicMock(entities=[]) + ctx = Context(_settings(), Policy(), osw=osw) + + with pytest.raises(errors.NotFound): + entities.export_entity_jsonld(ctx, title="Item:OSW404") + + +# -- create_or_update_entity --------------------------------------------------- +def test_create_or_update_entity_uses_active_domain(monkeypatch): + osw = MagicMock() + osw.fetch_schema.return_value = MagicMock(error_messages=[]) + osw.store_entity.return_value = MagicMock( + pages={"Item:OSW1": MagicMock()}, change_id="c1" + ) + monkeypatch.setattr( + entities, "_resolve_category_class", lambda category: entities.model_entity.Item + ) + monkeypatch.setattr( + entities.config, "get_active_domain", lambda: "wiki-b.example.org" + ) + ctx = Context(_settings(), Policy(), osw=osw) + + result = entities.create_or_update_entity( + ctx, category="Category:Item", jsondata={"label": [{"text": "Test"}]} + ) + + assert result["titles"] == ["Item:OSW1"] + assert result["change_id"] == "c1" + assert result["urls"] == ["https://wiki-b.example.org/wiki/Item:OSW1"] + + +def test_create_or_update_entity_schema_error_raises(): + osw = MagicMock() + osw.fetch_schema.return_value = MagicMock(error_messages=["bad schema"]) + ctx = Context(_settings(), Policy(), osw=osw) + + with pytest.raises(errors.SchemaError): + entities.create_or_update_entity(ctx, category="Category:Item", jsondata={}) + + +def test_create_or_update_entity_class_not_found_raises(monkeypatch): + osw = MagicMock() + osw.fetch_schema.return_value = MagicMock(error_messages=[]) + monkeypatch.setattr(entities, "_resolve_category_class", lambda category: None) + ctx = Context(_settings(), Policy(), osw=osw) + + with pytest.raises(errors.ClassNotFound): + entities.create_or_update_entity(ctx, category="Category:Bogus", jsondata={}) + + +def test_create_or_update_entity_validation_error_raises(monkeypatch): + osw = MagicMock() + osw.fetch_schema.return_value = MagicMock(error_messages=[]) + + class _Boom: + def __init__(self, **kwargs): + raise ValueError("nope") + + monkeypatch.setattr(entities, "_resolve_category_class", lambda category: _Boom) + ctx = Context(_settings(), Policy(), osw=osw) + + with pytest.raises(errors.ValidationError): + entities.create_or_update_entity(ctx, category="Category:Item", jsondata={}) + + +# -- records= (ledger hook) ---------------------------------------------------- +def test_create_or_update_entity_records_matches_old_inline_ledger_call(): + op = registry.REGISTRY["create_or_update_entity"] + + result = { + "titles": ["Item:OSW1", "Item:OSW2"], + "change_id": "c1", + "urls": [ + "https://wiki.example.org/wiki/Item:OSW1", + "https://wiki.example.org/wiki/Item:OSW2", + ], + } + + assert op.records(result) == [ + LedgerRecord( + title="Item:OSW1", op="create_or_update", change_id="c1", slots=["jsondata"] + ), + LedgerRecord( + title="Item:OSW2", op="create_or_update", change_id="c1", slots=["jsondata"] + ), + ] + + +def test_create_or_update_entity_records_empty_when_no_titles(): + op = registry.REGISTRY["create_or_update_entity"] + + assert op.records({"titles": [], "change_id": "c1", "urls": []}) == [] + + +def test_create_or_update_entity_schema_error_does_not_reach_bind_records(): + op = registry.REGISTRY["create_or_update_entity"] + osw = MagicMock() + osw.fetch_schema.return_value = MagicMock(error_messages=["boom"]) + fake_ledger = MagicMock() + ctx = Context( + _settings(), Policy(errors_as_dicts=True), osw=osw, ledger=fake_ledger + ) + bound = registry.bind(op, ctx) + + result = bound(category="Category:Item", jsondata={"label": [{"text": "Test"}]}) + + assert result["type"] == "SchemaError" + fake_ledger.record.assert_not_called() + + +# -- delete_entity -------------------------------------------------------- +def test_delete_untracked_is_blocked(): + osw, page = _osw_with_page() + ledger = MagicMock() + ledger.is_tracked.return_value = False + ctx = Context(_settings(), Policy(), osw=osw, ledger=ledger) + + with pytest.raises(errors.ExternalDeleteBlocked) as exc_info: + entities.delete_entity(ctx, title="Item:OSWx") + + assert exc_info.value.payload()["title"] == "Item:OSWx" + osw.site.get_page.assert_not_called() # never even fetched the page + page.delete.assert_not_called() + + +def test_delete_tracked_is_allowed(): + osw, page = _osw_with_page() + ledger = MagicMock() + ledger.is_tracked.return_value = True + ctx = Context(_settings(), Policy(), osw=osw, ledger=ledger) + + result = entities.delete_entity(ctx, title="Item:OSWx") + + assert result == {"title": "Item:OSWx", "deleted": True} + page.delete.assert_called_once() + ledger.mark_deleted.assert_called_once_with("Item:OSWx") + + +def test_delete_external_with_confirm(): + osw, page = _osw_with_page() + ledger = MagicMock() + ledger.is_tracked.return_value = False + ctx = Context(_settings(), Policy(), osw=osw, ledger=ledger) + + result = entities.delete_entity( + ctx, title="Item:OSWy", confirm_external_delete=True + ) + + assert result == {"title": "Item:OSWy", "deleted": True} + page.delete.assert_called_once() + + +def test_delete_nonexistent_page_raises(): + osw, page = _osw_with_page(exists=False) + ledger = MagicMock() + ledger.is_tracked.return_value = True + ctx = Context(_settings(), Policy(), osw=osw, ledger=ledger) + + with pytest.raises(errors.NotFound) as exc_info: + entities.delete_entity(ctx, title="Item:OSWz") + + assert exc_info.value.payload() == { + "title": "Item:OSWz", + "deleted": False, + "error": "Page 'Item:OSWz' does not exist.", + "type": "NotFound", + } + page.delete.assert_not_called() diff --git a/tests/test_service_ops_files.py b/tests/test_service_ops_files.py new file mode 100644 index 0000000..efe7d01 --- /dev/null +++ b/tests/test_service_ops_files.py @@ -0,0 +1,219 @@ +"""Unit tests for osw.service.ops.files (Operation.fn called directly). + +Runs in the plain dev env (no mcp extra, no network): ``WikiFileController`` +is replaced with a fake factory that records its constructor arguments, so +every test can inspect the title/namespace a real controller would have +derived without touching a wiki. +""" + +from unittest.mock import MagicMock + +import pytest + +from osw.core import OverwriteOptions +from osw.service import errors +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.ledger import LedgerRecord +from osw.service.ops import files +from osw.service.registry import REGISTRY + + +class _WfFactory: + """Stands in for ``WikiFileController``, recording every instance made. + + ``set_stream`` configures the ``.get()`` return value of instances made + *after* the call, mirroring how a real controller's stream only exists + once ``get()`` is invoked on it. + """ + + def __init__(self): + self.created: list = [] + self._stream = None + + def set_stream(self, stream) -> None: + self._stream = stream + + def __call__(self, **kwargs): + wf = MagicMock() + wf.namespace = kwargs.get("namespace") or "File" + wf.title = kwargs.get("title") + wf.url = f"https://wiki.example.org/wiki/{wf.namespace}:{wf.title}" + if self._stream is not None: + wf.get.return_value = self._stream + self.created.append(wf) + return wf + + +@pytest.fixture +def wf_factory(monkeypatch) -> _WfFactory: + """Replace ``files.WikiFileController`` with a fake, recording instances.""" + factory = _WfFactory() + monkeypatch.setattr(files, "WikiFileController", MagicMock(side_effect=factory)) + return factory + + +def _ctx() -> Context: + settings = Settings(domain="wiki.example.org", username="u", password="p") + return Context(settings, Policy(), osw=MagicMock(), ledger=MagicMock()) + + +def _set_page_exists(ctx: Context, exists: bool): + page = MagicMock() + page.exists = exists + ctx.osw.site.get_page.return_value.pages = [page] + return page + + +# -- get_file_info -------------------------------------------------------------- +def test_get_file_info_success(wf_factory): + ctx = _ctx() + _set_page_exists(ctx, True) + stream = MagicMock() + stream.headers = {"Content-Length": "1234", "Content-Type": "image/png"} + wf_factory.set_stream(stream) + + result = files.get_file_info(ctx, "File:OSWabc123.png") + + wf = wf_factory.created[-1] + assert wf.title == "OSWabc123.png" + assert result == { + "title": "File:OSWabc123.png", + "exists": True, + "url": wf.url, + "size": 1234, + "media_type": "image/png", + } + stream.close.assert_called_once() + + +def test_get_file_info_missing(wf_factory): + ctx = _ctx() + _set_page_exists(ctx, False) + + result = files.get_file_info(ctx, "File:doesnotexist.png") + + assert result == { + "title": "File:doesnotexist.png", + "exists": False, + "url": None, + "size": None, + "media_type": None, + } + assert wf_factory.created == [] # no controller built for a missing file + + +# -- read_file_text --------------------------------------------------------------- +def test_read_file_text_success(wf_factory): + ctx = _ctx() + _set_page_exists(ctx, True) + stream = MagicMock() + stream.read.return_value = b"hello world" + wf_factory.set_stream(stream) + + result = files.read_file_text(ctx, "File:OSWabc.txt") + + assert result == { + "title": "File:OSWabc.txt", + "content": "hello world", + "encoding": "utf-8", + "truncated": False, + } + stream.read.assert_called_once_with(ctx.settings.max_chars + 1) + stream.close.assert_called_once() + + +def test_read_file_text_truncates(wf_factory): + ctx = _ctx() + _set_page_exists(ctx, True) + stream = MagicMock() + stream.read.return_value = b"x" * 6 # cap + 1 bytes, cap == limit == 5 + wf_factory.set_stream(stream) + + result = files.read_file_text(ctx, "File:OSWabc.txt", limit=5) + + assert result["truncated"] is True + assert result["content"] == "x" * 5 + stream.read.assert_called_once_with(6) + + +def test_read_file_text_truncation_may_split_a_multibyte_character(wf_factory): + """A valid text file cut mid-character must not be reported as binary. + + ``limit`` counts bytes, so truncating can land inside a multi-byte + character. The incomplete trailing sequence is dropped; raising + BinaryContent here would tell the user to download a file that reads + perfectly well. + """ + ctx = _ctx() + _set_page_exists(ctx, True) + stream = MagicMock() + # 'ä' is two bytes starting at offset 9, so a cap of 10 splits it. + stream.read.return_value = ("a" * 9 + "ä" + "b" * 50).encode("utf-8")[:11] + wf_factory.set_stream(stream) + + result = files.read_file_text(ctx, "File:OSWabc.txt", limit=10) + + assert result["truncated"] is True + assert result["content"] == "a" * 9 + + +def test_read_file_text_missing_raises_not_found(wf_factory): + ctx = _ctx() + _set_page_exists(ctx, False) + + with pytest.raises(errors.NotFound): + files.read_file_text(ctx, "File:doesnotexist.txt") + assert wf_factory.created == [] + + +def test_read_file_text_binary_raises_binary_content(wf_factory): + ctx = _ctx() + _set_page_exists(ctx, True) + stream = MagicMock() + stream.read.return_value = b"\xff\xfe\x00\x01" + wf_factory.set_stream(stream) + + with pytest.raises(errors.BinaryContent): + files.read_file_text(ctx, "File:OSWabc.bin") + + +# -- write_file_text ---------------------------------------------------------- +def test_write_file_text_success(wf_factory): + ctx = _ctx() + + result = files.write_file_text(ctx, "File:OSWabc.txt", "hello") + + wf = wf_factory.created[-1] + assert wf.title == "OSWabc.txt" + wf.put.assert_called_once() + (stream_arg,), put_kwargs = wf.put.call_args + assert stream_arg.read() == b"hello" + assert stream_arg.name == "OSWabc.txt" + assert put_kwargs == {"overwrite": OverwriteOptions.true} + + assert result == {"title": "File:OSWabc.txt", "url": wf.url} + + +def test_write_file_text_custom_name_and_no_overwrite(wf_factory): + ctx = _ctx() + + files.write_file_text( + ctx, "File:OSWabc.txt", "hello", name="renamed.txt", overwrite=False + ) + + wf = wf_factory.created[-1] + (stream_arg,), put_kwargs = wf.put.call_args + assert stream_arg.name == "renamed.txt" + assert put_kwargs == {"overwrite": OverwriteOptions.false} + + +def test_write_file_text_records_ledger_entry(): + op = REGISTRY["write_file_text"] + result = {"title": "File:OSWabc.txt", "url": "https://example.org/x"} + + records = op.records(result) + + assert records == [ + LedgerRecord(title="File:OSWabc.txt", op="create", slots=["jsondata"]) + ] diff --git a/tests/test_service_ops_schema.py b/tests/test_service_ops_schema.py new file mode 100644 index 0000000..fbe7d83 --- /dev/null +++ b/tests/test_service_ops_schema.py @@ -0,0 +1,51 @@ +"""Unit tests for osw.service.ops.schema (Operation.fn called directly). + +Importing ``osw.service.ops.schema`` registers its operations in +``osw.service.registry.REGISTRY`` at import time, so this module must not +clear the registry the way ``test_service_registry.py`` does. +""" + +from unittest.mock import MagicMock + +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.ops import schema + + +def _settings() -> Settings: + return Settings(domain="wiki.example.org", username="u", password="p") + + +def test_get_category_schema_returns_schema_when_page_exists(): + page = MagicMock() + page.exists = True + page.get_slot_content.return_value = {"type": "object"} + osw = MagicMock() + osw.site.get_page.return_value.pages = [page] + ctx = Context(_settings(), Policy(), osw=osw) + + result = schema.get_category_schema(ctx, category="Category:Item") + + assert result == { + "category": "Category:Item", + "exists": True, + "schema": {"type": "object"}, + "truncated": False, + } + page.get_slot_content.assert_called_with("jsonschema") + + +def test_get_category_schema_returns_not_exists_for_missing_page(): + page = MagicMock() + page.exists = False + osw = MagicMock() + osw.site.get_page.return_value.pages = [page] + ctx = Context(_settings(), Policy(), osw=osw) + + result = schema.get_category_schema(ctx, category="Category:Missing") + + assert result == { + "category": "Category:Missing", + "exists": False, + "schema": None, + } diff --git a/tests/test_service_ops_search.py b/tests/test_service_ops_search.py new file mode 100644 index 0000000..e69c690 --- /dev/null +++ b/tests/test_service_ops_search.py @@ -0,0 +1,63 @@ +"""Unit tests for osw.service.ops.search (Operation.fn called directly). + +Importing ``osw.service.ops.search`` registers its operations in +``osw.service.registry.REGISTRY`` at import time, so this module must not +clear the registry the way ``test_service_registry.py`` does. +""" + +from unittest.mock import MagicMock + +import pytest + +from osw.service import errors +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.ops import search + + +def _settings() -> Settings: + return Settings(domain="wiki.example.org", username="u", password="p") + + +def test_search_entities_calls_semantic_search(): + osw = MagicMock() + osw.site.semantic_search.return_value = ["Item:OSW1", "Item:OSW2"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.search_entities(ctx, ask_query="[[Category:Item]]") + + assert result["titles"] == ["Item:OSW1", "Item:OSW2"] + assert result["count"] == 2 + osw.site.semantic_search.assert_called_once() + + +def test_full_text_search_calls_prefix_search(): + osw = MagicMock() + osw.site.prefix_search.return_value = ["Item:OSW1"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.full_text_search(ctx, text="OSW") + + assert result["titles"] == ["Item:OSW1"] + assert result["count"] == 1 + assert result["truncated"] is False + osw.site.prefix_search.assert_called_once() + + +def test_list_instances_of_category_calls_query_instances(): + osw = MagicMock() + osw.query_instances.return_value = ["Item:OSW1", "Item:OSW2"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.list_instances_of_category(ctx, category="Category:Item") + + assert result["titles"] == ["Item:OSW1", "Item:OSW2"] + assert result["count"] == 2 + osw.query_instances.assert_called_once() + + +def test_sparql_query_without_endpoint_raises_not_configured(): + ctx = Context(_settings(), Policy(), osw=MagicMock()) + + with pytest.raises(errors.NotConfigured): + search.sparql_query(ctx, query="SELECT * WHERE {?s ?p ?o}") diff --git a/tests/test_service_ops_slots.py b/tests/test_service_ops_slots.py new file mode 100644 index 0000000..5116f27 --- /dev/null +++ b/tests/test_service_ops_slots.py @@ -0,0 +1,229 @@ +"""Unit tests for osw.service.ops.slots (Operation.fn called directly). + +Importing ``osw.service.ops.slots`` registers its operations in +``osw.service.registry.REGISTRY`` at import time, so this module must not +clear the registry the way ``test_service_registry.py`` does. +""" + +from unittest.mock import MagicMock + +import pytest + +from osw.service import errors, registry +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.ledger import LedgerRecord +from osw.service.ops import slots + + +def _settings() -> Settings: + return Settings(domain="wiki.example.org", username="u", password="p") + + +def _osw_with_page(exists=True, present_slots=()): + page = MagicMock() + page.exists = exists + page._slots = list(present_slots) + osw = MagicMock() + osw.site.get_page.return_value.pages = [page] + return osw, page + + +# -- list_page_slots -------------------------------------------------------- +def test_list_page_slots_missing_page(): + osw, _ = _osw_with_page(exists=False) + ctx = Context(_settings(), Policy(), osw=osw) + + result = slots.list_page_slots(ctx, title="Item:OSW1") + + assert result == { + "title": "Item:OSW1", + "exists": False, + "slots": [], + "valid_slot_keys": list(slots.SLOTS), + } + + +def test_list_page_slots_existing_page(): + osw, page = _osw_with_page(present_slots=["main", "jsondata"]) + page.get_slot_content.side_effect = lambda key: "" if key == "main" else {"a": 1} + page.get_slot_content_model.side_effect = lambda key: ( + "wikitext" if key == "main" else "json" + ) + ctx = Context(_settings(), Policy(), osw=osw) + + result = slots.list_page_slots(ctx, title="Item:OSW1") + + assert result["title"] == "Item:OSW1" + assert result["exists"] is True + assert result["slots"] == [ + {"key": "main", "content_model": "wikitext", "empty": True}, + {"key": "jsondata", "content_model": "json", "empty": False}, + ] + assert result["valid_slot_keys"] == list(slots.SLOTS) + + +# -- get_slot ---------------------------------------------------------------- +def test_get_slot_rejects_unknown_slot(): + ctx = Context(_settings(), Policy(), osw=MagicMock()) + + with pytest.raises(errors.InvalidSlot): + slots.get_slot(ctx, title="Item:OSW1", slot="bogus") + + +def test_get_slot_missing_page_returns_not_exists(): + osw, _ = _osw_with_page(exists=False) + ctx = Context(_settings(), Policy(), osw=osw) + + result = slots.get_slot(ctx, title="Item:OSW1", slot="jsondata") + + assert result == { + "title": "Item:OSW1", + "slot": "jsondata", + "exists": False, + "content": None, + } + + +def test_get_slot_missing_slot_returns_not_exists(): + osw, _page = _osw_with_page(present_slots=["main"]) + ctx = Context(_settings(), Policy(), osw=osw) + + result = slots.get_slot(ctx, title="Item:OSW1", slot="jsondata") + + assert result == { + "title": "Item:OSW1", + "slot": "jsondata", + "exists": False, + "content": None, + } + + +def test_get_slot_existing_slot(): + osw, page = _osw_with_page(present_slots=["jsondata"]) + page.get_slot_content.return_value = {"label": [{"text": "X"}]} + page.get_slot_content_model.return_value = "json" + ctx = Context(_settings(), Policy(), osw=osw) + + result = slots.get_slot(ctx, title="Item:OSW1", slot="jsondata") + + assert result["exists"] is True + assert result["content_model"] == "json" + assert result["content"] == {"label": [{"text": "X"}]} + assert result["truncated"] is False + page.get_slot_content.assert_called_with("jsondata") + + +# -- set_slot ------------------------------------------------------------ +def test_set_slot_rejects_unknown_slot(): + ctx = Context(_settings(), Policy(), osw=MagicMock()) + + with pytest.raises(errors.InvalidSlot): + slots.set_slot(ctx, title="Item:OSW1", slot="bogus", content="x") + + +def test_set_slot_rejects_wrong_content_type_json(): + ctx = Context(_settings(), Policy(), osw=MagicMock()) + + with pytest.raises(errors.InvalidContent): + slots.set_slot(ctx, title="Item:OSW1", slot="jsondata", content="not-json") + + +def test_set_slot_rejects_wrong_content_type_wikitext(): + ctx = Context(_settings(), Policy(), osw=MagicMock()) + + with pytest.raises(errors.InvalidContent): + slots.set_slot(ctx, title="Item:OSW1", slot="main", content={"not": "a string"}) + + +def test_set_slot_missing_slot_without_create_raises_slot_missing(): + osw, page = _osw_with_page(present_slots=[]) + ctx = Context(_settings(), Policy(), osw=osw) + + with pytest.raises(errors.SlotMissing): + slots.set_slot( + ctx, + title="Item:OSW1", + slot="jsondata", + content={"a": 1}, + create_if_missing=False, + ) + page.create_slot.assert_not_called() + page.set_slot_content.assert_not_called() + + +def test_set_slot_creates_missing_slot_when_allowed(): + osw, page = _osw_with_page(present_slots=[]) + page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1" + ctx = Context(_settings(), Policy(), osw=osw) + + result = slots.set_slot(ctx, title="Item:OSW1", slot="jsondata", content={"a": 1}) + + page.create_slot.assert_called_once_with("jsondata", "json") + page.set_slot_content.assert_called_once_with("jsondata", {"a": 1}) + page.edit.assert_called_once() + assert result == { + "title": "Item:OSW1", + "slot": "jsondata", + "changed": True, + "url": "https://wiki.example.org/wiki/Item:OSW1", + } + + +def test_set_slot_existing_slot_skips_create(): + osw, page = _osw_with_page(present_slots=["jsondata"]) + page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1" + ctx = Context(_settings(), Policy(), osw=osw) + + result = slots.set_slot(ctx, title="Item:OSW1", slot="jsondata", content={"a": 1}) + + page.create_slot.assert_not_called() + page.set_slot_content.assert_called_once_with("jsondata", {"a": 1}) + assert result["changed"] is True + + +# -- records= (ledger hook) ------------------------------------------------- +def test_set_slot_records_matches_old_inline_ledger_call(): + op = registry.REGISTRY["set_slot"] + + result = { + "title": "Item:OSW1", + "slot": "jsondata", + "changed": True, + "url": "https://wiki.example.org/wiki/Item:OSW1", + } + + assert op.records(result) == [ + LedgerRecord(title="Item:OSW1", op="update", slots=["jsondata"]) + ] + + +def test_set_slot_records_empty_when_not_changed(): + op = registry.REGISTRY["set_slot"] + + assert ( + op.records({"title": "Item:OSW1", "slot": "jsondata", "changed": False}) == [] + ) + + +def test_set_slot_records_empty_when_changed_key_absent(): + op = registry.REGISTRY["set_slot"] + + assert op.records({"title": "Item:OSW1", "slot": "jsondata"}) == [] + + +def test_set_slot_error_paths_do_not_reach_bind_records(): + """The invalid-input/slot-missing paths raise, so bind() never calls + op.records for them -- matching the old code, which returned before + reaching ``ledger.record``.""" + op = registry.REGISTRY["set_slot"] + fake_ledger = MagicMock() + ctx = Context( + _settings(), Policy(errors_as_dicts=True), osw=MagicMock(), ledger=fake_ledger + ) + bound = registry.bind(op, ctx) + + result = bound(title="Item:OSW1", slot="bogus", content="x") + + assert result["type"] == "InvalidSlot" + fake_ledger.record.assert_not_called() diff --git a/tests/test_service_ops_status.py b/tests/test_service_ops_status.py new file mode 100644 index 0000000..40380e4 --- /dev/null +++ b/tests/test_service_ops_status.py @@ -0,0 +1,85 @@ +"""Unit tests for osw.service.ops.status (Operation.fn called directly). + +Importing ``osw.service.ops.status`` registers its operation in +``osw.service.registry.REGISTRY`` at import time, so this module must not +clear the registry the way ``test_service_registry.py`` does. +""" + +from unittest.mock import MagicMock + +from osw.service import config +from osw.service.context import Context, Policy +from osw.service.ops import status + +_ALL_VARS = [ + "OSW_DOMAIN", + "OSL_DOMAIN", + "OSW_USERNAME", + "OSL_USERNAME", + "OSW_PASSWORD", + "OSL_PASSWORD", + "OSW_CRED_FILEPATH", + "OSW_MCP_CRED_FILEPATH", + "OSL_CRED_FILEPATH", +] + + +def _clean_env(monkeypatch): + for var in _ALL_VARS: + monkeypatch.delenv(var, raising=False) + + +def test_status_reports_active_instance_and_connects(monkeypatch): + _clean_env(monkeypatch) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "u") + monkeypatch.setenv("OSW_PASSWORD", "p") + config.reset() + ledger = MagicMock() + ledger.path = "/tmp/ledger.json" + ledger.entry_count.return_value = 3 + ctx = Context(config.get_settings(), Policy(), osw=MagicMock(), ledger=ledger) + + result = status.status(ctx) + + assert result["connected"] is True + assert "password" not in result + assert result["active_iri"] == "wiki.example.org" + assert result["ledger_entry_count"] == 3 + config.reset() + + +def test_status_no_active_instance_reports_message(monkeypatch): + _clean_env(monkeypatch) + config.reset() + monkeypatch.setattr(config, "get_settings", lambda: config.Settings(domain=None)) + ctx = Context(config.get_settings(), Policy(), osw=MagicMock(), ledger=MagicMock()) + + result = status.status(ctx) + + assert result["connected"] is False + assert result["active_iri"] is None + assert "message" in result + config.reset() + + +def test_status_connection_failure_reports_connection_error(monkeypatch): + _clean_env(monkeypatch) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "u") + monkeypatch.setenv("OSW_PASSWORD", "p") + config.reset() + + from osw.service import context as context_module + + def _raise(*args, **kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(context_module, "OswExpress", _raise) + ctx = Context(config.get_settings(), Policy(), ledger=MagicMock()) + + result = status.status(ctx) + + assert result["connected"] is False + assert "boom" in result["connection_error"] + config.reset() diff --git a/tests/test_service_registry.py b/tests/test_service_registry.py new file mode 100644 index 0000000..06ad68d --- /dev/null +++ b/tests/test_service_registry.py @@ -0,0 +1,363 @@ +"""Unit tests for osw.service.registry (Operation validation, bind()). + +Registers test operations against a snapshot/restore of the global +``REGISTRY`` so this file cannot pollute other test modules. A fake +``osw``/``ledger`` is injected into ``Context`` so nothing here touches the +network. +""" + +import inspect +import typing +from unittest.mock import MagicMock + +import pytest + +from osw.service import errors, registry +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.ledger import LedgerRecord + + +@pytest.fixture(autouse=True) +def _clean_registry(): + original = dict(registry.REGISTRY) + registry.REGISTRY.clear() + yield + registry.REGISTRY.clear() + registry.REGISTRY.update(original) + + +def _settings() -> Settings: + return Settings(domain="wiki.example.org", username="u", password="p") + + +def _underlying_message(exc_info) -> str: + """Pydantic wraps our ``raise ValueError`` in its own message; unwrap it.""" + return str(exc_info.value.errors()[0]["ctx"]["error"]) + + +def _valid_fn(ctx, title: str) -> dict: + """Do a thing.""" + return {"title": title} + + +# -- Operation.command ------------------------------------------------------ +def test_command_defaults_to_name(): + op = registry.Operation(name="foo", fn=_valid_fn) + assert op.command == "foo" + + +def test_command_uses_cli_name_override(): + op = registry.Operation(name="foo", fn=_valid_fn, cli_name="bar") + assert op.command == "bar" + + +# -- validator ---------------------------------------------------------- +def test_validator_rejects_missing_ctx_param(): + def fn(): + """Doc.""" + return {} + + with pytest.raises(ValueError) as exc_info: + registry.Operation(name="no_params", fn=fn) + assert _underlying_message(exc_info).startswith("no_params:") + + +def test_validator_rejects_first_param_not_named_ctx(): + def fn(x): + """Doc.""" + return {} + + with pytest.raises(ValueError) as exc_info: + registry.Operation(name="bad_ctx", fn=fn) + assert _underlying_message(exc_info).startswith("bad_ctx:") + + +def test_validator_rejects_records_without_writes(): + with pytest.raises(ValueError) as exc_info: + registry.Operation(name="bad_records", fn=_valid_fn, records=lambda r: []) + assert _underlying_message(exc_info).startswith("bad_records:") + + +def test_validator_requires_docstring(): + def fn(ctx, title: str) -> dict: + return {} + + with pytest.raises(ValueError) as exc_info: + registry.Operation(name="no_doc", fn=fn) + assert _underlying_message(exc_info).startswith("no_doc:") + + +def test_validator_rejects_path_like_param_on_mcp_surface(): + def fn(ctx, source_path: str) -> dict: + """Doc.""" + return {} + + with pytest.raises(ValueError) as exc_info: + registry.Operation(name="bad_path", fn=fn) + msg = _underlying_message(exc_info) + assert msg.startswith("bad_path:") + assert "source_path" in msg + + +def test_validator_allows_path_like_param_on_cli_only_surface(): + def fn(ctx, source_path: str) -> dict: + """Doc.""" + return {} + + op = registry.Operation(name="cli_only", fn=fn, surfaces=frozenset({"cli"})) + assert "source_path" in inspect.signature(op.fn).parameters + + +def test_extra_forbid_rejects_misspelled_kwarg(): + with pytest.raises(ValueError): + registry.Operation(name="typo", fn=_valid_fn, sumary="oops") + + +# -- operation() decorator / REGISTRY ---------------------------------------- +def test_operation_decorator_registers_and_returns_fn_unchanged(): + @registry.operation() + def my_op(ctx, title: str) -> dict: + """Do a thing.""" + return {"title": title} + + assert "my_op" in registry.REGISTRY + assert registry.REGISTRY["my_op"].fn is my_op + assert my_op(None, title="x") == {"title": "x"} + + +def test_operation_decorator_name_override(): + @registry.operation(name="custom_name") + def my_op(ctx, title: str) -> dict: + """Do a thing.""" + return {} + + assert "custom_name" in registry.REGISTRY + assert "my_op" not in registry.REGISTRY + + +def test_operation_decorator_rejects_duplicate_name(): + @registry.operation() + def dup(ctx, title: str) -> dict: + """Do a thing.""" + return {} + + with pytest.raises(ValueError): + + @registry.operation(name="dup") + def other(ctx, title: str) -> dict: + """Do a thing.""" + return {} + + +# -- iter_operations ---------------------------------------------------- +def _register(name, **kwargs): + def fn(ctx, title: str) -> dict: + """Do a thing.""" + return {"title": title} + + kwargs.setdefault("surfaces", frozenset({"mcp", "cli"})) + registry.REGISTRY[name] = registry.Operation(name=name, fn=fn, **kwargs) + + +def test_iter_operations_filters_by_surface(): + _register("mcp_only", surfaces=frozenset({"mcp"})) + _register("cli_only", surfaces=frozenset({"cli"})) + names_mcp = {op.name for op in registry.iter_operations(surface="mcp")} + names_cli = {op.name for op in registry.iter_operations(surface="cli")} + assert "mcp_only" in names_mcp and "mcp_only" not in names_cli + assert "cli_only" in names_cli and "cli_only" not in names_mcp + + +def test_iter_operations_filters_writes(): + _register("reader", writes=False) + _register("writer", writes=True) + with_writes = {op.name for op in registry.iter_operations(surface="mcp")} + without_writes = { + op.name for op in registry.iter_operations(surface="mcp", include_writes=False) + } + assert "writer" in with_writes + assert "writer" not in without_writes + assert "reader" in without_writes + + +def test_iter_operations_preserves_registration_order(): + _register("first") + _register("second") + _register("third") + names = [op.name for op in registry.iter_operations(surface="mcp")] + assert names.index("first") < names.index("second") < names.index("third") + + +# -- bind(): signature / annotations / doc preservation ---------------------- +def test_bind_signature_excludes_ctx(): + def fn(ctx, title: str, limit: int = 5) -> dict: + """Do a thing.""" + return {} + + op = registry.Operation(name="op1", fn=fn) + ctx = Context(_settings(), osw=object()) + bound = registry.bind(op, ctx) + + sig = inspect.signature(bound) + assert list(sig.parameters) == ["title", "limit"] + assert "ctx" not in bound.__annotations__ + assert bound.__doc__ == fn.__doc__ + assert bound.__name__ == fn.__name__ + + +def test_bind_resolves_string_annotations_against_the_op_module(): + """An op module using ``from __future__ import annotations`` stores its + annotations as strings. ``bound`` lives in registry.py, so a consumer calling + get_type_hints() on it would resolve them against the wrong globals; bind() + must therefore resolve them eagerly.""" + ns: dict = {} + exec( + "from __future__ import annotations\n" + "from typing import Optional\n" + "class Marker: pass\n" + "def fn(ctx, thing: Optional[Marker] = None) -> dict:\n" + " '''Do a thing.'''\n" + " return {}\n", + ns, + ) + fn, marker = ns["fn"], ns["Marker"] + assert fn.__annotations__["thing"] == "Optional[Marker]" + + op = registry.Operation(name="op1", fn=fn) + bound = registry.bind(op, Context(_settings(), osw=object())) + + expected = typing.Optional[marker] + assert bound.__annotations__["thing"] == expected + assert inspect.signature(bound).parameters["thing"].annotation == expected + # Marker is not in registry.py's globals, so this raised NameError before. + assert typing.get_type_hints(bound)["thing"] == expected + + +# -- bind(): error handling -------------------------------------------------- +def test_bind_errors_as_dicts_true_returns_payload(): + def fn(ctx, title: str) -> dict: + """Do a thing.""" + raise errors.NotFound(f"Page '{title}' does not exist.") + + op = registry.Operation(name="op_err", fn=fn) + ctx = Context(_settings(), Policy(errors_as_dicts=True), osw=object()) + bound = registry.bind(op, ctx) + + result = bound(title="Item:X") + + assert result == {"error": "Page 'Item:X' does not exist.", "type": "NotFound"} + + +def test_bind_errors_as_dicts_false_reraises(): + def fn(ctx, title: str) -> dict: + """Do a thing.""" + raise errors.NotFound(f"Page '{title}' does not exist.") + + op = registry.Operation(name="op_err2", fn=fn) + ctx = Context(_settings(), Policy(errors_as_dicts=False), osw=object()) + bound = registry.bind(op, ctx) + + with pytest.raises(errors.NotFound): + bound(title="Item:X") + + +def test_bind_non_operror_exception_becomes_generic_dict(): + def fn(ctx, title: str) -> dict: + """Do a thing.""" + raise RuntimeError("boom") + + op = registry.Operation(name="op_err3", fn=fn) + ctx = Context(_settings(), Policy(errors_as_dicts=True), osw=object()) + bound = registry.bind(op, ctx) + + result = bound(title="x") + + assert result == {"error": "boom", "type": "RuntimeError"} + + +def test_bind_calls_require_write_for_writing_ops(): + def fn(ctx, title: str) -> dict: + """Do a thing.""" + return {"title": title} + + op = registry.Operation(name="writer_op", fn=fn, writes=True) + ctx = Context( + _settings(), + Policy(allow_writes=False, errors_as_dicts=True), + osw=object(), + ) + bound = registry.bind(op, ctx) + + result = bound(title="x") + + assert result["type"] == "ReadOnly" # the ReadOnly OpError require_write raises + + +# -- bind(): ledger recording ------------------------------------------- +def test_bind_invokes_ledger_once_per_returned_record_with_full_arguments(): + def fn(ctx, title: str) -> dict: + """Do a thing.""" + return {"titles": [title, title + "-2"]} + + def _records(result: dict) -> list: + return [ + LedgerRecord( + title=result["titles"][0], + op="create", + change_id="c1", + slots=["jsondata"], + ), + LedgerRecord(title=result["titles"][1], op="update", slots=["main"]), + ] + + op = registry.Operation( + name="writer_records", + fn=fn, + writes=True, + records=_records, + ) + fake_ledger = MagicMock() + ctx = Context(_settings(), osw=object(), ledger=fake_ledger) + bound = registry.bind(op, ctx) + + bound(title="Item:A") + + assert fake_ledger.record.call_count == 2 + + first = fake_ledger.record.call_args_list[0] + assert first.args == ("Item:A",) + assert first.kwargs == { + "tool": "writer_records", + "op": "create", + "change_id": "c1", + "slots": ["jsondata"], + "uuid": None, + "namespace": None, + } + + second = fake_ledger.record.call_args_list[1] + assert second.args == ("Item:A-2",) + assert second.kwargs == { + "tool": "writer_records", + "op": "update", + "change_id": None, + "slots": ["main"], + "uuid": None, + "namespace": None, + } + + +def test_bind_does_not_invoke_ledger_when_op_does_not_write(): + def fn(ctx, title: str) -> dict: + """Do a thing.""" + return {"titles": [title]} + + op = registry.Operation(name="reader_op", fn=fn, writes=False) + fake_ledger = MagicMock() + ctx = Context(_settings(), osw=object(), ledger=fake_ledger) + bound = registry.bind(op, ctx) + + bound(title="Item:A") + + fake_ledger.record.assert_not_called() diff --git a/tests/test_service_serialization.py b/tests/test_service_serialization.py new file mode 100644 index 0000000..08e3872 --- /dev/null +++ b/tests/test_service_serialization.py @@ -0,0 +1,58 @@ +"""Unit tests for osw.service.serialization.""" + +from pathlib import Path + +from osw.service.serialization import cap_list, maybe_truncate, to_jsonable + + +def test_cap_list_under_limit(): + items, total, truncated = cap_list([1, 2, 3], 10) + assert items == [1, 2, 3] + assert total == 3 + assert truncated is False + + +def test_cap_list_over_limit(): + items, total, truncated = cap_list(list(range(10)), 3) + assert items == [0, 1, 2] + assert total == 10 + assert truncated is True + + +def test_maybe_truncate_short_string(): + value, truncated = maybe_truncate("hello", 100) + assert value == "hello" + assert truncated is False + + +def test_maybe_truncate_long_string(): + value, truncated = maybe_truncate("x" * 50, 10) + assert value == "x" * 10 + assert truncated is True + + +def test_maybe_truncate_small_dict_roundtrips(): + value, truncated = maybe_truncate({"a": 1}, 100) + assert value == {"a": 1} + assert truncated is False + + +def test_maybe_truncate_large_dict_returns_truncated_json_string(): + big = {"items": list(range(1000))} + value, truncated = maybe_truncate(big, 50) + assert truncated is True + assert isinstance(value, str) + assert len(value) == 50 + + +def test_maybe_truncate_none(): + value, truncated = maybe_truncate(None, 10) + assert value is None + assert truncated is False + + +def test_to_jsonable_falls_back_to_str(): + # Path and set are not natively JSON-serializable + result = to_jsonable({"p": Path("/tmp/x"), "s": {1, 2}}) + assert isinstance(result["p"], str) + assert isinstance(result["s"], str) diff --git a/uv.lock b/uv.lock index 6ce8fa9..8bce090 100644 --- a/uv.lock +++ b/uv.lock @@ -2,14 +2,17 @@ version = 1 revision = 3 requires-python = ">=3.10, <3.14" resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", "python_full_version < '3.11'", ] +conflicts = [[ + { package = "osw", extra = "mcp" }, + { package = "osw", extra = "workflow" }, +], [ + { package = "osw", extra = "mcp" }, + { package = "osw", group = "dev" }, +]] [[package]] name = "aiosqlite" @@ -48,17 +51,41 @@ wheels = [ name = "anyio" version = "4.6.2.post1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "(python_full_version < '3.11' and extra == 'extra-3-osw-workflow') or (python_full_version < '3.11' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "idna", marker = "extra == 'extra-3-osw-workflow' or extra == 'group-3-osw-dev'" }, + { name = "sniffio", marker = "extra == 'extra-3-osw-workflow' or extra == 'group-3-osw-dev'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and extra == 'extra-3-osw-workflow') or (python_full_version < '3.11' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422, upload-time = "2024-10-14T14:31:44.021Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377, upload-time = "2024-10-14T14:31:42.623Z" }, ] +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] +dependencies = [ + { name = "exceptiongroup", marker = "(python_full_version < '3.11' and extra == 'extra-3-osw-mcp') or (python_full_version < '3.11' and extra != 'extra-3-osw-workflow' and extra != 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "idna", marker = "extra == 'extra-3-osw-mcp' or (extra != 'extra-3-osw-workflow' and extra != 'group-3-osw-dev')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and extra == 'extra-3-osw-mcp') or (python_full_version < '3.13' and extra != 'extra-3-osw-workflow' and extra != 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + [[package]] name = "apprise" version = "1.12.0" @@ -188,8 +215,8 @@ dependencies = [ { name = "pathspec" }, { name = "platformdirs" }, { name = "pytokens" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } wheels = [ @@ -411,7 +438,7 @@ name = "click" version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ @@ -527,7 +554,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, + { name = "tomli", marker = "python_full_version <= '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] [[package]] @@ -595,7 +622,7 @@ dependencies = [ { name = "click" }, { name = "cloudpickle" }, { name = "fsspec" }, - { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.12' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "packaging" }, { name = "partd" }, { name = "pyyaml" }, @@ -620,7 +647,7 @@ dependencies = [ { name = "packaging" }, { name = "pydantic" }, { name = "pyyaml" }, - { name = "tomli", marker = "python_full_version < '3.12'" }, + { name = "tomli", marker = "python_full_version < '3.12' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/88/78/dd57f7cb55be1b5465718eb0a53be947984ae07e48c7cdfdef1ae3da976f/datamodel_code_generator-0.51.0.tar.gz", hash = "sha256:8944813cdd9a354e651513868204fffae56c004855f2316a660b023421c712d0", size = 758566, upload-time = "2026-01-01T00:02:32.532Z" } wheels = [ @@ -782,7 +809,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11' or (python_full_version < '3.13' and extra == 'extra-3-osw-workflow') or (python_full_version < '3.13' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1036,12 +1063,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + [[package]] name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.6.2.post1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-3-osw-workflow' or extra == 'group-3-osw-dev'" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-3-osw-mcp' or (extra != 'extra-3-osw-workflow' and extra != 'group-3-osw-dev')" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, @@ -1056,6 +1097,32 @@ http2 = [ { name = "h2" }, ] +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "humanize" version = "4.16.0" @@ -1097,7 +1164,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12'" }, + { name = "zipp", marker = "python_full_version < '3.12' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -1246,8 +1313,8 @@ dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, { name = "referencing" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and extra == 'extra-3-osw-mcp') or (python_full_version < '3.11' and extra == 'extra-3-osw-workflow') or (python_full_version < '3.11' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-3-osw-mcp') or (python_full_version >= '3.11' and extra == 'extra-3-osw-workflow') or (python_full_version >= '3.11' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -1475,6 +1542,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] +[[package]] +name = "mcp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" } }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"], marker = "extra == 'extra-3-osw-mcp'" }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1737,9 +1842,7 @@ name = "numpy" version = "2.4.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } wheels = [ @@ -1800,9 +1903,7 @@ name = "numpy" version = "2.5.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12'", ] sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } wheels = [ @@ -1875,7 +1976,7 @@ name = "opensemantic" version = "0.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-strenum", marker = "python_full_version < '3.11'" }, + { name = "backports-strenum", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "oold" }, ] sdist = { url = "https://files.pythonhosted.org/packages/09/58/2bdbd07aeb065cbe95d0cdfe024e97e7ca69bfe0d2d49b48ff889466e139/opensemantic-0.2.4.tar.gz", hash = "sha256:1e5f6beac3dc84b04a3de9eb5c05bed9be1b2fd286898df7965be5ced387005e", size = 33204, upload-time = "2026-05-08T12:47:36.822Z" } @@ -1910,6 +2011,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/c8/ab45630822479696bd4e7650a7e3a547b782ae3a0b30bfcd04a39e6692d3/opensemantic_core-0.57.4.post1000002003001-py3-none-any.whl", hash = "sha256:6cb35e14e011be95e0ded366d1cc2dd8f547209a68665960ffab530ecb6d7ef4", size = 51538, upload-time = "2026-05-04T06:15:25.887Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "orjson" version = "3.11.9" @@ -1981,7 +2094,7 @@ name = "osw" version = "2.0.0" source = { editable = "." } dependencies = [ - { name = "backports-strenum", marker = "python_full_version < '3.11'" }, + { name = "backports-strenum", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "black" }, { name = "dask" }, { name = "datamodel-code-generator" }, @@ -1989,9 +2102,9 @@ dependencies = [ { name = "isort" }, { name = "jsonpath-ng" }, { name = "mwclient" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "oold" }, { name = "opensemantic" }, { name = "opensemantic-base" }, @@ -2004,6 +2117,7 @@ dependencies = [ { name = "requests" }, { name = "sparqlwrapper" }, { name = "tqdm" }, + { name = "typer" }, { name = "typing-extensions" }, ] @@ -2027,6 +2141,10 @@ db = [ { name = "psycopg2" }, { name = "sqlalchemy" }, ] +mcp = [ + { name = "mcp" }, + { name = "python-dotenv" }, +] s3 = [ { name = "boto3" }, ] @@ -2042,12 +2160,13 @@ wikitext = [ { name = "mwparserfromhell" }, ] workflow = [ - { name = "anyio" }, + { name = "anyio", version = "4.6.2.post1", source = { registry = "https://pypi.org/simple" } }, { name = "prefect" }, ] [package.dev-dependencies] dev = [ + { name = "anyio", version = "4.6.2.post1", source = { registry = "https://pypi.org/simple" } }, { name = "backports-strenum" }, { name = "boto3" }, { name = "deepl" }, @@ -2057,19 +2176,27 @@ dev = [ { name = "mike" }, { name = "mkdocstrings-python" }, { name = "mwparserfromhell" }, - { name = "osw", extra = ["workflow"] }, { name = "pre-commit" }, + { name = "prefect" }, { name = "psycopg2-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "python-dotenv" }, { name = "python-semantic-release" }, { name = "ruff" }, { name = "sqlalchemy" }, { name = "ty" }, { name = "zensical" }, ] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "python-dotenv" }, +] [package.metadata] requires-dist = [ @@ -2084,6 +2211,7 @@ requires-dist = [ { name = "httpx" }, { name = "isort" }, { name = "jsonpath-ng" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=2" }, { name = "mwclient", specifier = ">=0.11.0" }, { name = "mwparserfromhell", marker = "extra == 'wikitext'" }, { name = "numpy" }, @@ -2100,18 +2228,21 @@ requires-dist = [ { name = "pydantic", extras = ["email"], specifier = ">=2.12.0" }, { name = "pyld" }, { name = "pysimplegui", marker = "extra == 'ui'" }, + { name = "python-dotenv", marker = "extra == 'mcp'", specifier = ">=1.0" }, { name = "pyyaml" }, { name = "rdflib" }, { name = "requests" }, { name = "sparqlwrapper" }, { name = "sqlalchemy", marker = "extra == 'db'" }, { name = "tqdm" }, + { name = "typer" }, { name = "typing-extensions" }, ] -provides-extras = ["wikitext", "db", "s3", "dataimport", "ui", "workflow", "tutorial", "all"] +provides-extras = ["wikitext", "db", "s3", "dataimport", "ui", "mcp", "workflow", "tutorial", "all"] [package.metadata.requires-dev] dev = [ + { name = "anyio", specifier = ">=4.4.0,<4.7" }, { name = "backports-strenum" }, { name = "boto3" }, { name = "deepl" }, @@ -2121,19 +2252,27 @@ dev = [ { name = "mike", git = "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/squidfunk/mike.git?rev=2.2.0%2Bzensical-0.1.0" }, { name = "mkdocstrings-python", specifier = ">=1.0.3" }, { name = "mwparserfromhell" }, - { name = "osw", extras = ["workflow"] }, { name = "pre-commit", specifier = ">=4.0.0" }, + { name = "prefect", specifier = ">=2.20.25,<3.0" }, { name = "psycopg2-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "python-dotenv", specifier = ">=1.0" }, { name = "python-semantic-release", specifier = ">=10.0.0" }, { name = "ruff", specifier = ">=0.15.7" }, { name = "sqlalchemy" }, { name = "ty", specifier = ">=0.0.24" }, { name = "zensical", specifier = ">=0.0.46" }, ] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "python-dotenv", specifier = ">=1.0" }, +] [[package]] name = "packaging" @@ -2171,9 +2310,7 @@ name = "pendulum" version = "2.1.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", "python_full_version < '3.11'", ] dependencies = [ @@ -2187,9 +2324,7 @@ name = "pendulum" version = "3.2.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12'", ] dependencies = [ { name = "python-dateutil", marker = "python_full_version >= '3.12'" }, @@ -2287,7 +2422,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, { name = "alembic" }, - { name = "anyio" }, + { name = "anyio", version = "4.6.2.post1", source = { registry = "https://pypi.org/simple" } }, { name = "apprise" }, { name = "asgi-lifespan" }, { name = "asyncpg" }, @@ -2304,7 +2439,7 @@ dependencies = [ { name = "graphviz" }, { name = "griffe" }, { name = "httpcore" }, - { name = "httpx", extra = ["http2"] }, + { name = "httpx", extra = ["http2"], marker = "extra == 'extra-3-osw-workflow' or extra == 'group-3-osw-dev'" }, { name = "humanize" }, { name = "importlib-resources" }, { name = "itsdangerous" }, @@ -2316,9 +2451,9 @@ dependencies = [ { name = "orjson" }, { name = "packaging" }, { name = "pathspec" }, - { name = "pendulum", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "pendulum", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pydantic", extra = ["email"] }, + { name = "pendulum", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and extra == 'extra-3-osw-workflow') or (python_full_version < '3.12' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "pendulum", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra == 'extra-3-osw-workflow') or (python_full_version >= '3.12' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "pydantic", extra = ["email"], marker = "extra == 'extra-3-osw-workflow' or extra == 'group-3-osw-dev'" }, { name = "pydantic-core" }, { name = "python-dateutil" }, { name = "python-multipart" }, @@ -2330,7 +2465,7 @@ dependencies = [ { name = "rich" }, { name = "ruamel-yaml" }, { name = "sniffio" }, - { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "sqlalchemy", extra = ["asyncio"], marker = "extra == 'extra-3-osw-workflow' or extra == 'group-3-osw-dev'" }, { name = "toml" }, { name = "typer" }, { name = "typing-extensions" }, @@ -2564,6 +2699,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyld" version = "3.1.0" @@ -2622,13 +2774,13 @@ name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -2640,9 +2792,9 @@ name = "pytest-asyncio" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ @@ -2700,6 +2852,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/78/9b77ecb4644d1bbea94d29abf78f21c47eca6eb79e9745b702ec0bed2e19/python_discovery-1.4.3-py3-none-any.whl", hash = "sha256:b6e1e4a7d9e3f6948c39746ffe8218225162d738ba39d05ab1d2f6c1cac4878c", size = 33885, upload-time = "2026-07-03T13:21:50.174Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "python-gitlab" version = "8.4.0" @@ -2887,7 +3048,7 @@ name = "rdflib" version = "7.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "isodate", marker = "python_full_version < '3.11'" }, + { name = "isodate", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "pyparsing" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz", hash = "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df", size = 4943826, upload-time = "2026-02-13T07:15:55.938Z" } @@ -2910,8 +3071,8 @@ version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and extra == 'extra-3-osw-mcp') or (python_full_version < '3.11' and extra == 'extra-3-osw-workflow') or (python_full_version < '3.11' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-3-osw-mcp') or (python_full_version >= '3.11' and extra == 'extra-3-osw-workflow') or (python_full_version >= '3.11' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } @@ -3186,12 +3347,8 @@ name = "rpds-py" version = "2026.6.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } wheels = [ @@ -3404,7 +3561,7 @@ name = "sqlalchemy" version = "2.0.35" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet", marker = "(python_full_version < '3.13' and platform_machine == 'AMD64') or (python_full_version < '3.13' and platform_machine == 'WIN32') or (python_full_version < '3.13' and platform_machine == 'aarch64') or (python_full_version < '3.13' and platform_machine == 'amd64') or (python_full_version < '3.13' and platform_machine == 'ppc64le') or (python_full_version < '3.13' and platform_machine == 'win32') or (python_full_version < '3.13' and platform_machine == 'x86_64')" }, + { name = "greenlet", marker = "(python_full_version < '3.13' and platform_machine == 'AMD64') or (python_full_version < '3.13' and platform_machine == 'WIN32') or (python_full_version < '3.13' and platform_machine == 'aarch64') or (python_full_version < '3.13' and platform_machine == 'amd64') or (python_full_version < '3.13' and platform_machine == 'ppc64le') or (python_full_version < '3.13' and platform_machine == 'win32') or (python_full_version < '3.13' and platform_machine == 'x86_64') or (platform_machine != 'AMD64' and platform_machine != 'WIN32' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'ppc64le' and platform_machine != 'win32' and platform_machine != 'x86_64' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine != 'AMD64' and platform_machine != 'WIN32' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'ppc64le' and platform_machine != 'win32' and platform_machine != 'x86_64' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev') or (platform_machine == 'AMD64' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine == 'AMD64' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev') or (platform_machine == 'WIN32' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine == 'WIN32' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev') or (platform_machine == 'aarch64' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine == 'aarch64' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev') or (platform_machine == 'amd64' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine == 'amd64' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev') or (platform_machine == 'ppc64le' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine == 'ppc64le' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev') or (platform_machine == 'win32' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine == 'win32' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev') or (platform_machine == 'x86_64' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine == 'x86_64' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/36/48/4f190a83525f5cefefa44f6adc9e6386c4de5218d686c27eda92eb1f5424/sqlalchemy-2.0.35.tar.gz", hash = "sha256:e11d7ea4d24f0a262bccf9a7cd6284c976c5369dac21db237cff59586045ab9f", size = 9562798, upload-time = "2024-09-16T20:30:05.964Z" } @@ -3441,6 +3598,32 @@ asyncio = [ { name = "greenlet" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/3a/764912c58293d95b6dcdf4cc255f9d10de310580ced547b082eb9d72018c/sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d", size = 16516, upload-time = "2026-08-05T11:19:48.748Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + [[package]] name = "text-unidecode" version = "1.3" @@ -3518,13 +3701,22 @@ name = "tqdm" version = "4.68.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/5f/57ff8b434839e70dab45601284ea413e947a63799891b7553e5960a793a8/tqdm-4.68.4.tar.gz", hash = "sha256:19829c9673638f2a0b8617da4cdcb927e831cd88bcfcb6e78d42a4d1af131520", size = 792418, upload-time = "2026-07-07T09:58:18.369Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/22/2a/5e5e750890ada51017d18d0d4c30da696e5b5bd3180947729927628fc3cb/tqdm-4.68.4-py3-none-any.whl", hash = "sha256:5168118b2368f48c561afda8020fd79195b1bdb0bdf8086b88442c267a315dc2", size = 676612, upload-time = "2026-07-07T09:58:16.256Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "ty" version = "0.0.56" diff --git a/zensical.toml b/zensical.toml index 50218f4..671f583 100644 --- a/zensical.toml +++ b/zensical.toml @@ -14,6 +14,7 @@ nav = [ { "Home" = "index.md" }, { "About" = "about.md" }, { "Get Started" = "get-started.md" }, + { "CLI and MCP tools" = "cli-and-mcp.md" }, { "API Reference" = [ { "Overview" = "api/index.md" }, { "OSW" = "api/core.md" },