diff --git a/.gitignore b/.gitignore index dc1ba373..a362bb8e 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 cc156c0e..c3c4be62 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ pip install osw ``` Optional extras (`osw[wikitext]`, `osw[DB]`, `osw[S3]`, `osw[dataimport]`, -`osw[UI]`, `osw[all]`) are described in the +`osw[UI]`, `osw[mcp]`, `osw[all]`) are described in the [Get Started guide](https://opensemanticlab.github.io/osw-python/get-started/). ## Quickstart @@ -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. +## Tools + +Installing `osw` also installs an `osw` command line client, and the +`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 +[Tools guide](https://opensemanticlab.github.io/osw-python/tools/). + ## Logging osw reports what it is doing on the `osw` logger at INFO by default. Levels, diff --git a/docs/get-started.md b/docs/get-started.md index 3b246f02..794b21ce 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -30,6 +30,7 @@ | `osw[S3]` | Interact with S3 stores per S3FileController | | `osw[dataimport]` | Additional tools to import data | | `osw[UI]` | To use a helper UI to work with entity slots | +| `osw[mcp]` | [MCP server](tools/mcp.md) for agent clients | | `osw[all]` | All of the above | Install multiple extras with `pip install osw[opt1,opt2]`. diff --git a/docs/tools/cli.md b/docs/tools/cli.md new file mode 100644 index 00000000..54255620 --- /dev/null +++ b/docs/tools/cli.md @@ -0,0 +1,74 @@ +# CLI + +## Quick start + +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 searches upward from the working directory for it, so `osw status` +now reports the instance, the username (whether it comes from `OSW_USERNAME` +or a credential file), and connection state. The MCP server takes its +settings from the `env` block of its registration instead, see +[Registering a server](mcp.md#registering-a-server). Every variable is listed +under [Configuration](configuration.md). + +## 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`, `titles`, `content`, `entities`, `sparql` | +| `slot` | `list`, `get`, `set` | +| `schema` | `get` | +| `instances` | `list`, `status` | +| `ledger` | `path` | +| top level | `status` | + +`osw search entities` finds pages in the wiki that are instances of a +category, while `osw instances` is about the OSL servers this process can +connect to. + +`osw instances list` lists the iris the process can connect to: the +env-configured domain plus every entry of a configured credential file. + +`osw instances status` reports the same instances in more detail. For each +one it prints the iri, whether it is the active one, the username that would +be used, and whether a connection succeeded. It never prints passwords. The +instances are contacted one after another and a single attempt has no +timeout, so an unreachable instance delays the command until its connection +attempt gives up. + +Global options apply to every command. They belong to the `osw` command +itself, so they come before the subcommand, the same way `git` and `docker` +options do: `osw --instance status`, not `osw status --instance `. +Typing them after the subcommand now produces an error that names the correct +form. + +- `--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, and + adds the env-file line to the source report described under + [Where settings come from](configuration.md#where-settings-come-from). + +Failures exit non-zero with a short message on stderr and no traceback. diff --git a/docs/tools/configuration.md b/docs/tools/configuration.md new file mode 100644 index 00000000..989b6c7b --- /dev/null +++ b/docs/tools/configuration.md @@ -0,0 +1,125 @@ +# Configuration + +Both adapters share the settings below. + +## Where settings come from + +Settings are read from the process environment. A `.env` file fills that +environment; a real environment variable wins over the same name in a file. + +**Env file** + +| `OSW_ENV_FILE` | CLI | MCP server | +| --- | --- | --- | +| set | loads that file, searches nowhere | loads that file, searches nowhere | +| unset | searches upward from the working directory | searches nowhere | + +**Credential file.** The first step that produces a file wins: + +1. `OSW_CRED_FILEPATH` or an alias, set in the environment or the env file. + The run fails if this file has no entry for `OSW_DOMAIN`. That check is + skipped when `OSW_USERNAME` and `OSW_PASSWORD` are both set. +2. CLI only: `accounts.pwd.yaml` in the working directory. Parent directories + are not searched. This step is skipped when `OSW_USERNAME` or + `OSW_PASSWORD` is set. If the file has no entry for `OSW_DOMAIN` it is + ignored and the run continues. +3. No credential file. + +**Source report.** Both adapters write to stderr before connecting. The first +line is labelled `credential file` when a file was found: + +- ` (from the OSW_CRED_FILEPATH environment variable)` +- ` (from OSW_CRED_FILEPATH in the env file)` +- ` (accounts.pwd.yaml found in the working directory)` +- ` (accounts.pwd.yaml found in the working directory, ignored: no entry for domain '')` + +and `credentials` when none was: + +- `OSW_USERNAME/OSW_PASSWORD (from the environment)` +- `OSW_USERNAME/OSW_PASSWORD (from the env file)` +- `not configured (set OSW_CRED_FILEPATH, or OSW_USERNAME/OSW_PASSWORD)` + +The second line is labelled `env file`. Which lines appear depends on the +adapter: + +- **CLI**: the first line only. `--verbose`, or a command that fails, adds + the second. +- **MCP server**: neither, since its sources are fixed in the server entry. + `OSW_VERBOSE=true` prints both, and a failed start prints both regardless. + +A verbose run of the CLI prints: + +```text +[osw] credential file: /home/me/project/accounts.pwd.yaml (accounts.pwd.yaml found in the working directory) +[osw] env file : /home/me/project/.env (found from the working directory upward) +``` + +The prefix names the adapter that printed the line: `[osw]` for the CLI, +`[osw-mcp]` for the MCP server. This holds for every message the two share, +not only these two lines. + +## Where messages go + +The source report above is printed directly, because the adapter's own verbose +flag decides whether it appears, not the log level. + +Every other message the adapters produce goes to the `osw` logger, together +with the records of the library itself. A failed connection check and an +unreadable provenance ledger are reported that way. `OSW_LOG_LEVEL` sets how +much of it appears, and an application that configures logging itself takes the +records over. See [Logging](../get-started.md#logging). + +Both kinds of message are written to stderr, never to stdout. The MCP server +speaks JSON-RPC over stdout, and the CLI writes its `--json` output there, so +stdout has to stay free. + +## Credentials + +Keep credentials in a gitignored file. They are read once per process, into that +process only, and never written back to disk. Set either `OSW_USERNAME` and +`OSW_PASSWORD`, or `OSW_CRED_FILEPATH`. + +A credential file uses the YAML format osw's `CredentialManager` reads, keyed +by iri (default file name: `accounts.pwd.yaml`): + +```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 requires `osw --instance `. The MCP server +never selects one, see [One server per instance](mcp.md). + +## 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. A bare host (`wiki.example.org`) or a full URL (`https://wiki.example.org/w/`); the host is taken from either, and a value no host can be read from (`https://`, `/w/index.php`) is rejected at startup | +| `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 (falls back to `accounts.pwd.yaml` in the working directory, CLI only) | +| `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. Must be an absolute path; a leading `~` is expanded | +| `OSW_MAX_RESULTS` | `OSW_MCP_MAX_RESULTS` | Default result cap (100) | +| `OSW_MAX_CHARS` | `OSW_MCP_MAX_CHARS` | Result size cap in characters (100000) | +| `OSW_VERBOSE` | `OSW_MCP_VERBOSE` | `true` prints the configuration source report | + +## 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 +``` diff --git a/docs/tools/index.md b/docs/tools/index.md new file mode 100644 index 00000000..af7ac5df --- /dev/null +++ b/docs/tools/index.md @@ -0,0 +1,41 @@ +# 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 also part of `osw[all]`. The other extras are listed in the +[Get Started guide](../get-started.md#optional-extras). + +## In this section + +| Page | Contents | +| ---- | -------- | +| [CLI](cli.md) | The `.env` quick start, the full command reference, and the global flags | +| [MCP server](mcp.md) | The tool surface, the no-filesystem-access and one-server-per-instance rules, and how to register the server with a client such as Claude Code | +| [Configuration](configuration.md) | What both adapters share: where credentials and settings come from, and the full environment-variable reference | diff --git a/docs/tools/mcp.md b/docs/tools/mcp.md new file mode 100644 index 00000000..c022c234 --- /dev/null +++ b/docs/tools/mcp.md @@ -0,0 +1,185 @@ +# 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 / page titles / +page content), +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. + +## Quick install + +For Claude Code, one command registers the server. Replace the domain and the +credential file path with your own: + +```bash +claude mcp add osw-dev \ + -e OSW_DOMAIN=wiki-dev.open-semantic-lab.org \ + -e OSW_CRED_FILEPATH=/abs/path/to/accounts.pwd.yaml \ + -- uvx --from "osw[mcp]" osw-mcp +``` + +Notes: + +- `osw-dev` is the server name and becomes the tool prefix, so every call site + reads `mcp__osw-dev__get_entity`. Pick one name per instance, e.g. `osw-prod`. +- `osw-mcp` is the program `uvx` runs. It is the console script this package + installs, so it does not change. +- On Windows, write the path with forward slashes. +- The default scope is `local`: this project, your machine only. Use `-s user` + for every project, or `-s project` to write a shared `.mcp.json`. +- List what is registered with `claude mcp list`. + +## 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](configuration.md#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`, so the +destination instance is visible in the entry itself. Never put `OSW_PASSWORD` +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`. If it does not, the server stops and names the iris the file does +contain, never their secrets. + +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`. + +## Design notes + +Why the MCP server is shaped the way it is, and how that differs from the CLI: + +- **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. +- **`mcp` is an extra, not a base dependency.** The SDK pulls in a server stack + (starlette, uvicorn, sse-starlette) that nothing in the Python API or the CLI + needs, so only users who actually run the server pay for it. + +## 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/pyproject.toml b/pyproject.toml index c52db786..4412aaaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,16 @@ 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", + # typer is built on click, and osw.cli.main subclasses click's Command and + # Group to report a misplaced root option. That is a direct import, so it + # is declared here rather than relied on through typer. + "click", + # .env loading (osw.service.config). Also a base dependency: without it the + # CLI silently cannot find a .env file and reports missing credentials + "python-dotenv>=1.0", ] [project.urls] @@ -73,6 +83,11 @@ UI = [ # The LGPLv3 line resumed at version 6, which the GUI examples target. "pysimplegui>=6", ] +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", +] workflow = [ "prefect>=2.20.25,<3.0", # prefect 2.20.25 is the final 2.x release (no backports). Its @@ -85,7 +100,17 @@ workflow = [ "anyio>=4.9,<4.14", ] tutorial = ["osw[dataimport]"] -all = ["osw[dataimport,DB,UI,S3,wikitext]"] +all = ["osw[dataimport,DB,UI,S3,wikitext,mcp]"] + +[project.scripts] +# command-line access to a live OSL instance, built from the same +# osw.service.registry the MCP server uses. Goes through osw_entry so the +# import-time notice about osw's logging is suppressed for this entry point. +osw = "osw_entry:cli" +# stdio MCP server exposing a live OSL instance to MCP clients (e.g. Claude +# Code). Goes through osw_entry so the import-time notice about osw's +# logging is suppressed for this entry point. +osw-mcp = "osw_entry:mcp" [build-system] requires = ["hatchling"] @@ -99,8 +124,10 @@ dev = [ "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]", + # 3.x in CI, whose server API breaks the prefect-2.20-targeted tests. + # mcp is included so the MCP server and its tests run in the same + # environment as everything else. + "osw[workflow,mcp]", "geopy", "deepl", "sqlalchemy", @@ -129,7 +156,9 @@ dev = [ ] [tool.hatch.build.targets.wheel] -packages = ["src/osw"] +# osw_entry.py must be shipped too: both console scripts point at it. +only-include = ["src/osw", "src/osw_entry.py"] +sources = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] @@ -306,6 +335,9 @@ 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, src/osw/service and src/osw/cli are checked: the mcp extra is +# part of the dev group, so the SDK imports resolve. exclude = [ "src/osw/model/entity.py", "examples", @@ -344,11 +376,14 @@ opensemantic-core = "opensemantic" opensemantic-base = "opensemantic" pybars3-wheel = "pybars" "backports.strenum" = "backports" -# extras packages not installed in the dev env, mapped explicitly so -# deptry does not have to guess ("Assuming ..." warnings) +# extras packages whose import name deptry would otherwise have to guess +# ("Assuming ..." warnings) 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 00000000..0a237f9f --- /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 00000000..997683b2 --- /dev/null +++ b/src/osw/cli/main.py @@ -0,0 +1,314 @@ +"""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 +import sys +from typing import Any, Optional, get_type_hints + +import click +import typer +from typer.core import TyperCommand, TyperGroup + +# 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) + + +def _force_utf8_output() -> None: + """Encode stdout and stderr as UTF-8, whatever the locale asks for. + + Python encodes a redirected stream with the locale encoding, which on a + German Windows system is cp1252. A non-ASCII label then reaches the + consumer as bytes no JSON parser can read, and a character cp1252 has no + code point for -- Japanese, Greek, Cyrillic -- raises UnicodeEncodeError + and ends the command. A Windows console stream is UTF-8 already, so on + Windows only redirected output changes. Elsewhere a terminal uses the + locale encoding, so this overrides a deliberate non-UTF-8 LANG or + PYTHONIOENCODING too. stderr is covered as well as stdout, because + ``Context.guard`` sends captured stdout to stderr under ``--json``. + + Called from the app callback, so it covers every command. Click prints + help and rejects an unknown root-level name before any callback runs, so + those paths keep the locale encoding. They carry no wiki content: every + help string in this package is ASCII (held by a test), and rich + substitutes its box-drawing characters once the stream is not UTF-8. What + stays exposed is the name the user typed, echoed back in a usage error -- + an unknown command name or an unknown root option name. A name typed + after the command is fine, because click resolves the command, runs this + callback, and only then parses the command's own arguments. + """ + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + errors = getattr(stream, "errors", None) + # A stream a test harness or host application substituted may have + # neither, and then decides its own encoding. Both are required: + # errors= must be passed, because reconfigure() silently resets the + # handler to strict otherwise, which would let stderr raise while + # reporting a failure. Passing errors=None does exactly that too. + if reconfigure is not None and errors is not None: + reconfigure(encoding="utf-8", errors=errors) + + +@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. + """ + # Set first, before a call that can raise: set_log_prefix never raises, so + # the prefix is always correct for any message printed on the way out, + # including one printed while handling set_env_file_discovery's error. + config.set_log_prefix("osw") + # Before any output, including the configuration banner. + _force_utf8_output() + # 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, + } + + +# These belong to ``osw`` itself (the root callback above) and, like git and +# docker, must come before the command name; typer/click reject them after +# it. The mapping and the classes below turn that rejection into a message +# that names the correct form instead of a bare "No such option". +_ROOT_OPTIONS = { + "--instance": "--instance ", + "--json": "--json", + "-j": "-j", + "--read-only": "--read-only", + "--verbose": "--verbose", + "-v": "-v", +} + + +def _root_option_hint(ctx, exc): + """Turn a root option typed after the command into an actionable error. + + Returns ``exc`` unchanged when it does not name one of ``_ROOT_OPTIONS``, + and also when click already found a close match on the command itself: + ``osw entity put --json ...`` is a misspelling of that command's own + ``--jsondata``, and click's "Did you mean" is the better message there. + """ + usage = _ROOT_OPTIONS.get(exc.option_name) + if usage is None or exc.possibilities: + return exc + prog = ctx.command_path.split()[0] + rest = " ".join(ctx.command_path.split()[1:]) + return click.NoSuchOption( + exc.option_name, + message=( + f"No such option: {exc.option_name}. It is an option of " + f"'{prog}', not of '{ctx.command_path}', so it has to come " + f"before the command: {prog} {usage} {rest}" + ), + ctx=ctx, + ) + + +class _RootOptionHintCommand(TyperCommand): + """A command whose unknown-option errors get the root-option hint.""" + + def parse_args(self, ctx, args): + try: + return super().parse_args(ctx, args) + except click.NoSuchOption as exc: + raise _root_option_hint(ctx, exc) from None + + +class _RootOptionHintGroup(TyperGroup): + """A group whose unknown-option errors get the root-option hint.""" + + def parse_args(self, ctx, args): + try: + return super().parse_args(ctx, args) + except click.NoSuchOption as exc: + raise _root_option_hint(ctx, exc) from None + + +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: + verbose = bool(opts.get("verbose")) + # Before anything that can fail, so every error still reports which + # files were read. --instance is validated against the credential file + # this names, so the banner belongs above that check too. The env-file + # line is suppressed unless the command is verbose or fails. + config.log_config_sources(verbose=verbose) + + instance = opts.get("instance") + if instance: + try: + config.set_active_instance(instance) + except ValueError as exc: + raise errors.UnknownInstance(str(exc)) from exc + + 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: + if not verbose: + config.log_env_file_source() + typer.echo(f"{exc.type}: {exc}", err=True) + raise typer.Exit(exc.exit_code) + except Exception as exc: + if opts.get("verbose"): + raise + # A failing command still reports every source, even non-verbosely. + config.log_env_file_source() + 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.", + "instances": "The OSL instances this process can connect to: list them, " + "or check each one.", + "ledger": "The local provenance ledger of pages written from here.", + "schema": "Category JSON Schemas.", + "search": "Find pages. OSW pages are titled by OSW-ID, so use 'ask' " + "to search by name.", + "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, cls=_RootOptionHintCommand)(_command) + else: + _sub = _groups.get(_op.group) + if _sub is None: + _sub = typer.Typer(cls=_RootOptionHintGroup) + _groups[_op.group] = _sub + app.add_typer(_sub, name=_op.group, help=_GROUP_HELP.get(_op.group)) + _sub.command(name=_op.command, cls=_RootOptionHintCommand)(_command) + + +if __name__ == "__main__": + app() diff --git a/src/osw/cli/ops.py b/src/osw/cli/ops.py new file mode 100644 index 00000000..0b7d502e --- /dev/null +++ b/src/osw/cli/ops.py @@ -0,0 +1,241 @@ +"""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 logging +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 + +_logger = logging.getLogger(__name__) + + +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="instances", + 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(), + } + + +def _close_quietly(connection, iri: str) -> None: + """Close a throwaway connection, reporting a failure without raising. + + Mirrors :meth:`osw.service.context.Context.reset`. Failing to close a + connection says nothing about whether it was reachable, so it must not + turn a successful check into a reported connection error. + """ + try: + connection.close_connection() + except Exception as exc: + _logger.warning(f"[osw] error closing connection to {iri}: {exc!r}") + + +@operation( + group="instances", + cli_name="status", + surfaces=frozenset({"cli"}), + read_only_hint=True, + idempotent_hint=True, +) +def status_instances(ctx: Context) -> dict: + """Report connection status for every configured OSL instance. + + Iterates every iri from :func:`osw.service.config.available_iris` (the + env-configured domain plus every iri in a configured credential file) and + checks each one independently, so a user with several endpoints in a + credential file can check them all in one command. One failing instance + does not stop the check of the others. Never returns passwords, or any + other credential value. + + The instances are checked one after another and a single check has no + timeout (see the probe in :mod:`osw.express`), so an unreachable instance + holds the command up for as long as its connection attempt takes. + """ + iris = config.available_iris() + if not iris: + return { + "instances": [], + "count": 0, + "message": ( + "No OSL instance configured. 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 ." + ), + } + active_iri = config.get_active_iri() + instances = [] + for iri in iris: + entry = { + "iri": iri, + "active": iri == active_iri, + "username": None, + "connected": False, + } + # Everything that can fail for one instance stays inside this try, so + # the remaining instances are still checked. + try: + entry["username"] = config.get_credentials_for(iri)[0] + with ctx.guard(): + # Not ctx.osw: that one is cached and bound to the active + # instance. These connections are ours alone, so we close each + # one instead of leaving it open for the rest of the command. + connection = ctx.osw_for(config.derive_domain(iri)) + entry["connected"] = True + _close_quietly(connection, iri) + except Exception as exc: + entry["error"] = str(exc) + instances.append(entry) + return {"instances": instances, "count": len(instances)} diff --git a/src/osw/cli/render.py b/src/osw/cli/render.py new file mode 100644 index 00000000..5787f222 --- /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 00000000..1b6e98f3 --- /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`` dependency 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 00000000..eef6a183 --- /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 00000000..b2b45e7e --- /dev/null +++ b/src/osw/mcp/server.py @@ -0,0 +1,185 @@ +"""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 io +import sys +from typing import Any, Optional, TextIO + +from mcp.server import MCPServer +from mcp.types import ToolAnnotations + +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(report: Optional[TextIO] = None) -> 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. + + ``report`` receives the configuration source lines instead of stderr, so + the caller decides whether to show them. With no ``report`` they are + discarded. + """ + # Set before any shared-code logging runs, so every "[xxx] ..." message + # and wiki edit comment from shared code names this adapter. Also set as + # the first statement of main(), since main() prints on a start failure + # and this function is also callable on its own, e.g. from tests. + config.set_log_prefix("osw-mcp") + # Before get_settings(), so a misconfiguration that makes loading raise + # still reports which files were read. Into `report` rather than stderr: + # these lines repeat the client's own server entry, so main() shows them + # only when asked or when the start fails. + config.log_config_sources(stream=report if report is not None else io.StringIO()) + 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.""" + # See _build_server for why this is set here too. + config.set_log_prefix("osw-mcp") + report = io.StringIO() + try: + mcp, ctx = _build_server(report) + except Exception as exc: + # A failed start is when the configuration sources matter most, so + # they are printed even with OSW_VERBOSE unset. + sys.stderr.write(report.getvalue()) + # a fatal startup message stays a print, not a log record: setting + # OSW_LOG_LEVEL=OFF must not make the server fail silently. + print(f"[osw-mcp] failed to start: {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + + if config.get_settings().verbose: + sys.stderr.write(report.getvalue()) + sys.stderr.flush() + + 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 00000000..33dac855 --- /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 00000000..52397d42 --- /dev/null +++ b/src/osw/service/config.py @@ -0,0 +1,895 @@ +"""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 pathlib import Path +from typing import Optional +from urllib.parse import urlparse + +import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + +from osw.auth import CredentialManager +from osw.defaults import CRED_FILENAME_DEFAULT + +# 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") +ENV_VERBOSE = ("OSW_VERBOSE", "OSW_MCP_VERBOSE") + + +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 + + +# Which env variable tuple feeds each Settings field, used to name the offending +# variable when pydantic rejects a value. +_ENV_BY_FIELD: dict[str, tuple[str, ...]] = { + "domain": ENV_DOMAIN, + "username": ENV_USERNAME, + "password": ENV_PASSWORD, + "cred_filepath": ENV_CRED_FILEPATH, + "sparql_endpoint": ENV_SPARQL_ENDPOINT, + "read_only": ENV_READ_ONLY, + "state_dir": ENV_STATE_DIR, + "max_results": ENV_MAX_RESULTS, + "max_chars": ENV_MAX_CHARS, + "verbose": ENV_VERBOSE, +} + + +def _env_name_for(field_name: str) -> str: + """Name the env variable that actually supplied ``field_name``. + + Falls back to the canonical name so the operator always gets something + actionable to fix. + """ + names = _ENV_BY_FIELD.get(field_name, ()) + if not names: + return field_name + for name in names: + if os.getenv(name): + return name + return names[0] + + +class Settings(BaseModel): + """Resolved, validated server settings.""" + + model_config = ConfigDict(frozen=True) + + # 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 = Field(default=100, gt=0) + max_chars: int = Field(default=100_000, gt=0) + # Only controls the startup configuration report. No tool or command + # reads it, and Settings.redacted() deliberately does not expose it. + verbose: bool = False + + @field_validator("domain") + @classmethod + def _validate_domain(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return value + if not value.strip(): + raise ValueError("must not be empty or whitespace-only") + if any(char.isspace() for char in value): + raise ValueError("must not contain whitespace") + if any(ord(char) < 32 for char in value): + raise ValueError("must not contain control characters") + # A bare host and a full URL are both legal here, so the check is on + # what _derive_domain makes of the value, not on its form. + # OswExpress.validate_domain rejects a hostless value too, but only on + # the first connection, and its message quotes a regex rather than + # naming the variable the operator has to correct. + if not _derive_domain(value): + raise ValueError( + "must contain a host name (e.g. 'wiki.example.org' or " + "'https://wiki.example.org/w/')" + ) + return value + + @field_validator("sparql_endpoint") + @classmethod + def _validate_sparql_endpoint(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return value + parsed = urlparse(value) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise ValueError( + "must be a valid http(s) URL (e.g. 'https://wiki.example.org/sparql')" + ) + return value + + @field_validator("state_dir") + @classmethod + def _validate_state_dir(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return value + if not value.strip(): + raise ValueError("must not be empty or whitespace-only") + # The only validator here that rewrites its value. Ledger builds its + # file as Path(state_dir) / ... and never expands a leading ~, so + # "~/osw" used to create a directory literally named "~". + if value.startswith("~"): + try: + value = str(Path(value).expanduser()) + except RuntimeError as exc: + raise ValueError( + f"starts with '~' but the home directory cannot be " + f"determined ({exc})" + ) from exc + if not Path(value).is_absolute(): + raise ValueError( + "must be an absolute path: a relative one resolves against the " + "working directory, which for osw-mcp is chosen by the client" + ) + return value + + @field_validator("cred_filepath") + @classmethod + def _validate_cred_filepath(cls, value: Optional[str]) -> Optional[str]: + # Control characters are deliberately not rejected here: load() already + # produces a much better, hint-carrying error for that case via + # _escape_hint(), and that check runs before Settings is constructed. + if value is None: + return value + if not value.strip(): + raise ValueError("must not be empty or whitespace-only") + return value + + 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 _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" + +# Names that a .env file actually introduced (as opposed to names that were +# already set in the real environment and therefore left untouched, since +# load_dotenv() defaults to override=False). Used to tell the two apart in +# the startup banner. Accumulates across calls within a process, so a +# repeated _load_env_file() call cannot erase an earlier call's attribution; +# reset() clears it. +_env_file_supplied: set[str] = set() + +# Where the credential file actually came from, for the startup banner. One +# of "environment", "env file", "default" (the accounts.pwd.yaml fallback), +# "none" (searched, nothing found) or "not searched". See _resolve_cred_file(). +_cred_file_path: Optional[str] = None +_cred_file_origin: str = "not searched" +_cred_file_var: Optional[str] = None + +# The adapter name every "[name] ..." message this module (and the rest of +# osw.service) prints. "osw" is the default, covering a process that embeds +# osw.service directly, without going through either adapter. +_LOG_PREFIX = "osw" + + +def set_log_prefix(name: str) -> None: + """Set the adapter name used in every "[name] ..." message osw.service prints. + + Call this once at process startup, before any of this module's logging + functions run: ``osw.cli.main`` passes ``"osw"`` and ``osw.mcp.server`` + passes ``"osw-mcp"``. The default ``"osw"`` already covers a process that + embeds ``osw.service`` directly, without going through either adapter, so + such a process need not call this at all. + """ + global _LOG_PREFIX + _LOG_PREFIX = name + + +def log_prefix() -> str: + """Return the current adapter name, formatted as ``"[name]"``. + + Reads ``_LOG_PREFIX`` at call time rather than at import time: a module + that captured it once into a module-level constant would keep printing + the default prefix forever, even for an adapter that calls + :func:`set_log_prefix` before that constant is ever read. + """ + return f"[{_LOG_PREFIX}]" + + +def set_env_file_discovery(enabled: bool) -> None: + """Enable or disable implicit discovery (default: disabled). + + This governs two searches of the current working directory, both skipped + when disabled: the implicit ``.env`` search, and the ``accounts.pwd.yaml`` + credential file fallback in :func:`_resolve_cred_file`. Both searches + depend on the working directory the process happens to run in, so both + are gated by the same flag: the CLI's working directory is the one the + user typed the command in, while the MCP server's is chosen by the MCP + client, which it does not control. + + 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 a base dependency, so it is normally present. It is still + imported defensively, for an environment that stripped it: 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``. + + Also records, in ``_env_file_supplied``, every environment variable name + that a ``.env`` file has introduced into this process (as opposed to + names already set in the real environment, which ``load_dotenv()``'s + default ``override=False`` leaves untouched). The set accumulates across + calls, so a repeated call within the same process cannot erase an + earlier call's attribution; :func:`reset` clears it. + :func:`_resolve_cred_file` uses it to report whether a resolved + credential path came from the file or from the real environment. + """ + global _env_file_path, _env_file_origin, _env_file_supplied + 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 python-dotenv (a dependency of osw) to use an env file." + ) + if path: + before = set(os.environ) + dotenv.load_dotenv(path) + _env_file_supplied |= set(os.environ) - before + _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 + before = set(os.environ) + dotenv.load_dotenv(found) + _env_file_supplied |= set(os.environ) - before + _env_file_path, _env_file_origin = found, "discovered" + + +def _resolve_cred_file() -> Optional[str]: + """Resolve the credential file path, in this order, and record its origin. + + 1. An explicitly configured ``OSW_CRED_FILEPATH`` (or its + ``OSW_MCP_CRED_FILEPATH`` / ``OSL_CRED_FILEPATH`` aliases) always wins, + whether it came from the real environment or from a ``.env`` file. + Existence is not checked here; ``load()`` already reports a missing + configured file with a specific error message. + 2. Otherwise, if implicit discovery is disabled (the MCP server; see + :func:`set_env_file_discovery`), nothing is resolved: origin + ``"not searched"``. + 3. Otherwise, if either ``OSW_USERNAME`` or ``OSW_PASSWORD`` (or their + ``OSL_*`` aliases) is already configured, nothing is resolved either: + also ``"not searched"``. Any explicitly named credential means the + operator intends to authenticate that way; a half-configured pair + must produce a visible "missing variable" error rather than silently + switch to a different identity via the fallback file. + 4. Otherwise, look for ``accounts.pwd.yaml`` in the current working + directory (no walk to parent directories). If present, and a domain is + configured, verify that the file has an entry for it + (:func:`_verify_cred_file_has_domain`): if that fails, origin + ``"rejected"`` and ``None`` is returned, but the path is kept in + ``_cred_file_path`` so callers can still report which file was + ignored. This is not fatal, unlike the same check for an explicitly + configured file: the operator never named this file, they only + happened to have one lying around, and :func:`load` accepts + ``strict=False`` precisely so a status command can report "not + configured" instead of crashing. If the file matches (or no domain is + configured yet to check against), origin ``"default"``. If no such + file exists, origin ``"none"``. + + So ``"not searched"`` has two distinct causes: discovery disabled, or + username/password already configured. Safe to call more than once, like + :func:`_load_env_file`, which this assumes has already run. + """ + global _cred_file_path, _cred_file_origin, _cred_file_var + path = _first_env(ENV_CRED_FILEPATH) + if path: + _cred_file_var = next( + (n for n in ENV_CRED_FILEPATH if os.getenv(n) == path), ENV_CRED_FILEPATH[0] + ) + _cred_file_origin = ( + "env file" if _cred_file_var in _env_file_supplied else "environment" + ) + _cred_file_path = path + return path + if not _discover_env_file: + _cred_file_path, _cred_file_origin, _cred_file_var = None, "not searched", None + return None + if _first_env(ENV_USERNAME) or _first_env(ENV_PASSWORD): + _cred_file_path, _cred_file_origin, _cred_file_var = None, "not searched", None + return None + candidate = Path.cwd() / CRED_FILENAME_DEFAULT + if candidate.is_file(): + domain = _first_env(ENV_DOMAIN) + if domain: + try: + _verify_cred_file_has_domain(str(candidate), domain) + except RuntimeError: + _cred_file_path, _cred_file_origin, _cred_file_var = ( + str(candidate), + "rejected", + None, + ) + return None + _cred_file_path, _cred_file_origin, _cred_file_var = ( + str(candidate), + "default", + None, + ) + return str(candidate) + _cred_file_path, _cred_file_origin, _cred_file_var = None, "none", None + return None + + +def _describe_env_file() -> str: + """Describe where the ``.env`` file came from, for the startup banner.""" + return { + "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] + + +def log_config_sources(stream=None, verbose: bool = True) -> None: + """Print where configuration was read from, to stderr. + + 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. + + The credential line is printed first, since it is the one line that + still appears when ``verbose`` is ``False``; the env-file line is then + printed after it, only when ``verbose`` is ``True``. This keeps the two + lines in the same relative order regardless of ``verbose``, including in + the CLI's failure path, which prints the env-file line (via + :func:`log_env_file_source`) after this one has already run. + + ``verbose`` defaults to ``True``, which keeps both lines. The CLI passes + ``verbose=False`` for a successful, non-verbose command, and prints the + env-file line separately (via :func:`log_env_file_source`) on failure or + when the user passed ``--verbose``. The MCP server writes both lines to a + buffer instead of stderr and shows them only when ``OSW_VERBOSE`` is set + or the server fails to start. + + Writes to ``stderr``, or to ``stream`` when one is given, and never to + ``stdout``: under MCP ``stdout`` carries the JSON-RPC stream, and under + ``osw --json`` it carries the result payload. + """ + _load_env_file() + # The configuration source report stays a print rather than a log record: + # it is an aligned report written to a caller-chosen stream, and the running + # adapter's own verbose flag decides whether it appears, not the log level. + # Every print below flushes. sys.stderr is line-buffered on the supported + # Python versions, but these go to whichever stream the caller passed, and + # that one need not be: the MCP server passes an io.StringIO, and click's + # CliRunner replaces sys.stderr with a wrapper it never flushes. The lines + # must appear before the command's own output and before any error. + out = sys.stderr if stream is None else stream + _resolve_cred_file() + if _cred_file_path: + cred_described = { + "environment": f"(from the {_cred_file_var} environment variable)", + "env file": f"(from {_cred_file_var} in the env file)", + "default": f"({CRED_FILENAME_DEFAULT} found in the working directory)", + "rejected": ( + f"({CRED_FILENAME_DEFAULT} found in the working directory, " + f"ignored: no entry for domain '{_first_env(ENV_DOMAIN)}')" + ), + }[_cred_file_origin] + print( + f"{log_prefix()} credential file: {_cred_file_path} {cred_described}", + file=out, + flush=True, + ) + else: + username = _first_env(ENV_USERNAME) + password = _first_env(ENV_PASSWORD) + if username or password: + username_name = next((n for n in ENV_USERNAME if os.getenv(n)), None) + password_name = next((n for n in ENV_PASSWORD if os.getenv(n)), None) + from_env_file = ( + username_name in _env_file_supplied + or password_name in _env_file_supplied + ) + source = "from the env file" if from_env_file else "from the environment" + cred_described = f"OSW_USERNAME/OSW_PASSWORD ({source})" + else: + cred_described = ( + "not configured (set OSW_CRED_FILEPATH, or OSW_USERNAME/OSW_PASSWORD)" + ) + print(f"{log_prefix()} credentials : {cred_described}", file=out, flush=True) + if verbose: + print( + f"{log_prefix()} env file : {_describe_env_file()}", + file=out, + flush=True, + ) + + +def log_env_file_source(stream=None) -> None: + """Print only the env-file line, for the CLI's failure path. + + Does *not* call :func:`_load_env_file`, so it is safe to call after + :func:`load` has already raised: prints the line that + ``log_config_sources(verbose=False)`` suppressed, using whatever + ``_env_file_origin`` that earlier call already recorded. + """ + out = sys.stderr if stream is None else stream + # a print for the same reason as log_config_sources above: this is one line + # of that same report. + print( + f"{log_prefix()} env file : {_describe_env_file()}", file=out, flush=True + ) + + +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. When no credential + file is configured, no username/password is set, and implicit discovery + is enabled, an ``accounts.pwd.yaml`` file in the current working directory + is used instead (see :func:`_resolve_cred_file`); this fallback never + fires for the MCP server. + + 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 environment variable holding a value the + settings model rejects (an unparseable or non-positive integer, a + malformed SPARQL endpoint URL, a domain containing whitespace), 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 = _resolve_cred_file() + + 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 discovered file (origin "default") was already checked against the + # domain inside _resolve_cred_file: a mismatch set origin "rejected" and + # cleared cred_filepath there, so cred_file_usable is already False in + # that case below. An explicitly configured file is different: the + # operator named it, so a mismatch is still a hard error, unconditionally. + discovered_file_discarded = _cred_file_origin == "rejected" + if cred_file_usable and domain and _cred_file_origin != "default": + _verify_cred_file_has_domain(cred_filepath, domain) + + # 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: + if discovered_file_discarded: + fallback_hint = ( + " A file named 'accounts.pwd.yaml' was found in the current " + f"working directory, but it has no entry for domain '{domain}'." + ) + elif _discover_env_file: + fallback_hint = ( + " Or place an 'accounts.pwd.yaml' file in the current working " + "directory." + ) + else: + fallback_hint = "" + # The stdio-hang rationale only applies to the MCP server: a CLI user + # is not running a stdio server, so it would only confuse them. + stdio_hint = ( + "" + if _discover_env_file + else ( + " The server refuses to start without them to avoid an " + "interactive credential prompt that would hang the stdio " + "transport." + ) + ) + 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)." + fallback_hint + stdio_hint + ) + + kwargs: dict = dict( + domain=domain, + username=username, + password=password, + cred_filepath=cred_filepath, + sparql_endpoint=_first_env(ENV_SPARQL_ENDPOINT), + state_dir=_first_env(ENV_STATE_DIR), + ) + # An unset or blank/whitespace-only variable falls back to the model + # default; pass the raw string only when there is one to validate. Letting + # pydantic parse read_only rather than testing membership in a truthy set + # matters because the default is fail-open: a typo like "ture" would + # otherwise silently leave writes enabled on a server meant to be read-only. + read_only_raw = _first_env(ENV_READ_ONLY) + if read_only_raw is not None and read_only_raw.strip(): + kwargs["read_only"] = read_only_raw + max_results_raw = _first_env(ENV_MAX_RESULTS) + if max_results_raw is not None and max_results_raw.strip(): + kwargs["max_results"] = max_results_raw + max_chars_raw = _first_env(ENV_MAX_CHARS) + if max_chars_raw is not None and max_chars_raw.strip(): + kwargs["max_chars"] = max_chars_raw + verbose_raw = _first_env(ENV_VERBOSE) + if verbose_raw is not None and verbose_raw.strip(): + kwargs["verbose"] = verbose_raw + + try: + return Settings(**kwargs) + except ValidationError as exc: + details = [] + for err in exc.errors(): + field_name = str(err["loc"][0]) if err["loc"] else "" + details.append( + f"{_env_name_for(field_name)}={err.get('input')!r}: {err['msg']}" + ) + raise RuntimeError("Invalid OSW configuration: " + "; ".join(details)) from exc + + +_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, _env_file_supplied + global _cred_file_path, _cred_file_origin, _cred_file_var + _settings = None + _active_iri = None + _active_resolved = False + _discover_env_file = False + _env_file_path = None + _env_file_origin = "not searched" + _env_file_supplied = set() + _cred_file_path = None + _cred_file_origin = "not searched" + _cred_file_var = None + + +# -- 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 derive_domain(iri: str) -> str: + """Return the bare domain of ``iri``, stripping a scheme and path if present.""" + 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_credentials_for(iri: str) -> tuple[Optional[str], Optional[str]]: + """Return the username/password to use for ``iri``. + + Resolution order: + + 1. If a credential file is configured, look up ``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() + if settings.cred_filepath and iri: + cred_mngr = CredentialManager(cred_filepath=settings.cred_filepath) + credential = cred_mngr.get_credential( + CredentialManager.CredentialConfig( + iri=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 + + +def get_active_credentials() -> tuple[Optional[str], Optional[str]]: + """Return the username/password to use for the currently active instance. + + See :func:`get_credentials_for` for the resolution order. 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 active_iri is None: + return settings.username, settings.password + return get_credentials_for(active_iri) diff --git a/src/osw/service/context.py b/src/osw/service/context.py new file mode 100644 index 00000000..5026891d --- /dev/null +++ b/src/osw/service/context.py @@ -0,0 +1,182 @@ +"""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 logging +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 + +_logger = logging.getLogger(__name__) + + +@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 + + def osw_for(self, domain: str) -> OswExpress: + """Build an ``OswExpress`` for ``domain``. + + Not cached and does not touch the active instance (see ``osw``): use + this for operations that connect to several instances in one call. + + 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.settings.cred_filepath: + cred_mngr = CredentialManager(cred_filepath=self.settings.cred_filepath) + return OswExpress(domain=domain, cred_mngr=cred_mngr) + return OswExpress(domain=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: + self._osw = self.osw_for(self._require_active_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: + _logger.warning( + f"{config.log_prefix()} error closing connection: {exc!r}" + ) + 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 00000000..3c8d5a16 --- /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 00000000..f60a0ea3 --- /dev/null +++ b/src/osw/service/ledger.py @@ -0,0 +1,159 @@ +"""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 logging +import os +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import List, Optional + +from pydantic import BaseModel + +from osw.service import config + +_logger = logging.getLogger(__name__) + +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. + _logger.warning( + f"{config.log_prefix()} ledger at {self.path} unreadable ({exc}); " + "starting a new one." + ) + 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 00000000..9c5c6444 --- /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 00000000..b93c78a7 --- /dev/null +++ b/src/osw/service/ops/entities.py @@ -0,0 +1,209 @@ +"""Entity operations: read entity JSON, export JSON-LD, create/update, delete.""" + +from __future__ import annotations + +import logging +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 + +_logger = logging.getLogger(__name__) + +_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: + _logger.warning( + f"{config.log_prefix()} deleting externally-created page " + f"'{title}' (confirm_external_delete=True)" + ) + page.delete(comment or f"{config.log_prefix()} 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 00000000..50f31af8 --- /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 00000000..363feb17 --- /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 00000000..7942c34f --- /dev/null +++ b/src/osw/service/ops/search.py @@ -0,0 +1,222 @@ +"""Search operations: semantic (SMW ask), titles, content, entities, 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.wiki_tools import get_query_limit +from osw.wtsite import WtSite + + +def _hit_limit(total: int, limit: Optional[int]) -> bool: + """Whether a result set is as large as the limit that produced it. + + The wiki applies the limit itself, so ``cap_list`` never has to cut these + results and its own flag stays False. A full result set is then the only + signal left that the wiki may hold further matches. ``limit=0`` asks for + no results, so meeting it says nothing about truncation. + """ + return bool(limit) and total >= limit + + +@operation( + group="search", + cli_name="ask", + read_only_hint=True, + idempotent_hint=True, +) +def search_ask(ctx: Context, ask_query: str, limit: Optional[int] = None) -> dict: + """Run a Semantic MediaWiki 'ask' query and return matching page titles. + + This is the only search that can find an entity by a property value, such + as its name. OSW pages are titled by OSW-ID, for example + ``Item:OSW7ec...``, so searching titles for a name finds nothing. + + The query uses SMW ask syntax. Examples: + + \b + [[Category:Item]] + [[Category:Item]][[Keyword::sensor]] + [[Category:Item]][[HasName::~*sensor*]] + + ``~`` starts a wildcard comparison and ``*`` matches any text. + + Which property holds a name depends on the schema. The shipped base + schema maps the JSON field ``label`` to ``Property:HasLabel`` and + ``name`` to ``Property:HasName``, and stored queries read the displayed + label as ``Display_title_of``. A category declares this mapping in the + ``@context`` of its schema, so read it with ``osw schema get`` when a + name query returns nothing. + + ``limit`` defaults to ``OSW_MAX_RESULTS`` (100 when that is unset). A + ``limit=N`` written into the query itself wins over it. + Returns ``{titles, count, truncated}``, where ``titles`` are full page + names, ``count`` is how many came back once hits whose page does not + exist were dropped, and ``truncated`` reports that further matches may + exist beyond them. + """ + lim = ctx.limit(limit) + titles = ctx.osw.site.semantic_search( + WtSite.SearchParam(query=ask_query, limit=lim) + ) + # semantic_search lets a 'limit=' written into the query win over `lim`, + # so the flag has to compare against the limit that reached the wiki. + # `titles` excludes hits whose page does not exist, so a result set + # thinned that way reads as not truncated. + query_limit = get_query_limit(ask_query) + effective_limit = lim if query_limit is None else query_limit + capped, total, truncated = cap_list(titles, lim) + return { + "titles": capped, + "count": total, + "truncated": truncated or _hit_limit(total, effective_limit), + } + + +@operation( + group="search", + cli_name="titles", + read_only_hint=True, + idempotent_hint=True, +) +def search_titles(ctx: Context, text: str, limit: Optional[int] = None) -> dict: + """Search page titles by prefix. This does not search page content. + + Matches pages whose title starts with ``text``, via the MediaWiki + ``prefixsearch`` API. OSW pages are titled by OSW-ID, for example + ``Item:OSW7ec...``, so an entity's name is not part of its title and + cannot be found here. Use ``osw search ask`` to search by name. + Use ``osw search content`` to search the text of pages. + + Useful for the titles that are readable: categories, properties, + templates and other schema pages. + + ``limit`` defaults to ``OSW_MAX_RESULTS`` (100 when that is unset). + Returns ``{titles, count, truncated}``, where ``titles`` are full page + names, ``count`` is how many the wiki returned and ``truncated`` reports + that further matches may exist beyond them. + """ + 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 or _hit_limit(total, lim), + } + + +@operation( + group="search", + cli_name="content", + read_only_hint=True, + idempotent_hint=True, +) +def search_content(ctx: Context, text: str, limit: Optional[int] = None) -> dict: + """Search the text content of pages for ``text``. + + Uses the MediaWiki ``search`` API, which reads page wikitext. On an OSW + instance an entity's values live in its ``jsondata`` slot, not in the + wikitext, so a stored value may not be reachable here; ``osw search ask`` + queries that data directly and is the better tool for it. + + Returns page titles, not the matching passages. ``limit`` defaults to + ``OSW_MAX_RESULTS`` (100 when that is unset). Returns + ``{titles, count, truncated}``, where ``titles`` are full page names, + ``count`` is how many the wiki returned and ``truncated`` reports that + further matches may exist beyond them. + """ + lim = ctx.limit(limit) + titles = ctx.osw.site.content_search(WtSite.SearchParam(query=text, limit=lim)) + capped, total, truncated = cap_list(titles, lim) + return { + "titles": capped, + "count": total, + "truncated": truncated or _hit_limit(total, lim), + } + + +@operation( + group="search", + cli_name="entities", + read_only_hint=True, + idempotent_hint=True, +) +def search_entities(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`` or + ``Category:OSW...``. This runs the ask query + ``[[HasType::]]``, so it lists the pages that declare + this exact category as their type. + + ``limit`` defaults to ``OSW_MAX_RESULTS`` (100 when that is unset). + Returns ``{titles, count, truncated}``, where ``titles`` are full page + names, ``count`` is how many the wiki returned and ``truncated`` reports + that further matches may exist beyond them. + """ + 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 or _hit_limit(total, lim), + } + + +@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. If neither is set the command fails. + + ``limit`` caps the returned bindings and defaults to 500. It is applied + to the response, not added to the query, so a large query still costs the + endpoint its full work. + + Returns ``{vars, bindings, count, truncated}``, where ``count`` is how + many bindings the endpoint returned. + """ + 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 00000000..ba29f75f --- /dev/null +++ b/src/osw/service/ops/slots.py @@ -0,0 +1,142 @@ +"""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 config, 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"{config.log_prefix()} 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 00000000..05aac553 --- /dev/null +++ b/src/osw/service/ops/status.py @@ -0,0 +1,68 @@ +"""Status / whoami operation: report connection and configuration (no secrets).""" + +from __future__ import annotations + +import logging + +from osw.service import config +from osw.service.context import Context +from osw.service.registry import operation + +_logger = logging.getLogger(__name__) + + +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, + } + # settings.username only reflects OSW_USERNAME/OSL_USERNAME, so it is None + # whenever the username comes from a credential file instead. Report what + # the connection will actually use: get_active_credentials applies the same + # precedence as the login path (credential file first, environment second), + # and it never prompts and never raises, so this is safe on the MCP stdio + # surface too. + if active_iri is not None: + info["username"] = config.get_active_credentials()[0] + 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: + _logger.warning( + f"{config.log_prefix()} status connection check failed: {exc!r}" + ) + 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 00000000..92eaf441 --- /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 00000000..5a0f52d4 --- /dev/null +++ b/src/osw/service/registry.py @@ -0,0 +1,201 @@ +"""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 logging +from typing import Any, Callable, Iterator, Literal, Optional, get_type_hints + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from osw.service import config +from osw.service.context import Context +from osw.service.errors import OpError +from osw.service.ledger import LedgerRecord + +_logger = logging.getLogger(__name__) + +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 + _logger.error(f"{config.log_prefix()} {op.name} failed: {exc!r}") + 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 00000000..780a1698 --- /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/src/osw/wiki_tools.py b/src/osw/wiki_tools.py index bcc8448e..3bf832f9 100644 --- a/src/osw/wiki_tools.py +++ b/src/osw/wiki_tools.py @@ -252,6 +252,70 @@ def prefix_search_(single_text) -> Union[List[str], dict]: # return page_list # original return +def content_search( + site: mwclient.client.Site, text: Union[str, List[str], SearchParam] +) -> Union[List[str], List[dict]]: + """Searches the content (wikitext) of pages. Equivalent to the following + mediawiki API call api.php?action=query&list=search&srsearch=Star Wars. + + See https://www.mediawiki.org/wiki/API:Search for details. + + Parameters + ---------- + site : + Site object from mwclient lib + text : + Query text or instance of SearchParam + + Returns + ------- + result: + With ``return_json=False`` (default): a flat list of page titles. With + ``return_json=True``: a list of raw MediaWiki ``search`` API response + dicts, one per query (always a list, even for a single query). + """ + if not isinstance(text, SearchParam): + query = SearchParam(query=text) + else: + query = text + + def content_search_(single_text) -> Union[List[str], dict]: + page_list = list() + result = site.api( + "query", + list="search", + srsearch=single_text, + srlimit=query.limit, + format="json", + ) + if query.debug and len(result["query"]["search"]) == 0: + print("No results") + if query.return_json: + return result + + for page in result["query"]["search"]: + title = page["title"] + if query.debug: + print(title) + page_list.append(title) + return page_list + + if query.parallel: + query_results = parallelize( + func=content_search_, iterable=query.query, flush_at_end=query.debug + ) + else: + query_results = [content_search_(single_text=sq) for sq in query.query] + + if query.return_json: + # Each entry of query_results is the raw API response dict for one query. + # Do not flatten dicts; always return the list of responses (one per query), + # even when only a single query was passed. + return query_results + + return [item for sublist in query_results for item in sublist] + + def _ask_results_as_dict(results: Union[dict, list]) -> dict: """Normalise the ``results`` payload of an SMW ``ask`` response to a mapping. diff --git a/src/osw/wtsite.py b/src/osw/wtsite.py index 822d533c..9071e4c8 100644 --- a/src/osw/wtsite.py +++ b/src/osw/wtsite.py @@ -627,6 +627,21 @@ def prefix_search(self, text: Union[str, SearchParam]): """ return wt.prefix_search(self._site, text) + @try_and_renew_token + def content_search(self, text: Union[str, SearchParam]): + """Send a search request for the text content of pages to the site. + + Parameters + ---------- + text + The search text or a SearchParam object + + Returns + ------- + A list of page titles + """ + return wt.content_search(self._site, text) + @try_and_renew_token def semantic_search(self, query: Union[str, SearchParam]): """Send a swm ask query to the site. diff --git a/src/osw_entry.py b/src/osw_entry.py new file mode 100644 index 00000000..ae41c65d --- /dev/null +++ b/src/osw_entry.py @@ -0,0 +1,53 @@ +"""Console-script shims for the ``osw`` and ``osw-mcp`` entry points. + +Importing :mod:`osw` writes a one-off notice to the ``osw`` logger, unless +``OSW_LOG_LEVEL`` is already set in the environment (see +``src/osw/__init__.py``). That notice is meant for library users, who are +told where osw's logging lives and how to change it. For the two console +scripts, osw is the application rather than a library, so the notice is +clutter, and this module sets the environment variable before osw is +imported to suppress it. Nothing inside the osw package can do this itself: +importing any of its submodules imports the package first, and the notice +has already been written by the time control reaches that submodule. So the +suppression has to happen here, outside the osw package, before osw enters +the picture at all. +""" + +from __future__ import annotations + +import os + +#: The level the shim sets. It is the name of osw's own DEFAULT_LOG_LEVEL, +#: written out rather than imported, because importing osw here would emit +#: the very notice this module exists to suppress. A test in +#: tests/test_osw_entry.py keeps the two in step. +_DEFAULT_LEVEL = "INFO" + + +def _suppress_import_notice(): + """Sets OSW_LOG_LEVEL before osw is imported, so its notice stays quiet + + Uses setdefault so a value the caller already set is left alone. The + value chosen is osw's own DEFAULT_LOG_LEVEL, so the log level stays + exactly what it is today; only the notice about it disappears. + """ + os.environ.setdefault("OSW_LOG_LEVEL", _DEFAULT_LEVEL) + + +def cli(): + _suppress_import_notice() + # Imported here, not at module level: a module-level import would run + # osw/__init__.py before _suppress_import_notice() had a chance to set + # the environment variable. + from osw.cli.main import app + + app() + + +def mcp(): + _suppress_import_notice() + # Imported here, not at module level, for the same reason as in cli() + # above: osw/__init__.py must not run before the variable is set. + from osw.mcp.server import main + + main() diff --git a/tests/conftest.py b/tests/conftest.py index 389a3466..64ab03a1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,11 +9,27 @@ import pytest +from osw.service import config + # Note: pytest_addoption lives in the repo-root conftest.py - since pytest 9.0 # it is not loaded from this subdirectory conftest via testpaths. The # option-backed fixtures below stay here. +@pytest.fixture(autouse=True) +def _restore_log_prefix(): + """Restore ``osw.service.config._LOG_PREFIX`` after each test. + + It is process-wide mutable state, set once by whichever adapter starts a + process (see ``config.set_log_prefix``). Without this, a test that sets + it directly, or indirectly by exercising the CLI or the MCP server, would + leak that choice into a later test that expects the default. + """ + original = config._LOG_PREFIX + yield + config._LOG_PREFIX = original + + @pytest.fixture(scope="session") def wiki_domain(request): value = request.config.option.wiki_domain diff --git a/tests/integration/test_express.py b/tests/integration/test_express.py index 2c803e53..55e92d8c 100644 --- a/tests/integration/test_express.py +++ b/tests/integration/test_express.py @@ -22,7 +22,6 @@ * test_upload_file """ -import os import uuid from contextlib import contextmanager from pathlib import Path @@ -102,11 +101,13 @@ def test_init_with_domain(wiki_domain, wiki_username, wiki_password, mocker): osw_express.shut_down() -def test_init_from_env_vars(wiki_domain, wiki_username, wiki_password): +def test_init_from_env_vars(monkeypatch, wiki_domain, wiki_username, wiki_password): + # monkeypatch, not os.environ: the file is unlinked at the end of this test, so + # a leaked OSW_CRED_FILEPATH would point every later test at a missing file. cred_filepath = Path.cwd() / "accounts.pwd.yaml" - os.environ["OSW_CRED_FILEPATH"] = str(cred_filepath) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_filepath)) create_credentials_file(cred_filepath, wiki_domain, wiki_username, wiki_password) - os.environ["OSW_DOMAIN"] = wiki_domain + monkeypatch.setenv("OSW_DOMAIN", wiki_domain) osw_express = osw.express.OswExpress() osw_express_and_credentials(osw_express, wiki_domain, wiki_username, wiki_password) diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py new file mode 100644 index 00000000..0c4a3613 --- /dev/null +++ b/tests/integration/test_mcp_server.py @@ -0,0 +1,88 @@ +"""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 + +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() + # This fixture stands in for the MCP server, which sets the prefix itself + # in src/osw/mcp/server.py. + config.set_log_prefix("osw-mcp") + + 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_ask"](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 + + # An ask query has no defined result order and a category can hold pages + # without a jsondata slot, so check every hit rather than trusting the first. + titles = found["titles"] + if titles: + with_jsondata = [] + for title in titles: + 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 + if any(s["key"] == "jsondata" for s in page_slots["slots"]): + with_jsondata.append(title) + assert with_jsondata, f"no jsondata slot on any of {titles}" + + +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 00000000..77fad5bb --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,866 @@ +"""Unit tests for the osw CLI (src/osw/cli). + +The CLI never imports the mcp SDK, and 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 +import logging +import re +import sys +from unittest.mock import MagicMock + +import click +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", + "OSW_VERBOSE", + "OSW_MCP_VERBOSE", +] + + +@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]`` configuration lines.""" + return [ + line for line in stderr.strip().splitlines() if not line.startswith("[osw] ") + ] + + +_ANSI = re.compile(r"\x1b\[[0-9;]*m") +_BOX = re.compile(r"[─-╿]") # the Box Drawing block, rich's panel + + +def _usage_error(result) -> str: + """``result``'s whole output as one line, without styling or box drawing. + + typer renders a usage error through rich, and two parts of that rendering + depend on the environment. typer forces colour on when GITHUB_ACTIONS is + set (typer/rich_utils.py), and rich wraps at the width of the real + terminal. Colour breaks an option name into separate escape sequences, + because rich styles the leading dash on its own, and wrapping breaks it + across two lines. Either one defeats a plain substring check, which is why + these assertions passed on a developer machine and failed in CI. Removing + the escape sequences and the panel borders, then joining the lines, leaves + the words the assertions are about. + """ + text = _ANSI.sub("", result.stdout + result.stderr) + return " ".join(_BOX.sub(" ", text).split()) + + +def _banner_lines(stderr: str) -> list[str]: + """The ``[osw]`` configuration lines of ``stderr``, in the order printed.""" + return [line for line in stderr.strip().splitlines() if 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 + + +# -- output encoding ------------------------------------------------------------ +# Redirected stdout on Windows is opened with the locale encoding, not UTF-8, so +# a German label used to reach the consumer as cp1252 bytes. CliRunner's charset +# gives the captured stream that same encoding, which reproduces the platform +# behaviour everywhere, so these run on Linux CI too. +_MISSING = object() # "do not set this attribute at all", distinct from None + + +@pytest.fixture +def cp1252_runner(): + return CliRunner(mix_stderr=False, charset="cp1252") + + +def _fake_osw_labelled(monkeypatch, label: str): + """Patch in an entity whose label slot holds ``label``.""" + fake_osw, page = _fake_osw_with_page() + page.get_slot_content.return_value = {"label": [{"text": label}]} + page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1" + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + +def test_json_output_is_utf8_when_stdout_uses_the_locale_encoding( + cp1252_runner, configured_env, monkeypatch +): + _fake_osw_labelled(monkeypatch, "Änderungen") + + result = cp1252_runner.invoke(app, ["--json", "entity", "get", "Item:OSW1"]) + + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout_bytes.decode("utf-8")) + assert payload["jsondata"]["label"][0]["text"] == "Änderungen" + + +def test_human_output_is_utf8_when_stdout_uses_the_locale_encoding( + cp1252_runner, configured_env, monkeypatch +): + _fake_osw_labelled(monkeypatch, "Änderungen") + + result = cp1252_runner.invoke(app, ["entity", "get", "Item:OSW1"]) + + assert result.exit_code == 0, result.stderr + assert "Änderungen" in result.stdout_bytes.decode("utf-8") + + +def test_error_message_is_utf8_when_stderr_uses_the_locale_encoding( + cp1252_runner, configured_env, monkeypatch +): + """An error names the page it failed on, so stderr carries labels too.""" + fake_osw, _page = _fake_osw_with_page(exists=False) + fake_osw.load_entity.return_value.entities = [] + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + result = cp1252_runner.invoke(app, ["--json", "entity", "export", "Item:Änderung"]) + + assert result.exit_code == 2 + assert "Item:Änderung" in result.stderr_bytes.decode("utf-8") + + +def test_forcing_utf8_keeps_the_error_handler_each_stream_was_given(monkeypatch): + """``reconfigure`` resets ``errors`` to strict unless it is passed as well. + + Python gives stderr ``backslashreplace`` precisely so that reporting a + failure cannot itself raise. Switching the encoding must not drop that. + """ + err = io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="backslashreplace") + monkeypatch.setattr(sys, "stderr", err) + monkeypatch.setattr( + sys, "stdout", io.TextIOWrapper(io.BytesIO(), encoding="cp1252") + ) + + cli_main._force_utf8_output() + + assert err.encoding == "utf-8" + err.write("\udc80") # a lone surrogate, which "strict" refuses to encode + err.flush() + assert err.buffer.getvalue() == rb"\udc80" + + +def test_every_help_string_is_ascii(): + """Guards the one gap ``_force_utf8_output`` cannot close. + + Click prints help and rejects an unknown name before any callback runs, + so those paths keep the locale encoding. That is only harmless while no + help string contains a character the locale encoding may lack. Adding a + German option description would make it a real defect, and this test is + what reports it. + """ + offenders = [] + + def walk(command, path): + texts = {"help": command.help, "short_help": command.short_help} + for param in command.params: + texts[f"--{param.name}"] = getattr(param, "help", None) + for where, text in texts.items(): + if text and not text.isascii(): + offenders.append(f"{' '.join(path) or 'osw'} {where}: {text!r}") + for name, sub in getattr(command, "commands", {}).items(): + walk(sub, [*path, name]) + + walk(typer.main.get_command(app), []) + + assert offenders == [] + + +def test_a_substituted_stream_with_no_usable_errors_value_is_left_alone(monkeypatch): + """Both halves of the guard are needed, not just the ``reconfigure`` half. + + A host application may put an object that is not a ``TextIOWrapper`` on + ``sys.stdout``. Reading ``.errors`` on one that lacks it raises, which + would end the command. A ``.errors`` of ``None`` is no better: passing it + on means ``strict``, the handler this function exists to preserve. + """ + + class Substituted: + def __init__(self, errors): + self.calls = [] + if errors is not _MISSING: + self.errors = errors + + def reconfigure(self, **kwargs): + self.calls.append(kwargs) + + without = Substituted(_MISSING) + none_valued = Substituted(None) + monkeypatch.setattr(sys, "stdout", without) + monkeypatch.setattr(sys, "stderr", none_valued) + + cli_main._force_utf8_output() + + assert without.calls == [] + assert none_valued.calls == [] + + +def test_a_log_handler_holding_stderr_writes_utf8_after_the_switch(monkeypatch): + """osw logs to ``sys.stderr``, and its handler is built at import time. + + ``logging.StreamHandler`` stores the stream object it was given, so the + handler osw attaches in ``enable_logging`` holds ``sys.stderr`` itself. + ``reconfigure`` changes that object in place rather than replacing it, + which is why an already attached handler writes UTF-8 too. Replacing + ``sys.stderr`` with a new object would leave the handler on the old one. + """ + err = io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="backslashreplace") + monkeypatch.setattr(sys, "stderr", err) + handler = logging.StreamHandler(sys.stderr) # as osw.enable_logging does + logger = logging.getLogger("test_utf8_handler") + logger.addHandler(handler) + monkeypatch.setattr( + sys, "stdout", io.TextIOWrapper(io.BytesIO(), encoding="cp1252") + ) + + cli_main._force_utf8_output() + logger.warning("Änderungen") + handler.flush() + + assert handler.stream is err + assert "Änderungen" in err.buffer.getvalue().decode("utf-8") + + +def test_label_the_locale_encoding_cannot_represent_is_written_not_raised( + cp1252_runner, configured_env, monkeypatch +): + """cp1252 has no Japanese characters, so encoding used to raise, not corrupt.""" + _fake_osw_labelled(monkeypatch, "文字") + + result = cp1252_runner.invoke(app, ["--json", "entity", "get", "Item:OSW1"]) + + assert result.exit_code == 0, result.exception or result.stderr + payload = json.loads(result.stdout_bytes.decode("utf-8")) + assert payload["jsondata"]["label"][0]["text"] == "文字" + + +# -- 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 + + +# -- instances 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, ["instances", "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", "instances", "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", "instances", "list"]) + + assert result.exit_code == 3 # UnknownInstance + assert _error_lines(result.stderr)[0].startswith("UnknownInstance:") + assert "wiki-a.example.org" in result.stderr + assert "Traceback" not in result.stderr + + +def test_successful_command_reports_only_the_credential_source( + runner, configured_env, monkeypatch, tmp_path +): + monkeypatch.chdir(tmp_path) # no accounts.pwd.yaml to discover + + result = runner.invoke(app, ["instances", "list"]) + + assert result.exit_code == 0, result.stderr + lines = _banner_lines(result.stderr) + assert len(lines) == 1 + assert lines[0].startswith("[osw] credentials :") + + +def test_verbose_adds_the_env_file_line(runner, configured_env, monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["--verbose", "instances", "list"]) + + assert result.exit_code == 0, result.stderr + lines = _banner_lines(result.stderr) + assert len(lines) == 2 + assert lines[0].startswith("[osw] credentials :") + assert lines[1].startswith("[osw] env file :") + + +def test_failing_command_reports_every_source_without_verbose( + runner, monkeypatch, tmp_path +): + # No credentials at all, so the command fails inside the operation. + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["instances", "list"]) + + assert result.exit_code == 1 + lines = _banner_lines(result.stderr) + assert len(lines) == 2 + assert lines[0].startswith("[osw] credentials :") + assert lines[1].startswith("[osw] env file :") + # The stdio-hang rationale belongs to the MCP server, not to the CLI. + assert "stdio transport" not in result.stderr + + +# -- status reports a credential-file username too (regression, Change 2) ------- +def test_status_reports_username_from_credential_file(runner, monkeypatch, tmp_path): + """settings.redacted() only sees OSW_USERNAME/OSL_USERNAME; a username + configured only via a credential file must still show up in status.""" + 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() + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: MagicMock()) + + result = runner.invoke(app, ["--json", "status"]) + + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["username"] == "alice" + assert "supersecret" not in result.stdout + + +def test_status_username_matches_the_one_the_login_uses(runner, monkeypatch, tmp_path): + """With both sources configured, report the one the connection will use. + + CredentialManager.get_credential consults the credential file first and + only falls back to OSW_USERNAME, so status must do the same. Reporting the + environment name here would name an account the session does not log in as. + """ + cred_file = tmp_path / "accounts.yaml" + cred_file.write_text( + yaml.safe_dump({ + "wiki-a.example.org": {"username": "from-file", "password": "secret"}, + }), + encoding="utf-8", + ) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file)) + monkeypatch.setenv("OSW_USERNAME", "from-env") + monkeypatch.setenv("OSW_PASSWORD", "env-secret") + monkeypatch.setenv("OSW_DOMAIN", "wiki-a.example.org") + config.reset() + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: MagicMock()) + + result = runner.invoke(app, ["--json", "status"]) + + assert result.exit_code == 0, result.stderr + assert json.loads(result.stdout)["username"] == "from-file" + + +# -- adapter-carried log prefix (Change: shared code no longer hardcodes it) ---- +def test_shared_code_reports_the_cli_prefix_on_a_connection_failure( + runner, configured_env, monkeypatch, caplog +): + """status's connection-failure branch lives in shared code + (osw.service.ops.status), so it must carry whichever prefix the running + adapter set, not a hardcoded one; the CLI sets "osw".""" + # Start from a foreign prefix so the assertion below proves the CLI's own + # callback set "osw"; starting from the config default would still pass + # even if that callback's set_log_prefix call were removed. + config.set_log_prefix("osw-mcp") + + def _boom(**kwargs): + raise RuntimeError("connection refused") + + monkeypatch.setattr("osw.service.context.OswExpress", _boom) + + # the failure is logged, not printed: CliRunner captures sys.stderr, but + # under pytest the record propagates to pytest's own handler instead. + with caplog.at_level(logging.WARNING, logger="osw"): + result = runner.invoke(app, ["status"]) + + assert result.exit_code == 0, result.stderr + assert "[osw] status connection check failed" in caplog.text + assert "[osw-mcp]" not in caplog.text + + +# -- instances status -------------------------------------------------------------- +def test_instances_status_reports_each_configured_instance( + runner, monkeypatch, tmp_path +): + cred_file = tmp_path / "accounts.yaml" + cred_file.write_text( + yaml.safe_dump({ + "wiki-a.example.org": {"username": "alice", "password": "secreta"}, + "wiki-b.example.org": {"username": "bob", "password": "secretb"}, + }), + encoding="utf-8", + ) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file)) + config.reset() + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: MagicMock()) + + result = runner.invoke(app, ["--json", "instances", "status"]) + + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + by_iri = {entry["iri"]: entry for entry in payload["instances"]} + assert set(by_iri) == {"wiki-a.example.org", "wiki-b.example.org"} + assert by_iri["wiki-a.example.org"]["username"] == "alice" + assert by_iri["wiki-b.example.org"]["username"] == "bob" + assert by_iri["wiki-a.example.org"]["connected"] is True + assert by_iri["wiki-b.example.org"]["connected"] is True + assert "secreta" not in result.stdout + assert "secretb" not in result.stdout + + +def test_instances_status_reports_a_failing_instance_without_stopping( + runner, monkeypatch, tmp_path +): + cred_file = tmp_path / "accounts.yaml" + cred_file.write_text( + yaml.safe_dump({ + "wiki-a.example.org": {"username": "alice", "password": "secreta"}, + "wiki-b.example.org": {"username": "bob", "password": "secretb"}, + }), + encoding="utf-8", + ) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file)) + config.reset() + + def fake_osw_express(*, domain, **kwargs): + if domain == "wiki-a.example.org": + raise RuntimeError("connection refused") + return MagicMock() + + monkeypatch.setattr("osw.service.context.OswExpress", fake_osw_express) + + result = runner.invoke(app, ["--json", "instances", "status"]) + + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + by_iri = {entry["iri"]: entry for entry in payload["instances"]} + assert by_iri["wiki-a.example.org"]["connected"] is False + assert "error" in by_iri["wiki-a.example.org"] + assert by_iri["wiki-b.example.org"]["connected"] is True + assert "error" not in by_iri["wiki-b.example.org"] + + +# -- a root option typed after the command names the correct form (Change 5) ---- +def test_root_option_after_command_names_the_correct_form(runner): + result = runner.invoke(app, ["status", "--instance", "wiki-dev.example.org"]) + + assert result.exit_code != 0 + combined = _usage_error(result) + assert "--instance " in combined + assert "before the command" in combined + + +def test_root_option_after_grouped_command_names_the_correct_form(runner): + result = runner.invoke(app, ["entity", "--instance", "x", "get", "T"]) + + assert result.exit_code != 0 + combined = _usage_error(result) + assert "--instance " in combined + assert "before the command" in combined + + +def test_root_options_mapping_covers_every_root_option(): + """_ROOT_OPTIONS is maintained by hand, next to but apart from _callback. + + Without this check, adding or renaming a root option would silently stop + the hint from firing for it, and the user would be back to click's bare + "No such option". + """ + root = typer.main.get_command(app) + declared = { + opt + for param in root.params + if isinstance(param, click.Option) + for opt in [*param.opts, *param.secondary_opts] + if opt != "--help" + } + + assert declared == set(cli_main._ROOT_OPTIONS) + + +def test_misspelled_command_option_keeps_clicks_suggestion(runner): + """--json is a root option, but here it is a misspelling of --jsondata. + + The hint must not displace click's "Did you mean", which names the option + the user actually wanted. + """ + result = runner.invoke(app, ["entity", "put", "--json", "{}"]) + + assert result.exit_code != 0 + combined = _usage_error(result) + assert "--jsondata" in combined + assert "before the command" not in combined diff --git a/tests/test_logging_setup.py b/tests/test_logging_setup.py index f68a6ebb..e006d6b5 100644 --- a/tests/test_logging_setup.py +++ b/tests/test_logging_setup.py @@ -36,11 +36,25 @@ def messages(self, name: str = None): @pytest.fixture def osw_logger(): - """Hands out the osw logger and puts its global state back afterwards""" + """The osw logger as an interpreter has it before osw is imported + + Does for the osw logger what plain_logging does for the root logger, and + puts the global state back afterwards. osw configures itself on import, + and whether that attaches its handler depends on whether anything had + configured logging by then. Import order decides that: a conftest that + imports osw runs before pytest attaches its capture handlers to the root + logger, one that does not leaves osw to be imported later, when they are + already on. Without this reset a test below would assert on the handler + left over from import rather than on the call it makes itself. + """ logger = logging.getLogger("osw") saved = (logger.handlers[:], logger.level, logger.propagate, osw._level_is_ours) + logger.handlers, logger.propagate = [], True + logger.setLevel(logging.NOTSET) + osw._level_is_ours = False yield logger - logger.handlers, logger.level, logger.propagate = saved[:3] + logger.handlers, logger.propagate = saved[0], saved[2] + logger.setLevel(saved[1]) osw._level_is_ours = saved[3] diff --git a/tests/test_mcp_registration.py b/tests/test_mcp_registration.py new file mode 100644 index 00000000..d7082515 --- /dev/null +++ b/tests/test_mcp_registration.py @@ -0,0 +1,107 @@ +"""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 + +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 00000000..670000ce --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,239 @@ +"""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 io + +import pytest +import yaml + +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", + "OSW_VERBOSE", + "OSW_MCP_VERBOSE", +] + + +@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() + + +def test_build_server_is_quiet_by_default(monkeypatch, capsys): + # The configuration source lines repeat what the MCP client's server entry + # already says, so a successful start says nothing without OSW_VERBOSE. + _configure(monkeypatch) + + server.create_server() + + assert "[osw]" not in capsys.readouterr().err + + +def test_build_server_writes_the_report_into_the_given_buffer(monkeypatch, capsys): + _configure(monkeypatch) + buf = io.StringIO() + + _mcp, ctx = server._build_server(buf) + ctx.close() + + assert "[osw-mcp] credentials" in buf.getvalue() + assert capsys.readouterr().err == "" + + +def test_build_server_report_lines_carry_the_mcp_prefix(monkeypatch): + """The config source lines are shared code (osw.service.config); this + server sets the "osw-mcp" prefix so they never show the CLI's "osw".""" + _configure(monkeypatch) + buf = io.StringIO() + + _mcp, ctx = server._build_server(buf) + ctx.close() + + lines = buf.getvalue().splitlines() + assert lines + assert all(line.startswith("[osw-mcp]") for line in lines) + + +def _serve_without_blocking(monkeypatch) -> None: + """Let main() return: no stdio loop, and no atexit handler left behind.""" + monkeypatch.setattr(server.MCPServer, "run", lambda self, **kwargs: None) + monkeypatch.setattr(server.atexit, "register", lambda func: func) + + +def test_main_is_quiet_on_a_successful_start(monkeypatch, capsys): + _configure(monkeypatch) + _serve_without_blocking(monkeypatch) + + server.main() + + assert "[osw]" not in capsys.readouterr().err + + +def test_main_prints_the_report_when_osw_verbose_is_set(monkeypatch, capsys): + _configure(monkeypatch) + monkeypatch.setenv("OSW_VERBOSE", "true") + config.reset() + _serve_without_blocking(monkeypatch) + + server.main() + + err = capsys.readouterr().err + assert "[osw-mcp] credentials" in err + assert "[osw-mcp] env file" in err + + +def test_main_prints_the_report_when_startup_fails(monkeypatch, tmp_path, capsys): + # No OSW_DOMAIN: _build_server raises, and that is exactly when the + # configuration sources have to be visible, OSW_VERBOSE or not. + 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(SystemExit): + server.main() + + err = capsys.readouterr().err + assert "[osw-mcp] " in err + assert "failed to start" in err 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 00000000..92996d74 --- /dev/null +++ b/tests/test_no_paths_on_mcp_surface.py @@ -0,0 +1,98 @@ +"""Guard tests: no filesystem path may ever reach the MCP surface. + +Offline: 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 the network nowhere. +""" + +from __future__ import annotations + +import inspect +import subprocess +import sys +from unittest.mock import MagicMock + +# 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(): + 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, + # text=True alone decodes with the locale encoding, cp1252 on a German + # Windows system, while a child running in Python's UTF-8 mode writes + # UTF-8. The reader thread then raises UnicodeDecodeError and the + # stream arrives as None. The child prints ASCII today, so this is + # protection against a future non-ASCII line rather than a fix. + encoding="utf-8", + errors="replace", + ) + 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_osw_entry.py b/tests/test_osw_entry.py new file mode 100644 index 00000000..0fcc69e0 --- /dev/null +++ b/tests/test_osw_entry.py @@ -0,0 +1,135 @@ +"""Offline tests for osw_entry, the shim behind the osw and osw-mcp console +scripts. + +osw writes a one-off notice to stderr at import time (see +src/osw/__init__.py), unless OSW_LOG_LEVEL is already in the environment. +osw_entry sets that variable before osw is imported, so the console scripts +stay quiet while a plain `import osw` still gets the notice. Nothing inside +the osw package could do this itself: importing any of its submodules +imports the package first, and the notice would already be written by then. +So this has to be proven with a real subprocess, one for each side of the +comparison, rather than by importing osw in this process. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +NOTICE = "osw logs at INFO" + +# Every subprocess below is read with these. text=True alone decodes with the +# locale encoding, which on a German Windows system is cp1252, and the reader +# thread then raises UnicodeDecodeError on the box-drawing bytes rich writes +# into --help output. The exception happens in the thread, so the test still +# passes and only a PytestUnhandledThreadExceptionWarning shows it. Decoding +# as UTF-8 matches what the child actually writes. errors="replace" keeps a +# byte outside UTF-8 from ending a test, which is safe because every +# assertion here searches for ASCII text. +_DECODE = {"text": True, "encoding": "utf-8", "errors": "replace"} + + +def _console_script(name: str) -> str: + """Path to a console script installed next to the running interpreter.""" + suffix = ".exe" if os.name == "nt" else "" + return str(Path(sys.executable).parent / f"{name}{suffix}") + + +def _env_without_log_level() -> dict: + """A copy of the environment with OSW_LOG_LEVEL removed, so a subprocess + starts exactly as an interpreter that never set it would.""" + env = dict(os.environ) + env.pop("OSW_LOG_LEVEL", None) + return env + + +def test_the_osw_console_script_does_not_print_the_import_notice_on_stderr(): + """The shim sets OSW_LOG_LEVEL before osw is imported, so the console + script stays quiet even though the environment it starts from does not + set the variable itself.""" + result = subprocess.run( + [_console_script("osw"), "--help"], + capture_output=True, + **_DECODE, + env=_env_without_log_level(), + ) + + assert result.returncode == 0, result.stderr + assert not any(NOTICE in line for line in result.stderr.splitlines()) + + +def test_importing_osw_directly_still_prints_the_notice_on_stderr(): + """Without the shim, library behaviour is unchanged: the notice is still + written, proving the console script above is quiet because of osw_entry + and not because the notice stopped firing altogether.""" + result = subprocess.run( + [sys.executable, "-c", "import osw"], + capture_output=True, + **_DECODE, + env=_env_without_log_level(), + ) + + assert result.returncode == 0, result.stderr + assert any(NOTICE in line for line in result.stderr.splitlines()) + + +def test_the_osw_mcp_console_script_stays_quiet_and_leaves_stdout_empty(): + """The second console script needs its own check, because it is the one + where a stray line is destructive rather than untidy. + + osw-mcp speaks JSON-RPC over stdout. A single non-JSON line there breaks + the client's parser. The notice goes to stderr today, so the risk is + about a future change moving it, which is what the stdout assertion + catches. Dummy credentials are enough: building the server does not + contact the wiki. Empty stdin gives the transport an immediate EOF, so + the server serves nothing and exits by itself. + """ + env = _env_without_log_level() + # Set explicitly so the run does not depend on the developer's own + # configuration, and so it can never reach a real wiki. + env.pop("OSW_ENV_FILE", None) + env.pop("OSW_CRED_FILEPATH", None) + env["OSW_DOMAIN"] = "wiki.example.org" + env["OSW_USERNAME"] = "not-a-real-user" + env["OSW_PASSWORD"] = "not-a-real-secret" + env["OSW_READ_ONLY"] = "true" + + result = subprocess.run( + [_console_script("osw-mcp")], + input="", + capture_output=True, + **_DECODE, + env=env, + timeout=180, + ) + + assert result.returncode == 0, result.stderr + assert not any(NOTICE in line for line in result.stderr.splitlines()) + assert result.stdout.strip() == "" + + +def test_the_level_the_shim_sets_is_osws_own_default(): + """osw_entry writes the level name out instead of importing it, so a + change to osw.DEFAULT_LOG_LEVEL would otherwise leave the shim setting a + different level than osw would have picked, and silently change what the + console scripts log.""" + import logging + + import osw + import osw_entry + + assert logging.getLevelName(osw.DEFAULT_LOG_LEVEL) == osw_entry._DEFAULT_LEVEL + + +def test_the_helper_does_not_override_an_already_set_log_level(monkeypatch): + """setdefault is what makes this safe: a value the caller chose on + purpose must survive, since overriding it would silently change what the + caller asked osw to log at.""" + monkeypatch.setenv("OSW_LOG_LEVEL", "DEBUG") + import osw_entry + + osw_entry._suppress_import_notice() + + assert os.environ["OSW_LOG_LEVEL"] == "DEBUG" diff --git a/tests/test_service_config.py b/tests/test_service_config.py new file mode 100644 index 00000000..e3cbb3b7 --- /dev/null +++ b/tests/test_service_config.py @@ -0,0 +1,1146 @@ +"""Unit tests for osw.service.config (fail-fast credential validation).""" + +import os +import sys +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from osw.service import config +from osw.service.config import Settings + +_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", + "OSW_VERBOSE", + "OSW_MCP_VERBOSE", +] + + +@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 + # dotenv writes into os.environ directly, so monkeypatch never learns about + # the variables a loaded .env file introduced and cannot undo them. Left in + # place they leak into every later test in the session, including other + # files whose own variable list is narrower than this one. + for var in _ALL_VARS: + os.environ.pop(var, None) + 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 + + +# -- accounts.pwd.yaml fallback (CLI only) ----------------------------------- + + +def test_cred_file_fallback_in_working_directory(monkeypatch, tmp_path): + """With discovery enabled, an accounts.pwd.yaml in the working directory + is used when no credentials are otherwise configured.""" + cred_file = _write_cred_file( + tmp_path / "accounts.pwd.yaml", + {"wiki.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.chdir(tmp_path) + config.set_env_file_discovery(True) + + settings = config.load() + + assert settings.cred_filepath == str(cred_file) + assert settings.username is None + assert settings.password is None + + +def test_cred_file_fallback_skipped_when_discovery_disabled(monkeypatch, tmp_path): + """The MCP server never enables discovery, so an accounts.pwd.yaml in its + working directory (chosen by the MCP client) must not be picked up.""" + _write_cred_file( + tmp_path / "accounts.pwd.yaml", + {"wiki.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.chdir(tmp_path) + + with pytest.raises(RuntimeError) as exc: + config.load() + assert "OSW_USERNAME" in str(exc.value) + assert "OSW_PASSWORD" in str(exc.value) + + +def test_explicit_cred_file_wins_over_fallback(monkeypatch, tmp_path): + _write_cred_file( + tmp_path / "accounts.pwd.yaml", + {"wiki.example.org": {"username": "alice", "password": "secret"}}, + ) + explicit = _write_cred_file( + tmp_path / "explicit.yaml", + {"wiki.example.org": {"username": "bob", "password": "other"}}, + ) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(explicit)) + monkeypatch.chdir(tmp_path) + config.set_env_file_discovery(True) + + settings = config.load() + + assert settings.cred_filepath == str(explicit) + + +def test_cred_file_fallback_skipped_when_username_password_configured( + monkeypatch, tmp_path +): + # The fallback only fills a gap; a credential file entry can carry a + # different user name, so it must never override explicit credentials. + _write_cred_file( + tmp_path / "accounts.pwd.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.chdir(tmp_path) + config.set_env_file_discovery(True) + + settings = config.load() + + assert settings.cred_filepath is None + + +def test_cred_file_fallback_skipped_when_only_username_configured( + monkeypatch, tmp_path +): + # Any explicitly named credential means the operator intends to + # authenticate that way; a half-configured pair must raise rather than + # silently switch to a different identity via the fallback file. + _write_cred_file( + tmp_path / "accounts.pwd.yaml", + {"wiki.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.chdir(tmp_path) + config.set_env_file_discovery(True) + + with pytest.raises(RuntimeError) as exc: + config.load() + + assert "OSW_PASSWORD" in str(exc.value) + + +def test_discovered_cred_file_wrong_domain_discarded_when_not_strict( + monkeypatch, tmp_path +): + # A discovered accounts.pwd.yaml is a convenience, not something the + # operator configured, so a domain mismatch discards it instead of + # raising: load(strict=False) exists precisely so a status command can + # report "not configured" rather than crash. + _write_cred_file( + tmp_path / "accounts.pwd.yaml", + {"other.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.chdir(tmp_path) + config.set_env_file_discovery(True) + + settings = config.load(strict=False) + + assert settings.cred_filepath is None + + +def test_discovered_cred_file_wrong_domain_raises_when_strict(monkeypatch, tmp_path): + _write_cred_file( + tmp_path / "accounts.pwd.yaml", + {"other.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.chdir(tmp_path) + config.set_env_file_discovery(True) + + with pytest.raises(RuntimeError) as exc: + config.load() + + assert "accounts.pwd.yaml" in str(exc.value) + assert "wiki.example.org" in str(exc.value) + + +def test_discovered_cred_file_wrong_domain_records_rejected_origin( + monkeypatch, tmp_path +): + # The rejection must be visible in the module state, not just discarded + # locally, so log_config_sources (which runs before load()) can report + # the same outcome load() then acts on. + cred_file = _write_cred_file( + tmp_path / "accounts.pwd.yaml", + {"other.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.chdir(tmp_path) + config.set_env_file_discovery(True) + + config.load(strict=False) + + assert config._cred_file_origin == "rejected" + assert config._cred_file_path == str(cred_file) + + +def test_no_upward_walk_for_cred_file_fallback(monkeypatch, tmp_path): + """The accounts.pwd.yaml fallback must not walk to parent directories, + unlike the .env search, which does.""" + _write_cred_file( + tmp_path / "accounts.pwd.yaml", + {"wiki.example.org": {"username": "alice", "password": "secret"}}, + ) + child = tmp_path / "child" + child.mkdir() + monkeypatch.chdir(child) + config.set_env_file_discovery(True) + + with pytest.raises(RuntimeError) as exc: + config.load() + + assert "OSW_USERNAME" in str(exc.value) + assert "OSW_PASSWORD" in str(exc.value) + + +def test_missing_credentials_error_mentions_fallback_when_discovery_enabled( + monkeypatch, tmp_path +): + monkeypatch.chdir(tmp_path) + config.set_env_file_discovery(True) + + with pytest.raises(RuntimeError) as exc: + config.load() + + assert "accounts.pwd.yaml" in str(exc.value) + + +def test_missing_credentials_error_omits_fallback_when_discovery_disabled(monkeypatch): + with pytest.raises(RuntimeError) as exc: + config.load() + + assert "accounts.pwd.yaml" not in str(exc.value) + + +# -- 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 + + +def test_log_config_sources_reports_fallback_cred_file(monkeypatch, tmp_path, capsys): + _write_cred_file( + tmp_path / "accounts.pwd.yaml", + {"wiki.example.org": {"username": "u", "password": "p"}}, + ) + monkeypatch.chdir(tmp_path) + config.set_env_file_discovery(True) + + config.log_config_sources() + + captured = capsys.readouterr() + assert "accounts.pwd.yaml found in the working directory" in captured.err + + +def test_log_config_sources_reports_rejected_cred_file(monkeypatch, tmp_path, capsys): + # The banner must name the rejection, not just the file: load() rejects + # this same file right after, so claiming it is "in use" would be wrong. + cred_file = _write_cred_file( + tmp_path / "accounts.pwd.yaml", + {"other.example.org": {"username": "u", "password": "p"}}, + ) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.chdir(tmp_path) + config.set_env_file_discovery(True) + + config.log_config_sources() + + captured = capsys.readouterr() + assert str(cred_file) in captured.err + assert "ignored: no entry for domain" in captured.err + assert "wiki.example.org" in captured.err + + +def test_log_config_sources_reports_cred_file_from_env_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(f"OSW_CRED_FILEPATH={cred}\n", encoding="utf-8") + monkeypatch.setenv("OSW_ENV_FILE", str(env)) + + config.log_config_sources() + + captured = capsys.readouterr() + assert "from OSW_CRED_FILEPATH in the env file" in captured.err + + +def test_env_file_attribution_survives_a_second_load(monkeypatch, tmp_path, capsys): + # A repeated _load_env_file() call within the same process must not erase + # the attribution of a name the file introduced on the first call: the + # name is already in os.environ by then, so a second before/after diff + # would otherwise come up empty. + 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(f"OSW_CRED_FILEPATH={cred}\n", encoding="utf-8") + monkeypatch.setenv("OSW_ENV_FILE", str(env)) + + config._load_env_file() + config._load_env_file() + config.log_config_sources() + + captured = capsys.readouterr() + assert "from OSW_CRED_FILEPATH in the env file" in captured.err + + +def test_log_config_sources_reports_cred_file_from_environment( + 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", + ) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred)) + + config.log_config_sources() + + captured = capsys.readouterr() + assert "from the OSW_CRED_FILEPATH environment variable" in captured.err + + +def test_log_config_sources_verbose_false_prints_only_credential_line( + monkeypatch, capsys +): + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + + config.log_config_sources(verbose=False) + + captured = capsys.readouterr() + lines = captured.err.splitlines() + assert len(lines) == 1 + assert lines[0].startswith("[osw] credentials :") + + +def test_log_config_sources_verbose_true_prints_both_lines_credential_first( + monkeypatch, capsys +): + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + + config.log_config_sources() + + captured = capsys.readouterr() + lines = captured.err.splitlines() + assert len(lines) == 2 + assert lines[0].startswith("[osw] credentials :") + assert lines[1].startswith("[osw] env file :") + + +def test_log_config_sources_reports_username_password_from_env_file( + monkeypatch, tmp_path, capsys +): + env = tmp_path / "creds.env" + env.write_text("OSW_USERNAME=alice\nOSW_PASSWORD=secret\n", encoding="utf-8") + monkeypatch.setenv("OSW_ENV_FILE", str(env)) + + config.log_config_sources() + + captured = capsys.readouterr() + assert "OSW_USERNAME/OSW_PASSWORD (from the env file)" in captured.err + + +def test_log_config_sources_reports_username_password_from_environment( + monkeypatch, capsys +): + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + + config.log_config_sources() + + captured = capsys.readouterr() + assert "OSW_USERNAME/OSW_PASSWORD (from the environment)" in captured.err + + +def test_log_config_sources_reports_credentials_not_configured(monkeypatch, capsys): + config.log_config_sources() + + captured = capsys.readouterr() + assert ( + "not configured (set OSW_CRED_FILEPATH, or OSW_USERNAME/OSW_PASSWORD)" + in captured.err + ) + + +def test_log_env_file_source_prints_only_env_file_line(monkeypatch, tmp_path, capsys): + env = tmp_path / "creds.env" + env.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_ENV_FILE", str(env)) + config._load_env_file() + + config.log_env_file_source() + + captured = capsys.readouterr() + lines = captured.err.splitlines() + assert len(lines) == 1 + assert lines[0].startswith("[osw] env file :") + assert str(env) in lines[0] + + +def test_missing_credentials_message_has_stdio_hint_when_discovery_disabled( + monkeypatch, +): + with pytest.raises(RuntimeError) as exc: + config.load() + + assert "stdio transport" in str(exc.value) + + +def test_missing_credentials_message_omits_stdio_hint_when_discovery_enabled( + monkeypatch, tmp_path +): + # chdir away from the repo root: it has its own accounts.pwd.yaml for local + # dev, which discovery would otherwise pick up and satisfy the check with. + monkeypatch.chdir(tmp_path) + config.set_env_file_discovery(True) + + with pytest.raises(RuntimeError) as exc: + config.load() + + assert "stdio transport" not in str(exc.value) + + +# -- Settings validation (pydantic) ------------------------------------------ + + +def test_blank_max_results_falls_back_to_default(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MAX_RESULTS", " ") + settings = config.load() + assert settings.max_results == 100 + + +def test_zero_max_results_raises(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MAX_RESULTS", "0") + with pytest.raises(RuntimeError) as exc: + config.load() + assert "OSW_MAX_RESULTS" in str(exc.value) + + +def test_malformed_sparql_endpoint_raises(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_SPARQL_ENDPOINT", "not a url") + with pytest.raises(RuntimeError) as exc: + config.load() + assert "OSW_SPARQL_ENDPOINT" in str(exc.value) + + +def test_valid_sparql_endpoint_loads(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_SPARQL_ENDPOINT", "https://wiki.example.org/sparql") + settings = config.load() + assert settings.sparql_endpoint == "https://wiki.example.org/sparql" + + +def test_error_names_the_alias_that_was_set(monkeypatch): + # Only the alias is set (not the canonical name), so the error must name + # the alias, not the canonical variable, for the operator to find it. + 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) as exc: + config.load() + assert "OSW_MCP_MAX_RESULTS" in str(exc.value) + + +def test_misspelled_read_only_raises(monkeypatch): + # A typo must not silently enable writes: read_only is the one flag whose + # fail-open default is dangerous, so an unparseable value has to be loud. + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_READ_ONLY", "ture") + with pytest.raises(RuntimeError) as exc: + config.load() + assert "OSW_READ_ONLY" in str(exc.value) + + +@pytest.mark.parametrize("raw", ["1", "true", "TRUE", "yes", "on", "y", "t"]) +def test_read_only_truthy_spellings(monkeypatch, raw): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_READ_ONLY", raw) + assert config.load().read_only is True + + +@pytest.mark.parametrize("raw", ["0", "false", "FALSE", "no", "off", "n", "f"]) +def test_read_only_falsy_spellings(monkeypatch, raw): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_READ_ONLY", raw) + assert config.load().read_only is False + + +def test_blank_read_only_falls_back_to_default(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_READ_ONLY", " ") + assert config.load().read_only is False + + +def test_verbose_defaults_to_false_and_is_set_by_the_env_variable(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + assert config.load().verbose is False + + monkeypatch.setenv("OSW_VERBOSE", "true") + assert config.load().verbose is True + + +def test_misspelled_verbose_raises(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_VERBOSE", "ture") + with pytest.raises(RuntimeError) as exc: + config.load() + assert "OSW_VERBOSE" in str(exc.value) + + +def test_read_only_error_names_the_alias_that_was_set(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MCP_READ_ONLY", "disabled") + with pytest.raises(RuntimeError) as exc: + config.load() + assert "OSW_MCP_READ_ONLY" in str(exc.value) + + +def test_domain_with_whitespace_rejected(): + with pytest.raises(ValidationError): + Settings(domain="wiki.example.org has a space") + + +def test_domain_as_full_url_accepted(): + # get_active_domain() relies on a full URL being a legal domain value. + settings = Settings(domain="https://wiki.example.org/w/") + assert settings.domain == "https://wiki.example.org/w/" + + +@pytest.mark.parametrize("value", ["https://", "/w/index.php", "//", "https:///w/"]) +def test_domain_without_a_host_rejected(value): + """A value _derive_domain cannot reduce to a host is unusable. + + Caught here rather than in OswExpress.validate_domain, which only runs on + the first connection and reports a regex rather than the variable name. + """ + with pytest.raises(ValidationError) as exc: + Settings(domain=value) + assert "host" in str(exc.value) + + +def test_state_dir_expands_a_leading_tilde(): + """Path(state_dir) never expands it, so '~/osw' made a directory named '~'. + + Ledger builds its file as Path(state_dir) / ... with no expanduser call + (src/osw/service/ledger.py:70), so the expansion has to happen here. + """ + settings = Settings(domain="wiki.example.org", state_dir="~/osw-state") + assert settings.state_dir == str(Path.home() / "osw-state") + + +@pytest.mark.parametrize( + "value", + [ + "osw-state", + "./osw-state", + "../osw-state", + # Drive-relative Windows forms: rooted without a drive, and a drive + # without a root. Both resolve against process state (the current drive, + # and that drive's working directory), so both are as unpredictable as a + # plain relative path. is_absolute() is False for both on Windows and on + # POSIX, so these parameters need no platform marker. + "\\osw-state", + "C:osw-state", + ], +) +def test_state_dir_relative_rejected(value): + """A relative path resolves against a working directory the user may not own. + + The MCP client chooses the server's working directory, so the ledger would + be created in an unpredictable place. + """ + with pytest.raises(ValidationError) as exc: + Settings(domain="wiki.example.org", state_dir=value) + assert "absolute" in str(exc.value) + + +def test_state_dir_reports_an_undeterminable_home(monkeypatch): + """The '~' expansion can fail, and the failure has to name the setting. + + Path.expanduser() raises RuntimeError when no home directory can be found. + Uncaught it would surface as a bare RuntimeError with no mention of + OSW_STATE_DIR. Reproducing that state differs per platform (Windows reads + USERPROFILE, POSIX falls back to the password database), so the raise itself + is patched in. + """ + + def _no_home(self): + raise RuntimeError("Could not determine home directory.") + + monkeypatch.setattr(Path, "expanduser", _no_home) + with pytest.raises(ValidationError) as exc: + Settings(domain="wiki.example.org", state_dir="~/osw-state") + assert "home directory" in str(exc.value) + + +def test_state_dir_absolute_is_left_alone(tmp_path): + settings = Settings(domain="wiki.example.org", state_dir=str(tmp_path / "state")) + assert settings.state_dir == str(tmp_path / "state") + + +def test_settings_is_frozen(): + settings = Settings(domain="wiki.example.org") + with pytest.raises(ValidationError): + settings.domain = "other.example.org" + + +# -- log prefix --------------------------------------------------------------- + + +def test_log_prefix_defaults_to_osw(): + assert config.log_prefix() == "[osw]" + + +def test_log_prefix_reflects_set_log_prefix(): + config.set_log_prefix("osw-mcp") + assert config.log_prefix() == "[osw-mcp]" diff --git a/tests/test_service_context.py b/tests/test_service_context.py new file mode 100644 index 00000000..63f89a2a --- /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 00000000..966fa96d --- /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 00000000..67800cf0 --- /dev/null +++ b/tests/test_service_instances.py @@ -0,0 +1,248 @@ +"""Unit tests for multi-instance selection in osw.service (config + Context). + +These are fully offline: no network, no live wiki, and no MCP SDK, since +osw.service is deliberately SDK-free. +""" + +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_CRED_FILEPATH", + "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_ENV_FILE", + "OSW_MCP_ENV_FILE", + "OSW_VERBOSE", + "OSW_MCP_VERBOSE", +] + + +@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 00000000..d284bc77 --- /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 00000000..255f1a26 --- /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 00000000..9561211c --- /dev/null +++ b/tests/test_service_ops_entities.py @@ -0,0 +1,250 @@ +"""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() + + +def test_delete_default_comment_carries_the_configured_log_prefix(): + osw, page = _osw_with_page() + ledger = MagicMock() + ledger.is_tracked.return_value = True + ctx = Context(_settings(), Policy(), osw=osw, ledger=ledger) + entities.config.set_log_prefix("osw-mcp") + + entities.delete_entity(ctx, title="Item:OSWx") + + page.delete.assert_called_once() + assert page.delete.call_args[0][0].startswith("[osw-mcp]") diff --git a/tests/test_service_ops_files.py b/tests/test_service_ops_files.py new file mode 100644 index 00000000..83a11cae --- /dev/null +++ b/tests/test_service_ops_files.py @@ -0,0 +1,219 @@ +"""Unit tests for osw.service.ops.files (Operation.fn called directly). + +Fully offline: ``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 00000000..fbe7d830 --- /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 00000000..3868ec98 --- /dev/null +++ b/tests/test_service_ops_search.py @@ -0,0 +1,149 @@ +"""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_ask_calls_semantic_search(): + osw = MagicMock() + osw.site.semantic_search.return_value = ["Item:OSW1", "Item:OSW2"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.search_ask(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_search_titles_calls_prefix_search(): + osw = MagicMock() + osw.site.prefix_search.return_value = ["Item:OSW1"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.search_titles(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_search_content_calls_content_search(): + osw = MagicMock() + osw.site.content_search.return_value = ["Item:OSW1"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.search_content(ctx, text="sensor") + + assert result["titles"] == ["Item:OSW1"] + assert result["count"] == 1 + assert result["truncated"] is False + osw.site.content_search.assert_called_once() + + +def test_search_entities_calls_query_instances(): + osw = MagicMock() + osw.query_instances.return_value = ["Item:OSW1", "Item:OSW2"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.search_entities(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}") + + +def test_search_ask_flags_truncation_at_the_requested_limit(): + osw = MagicMock() + osw.site.semantic_search.return_value = ["Item:OSW1", "Item:OSW2"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.search_ask(ctx, ask_query="[[Category:Item]]", limit=2) + + assert result["count"] == 2 + assert result["truncated"] is True + + +def test_search_ask_flags_truncation_at_a_limit_inside_the_query(): + """The query's own limit reaches the wiki, so it decides truncation.""" + osw = MagicMock() + osw.site.semantic_search.return_value = ["Item:OSW1", "Item:OSW2"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.search_ask(ctx, ask_query="[[Category:Item]]|limit=2", limit=100) + + assert result["truncated"] is True + + +def test_search_ask_below_the_limit_is_not_truncated(): + osw = MagicMock() + osw.site.semantic_search.return_value = ["Item:OSW1"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.search_ask(ctx, ask_query="[[Category:Item]]", limit=2) + + assert result["truncated"] is False + + +def test_search_ask_with_limit_zero_in_the_query_is_not_truncated(): + """'limit=0' asks for no results, so meeting it is not truncation.""" + osw = MagicMock() + osw.site.semantic_search.return_value = [] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.search_ask(ctx, ask_query="[[Category:Item]]|limit=0", limit=100) + + assert result["truncated"] is False + + +def test_search_titles_flags_truncation_at_the_limit(): + osw = MagicMock() + osw.site.prefix_search.return_value = ["Item:OSW1", "Item:OSW2"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.search_titles(ctx, text="Item", limit=2) + + assert result["truncated"] is True + + +def test_search_content_flags_truncation_at_the_limit(): + osw = MagicMock() + osw.site.content_search.return_value = ["Item:OSW1", "Item:OSW2"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.search_content(ctx, text="sensor", limit=2) + + assert result["truncated"] is True + + +def test_search_entities_flags_truncation_at_the_limit(): + osw = MagicMock() + osw.query_instances.return_value = ["Item:OSW1", "Item:OSW2"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.search_entities(ctx, category="Category:Item", limit=2) + + assert result["truncated"] is True diff --git a/tests/test_service_ops_slots.py b/tests/test_service_ops_slots.py new file mode 100644 index 00000000..30ae2e5c --- /dev/null +++ b/tests/test_service_ops_slots.py @@ -0,0 +1,241 @@ +"""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 + + +def test_set_slot_default_comment_carries_the_configured_log_prefix(): + 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) + slots.config.set_log_prefix("osw-mcp") + + slots.set_slot(ctx, title="Item:OSW1", slot="jsondata", content={"a": 1}) + + page.edit.assert_called_once() + assert page.edit.call_args.kwargs["comment"].startswith("[osw-mcp]") + + +# -- 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 00000000..40380e47 --- /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 00000000..06ad68df --- /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 00000000..08e3872c --- /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/tests/test_wiki_tools.py b/tests/test_wiki_tools.py index b8f22ce6..2ebd2949 100644 --- a/tests/test_wiki_tools.py +++ b/tests/test_wiki_tools.py @@ -609,3 +609,42 @@ def test_prefix_search_returns_flat_list_of_titles(): # return_json=False (the default) still yields a flat list of page titles assert out == ["Star Wars", "Star Trek"] + + +def _search_result(*titles): + """Build a minimal MediaWiki ``search`` API result dict.""" + return { + "batchcomplete": "", + "query": { + "search": [ + {"ns": 0, "title": title, "pageid": idx} + for idx, title in enumerate(titles, start=1) + ] + }, + } + + +def test_content_search_returns_flat_list_of_titles(): + result = _search_result("Star Wars", "Star Trek") + site = MagicMock() + site.api.return_value = result + + out = wt.content_search(site, "Star") + + # return_json=False (the default) still yields a flat list of page titles + assert out == ["Star Wars", "Star Trek"] + + +def test_content_search_calls_the_search_api(): + site = MagicMock() + site.api.return_value = _search_result("Star Wars", "Star Trek") + + wt.content_search(site, wt.SearchParam(query="Star", limit=7)) + + # Pin the API contract: the content search must use list=search with its + # sr* parameters, not list=prefixsearch + args, kwargs = site.api.call_args + assert args == ("query",) + assert kwargs["list"] == "search" + assert kwargs["srsearch"] == "Star" + assert kwargs["srlimit"] == 7 diff --git a/uv.lock b/uv.lock index f335d33c..cce8a4c5 100644 --- a/uv.lock +++ b/uv.lock @@ -1035,6 +1035,19 @@ 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.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/8c/e925b1c92018abb3a1863ce1549d76d2381e334d21d65d4ac8f65dabd78a/httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db", size = 67740, upload-time = "2026-09-14T14:18:04.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/0d/117a771a2bb91df334b66bf4da14cd02f21aefbcfe53180f336ce55e8f90/httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e", size = 83162, upload-time = "2026-09-14T14:18:02.529Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -1055,6 +1068,32 @@ http2 = [ { name = "h2" }, ] +[[package]] +name = "httpx2" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", 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/b9/a0/e9deef4654132857b5a5dbe4eddd0ac59c2814500e11f2f5044cd81103ee/httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44", size = 100290, upload-time = "2026-09-14T14:18:05.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/d1/a0c72b0e006df654709fbc366cc5bcb53e5aee13e1e3395152c6dd293376/httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a", size = 95565, upload-time = "2026-09-14T14:18:03.553Z" }, +] + +[[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" @@ -1474,6 +1513,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.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { 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/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1909,6 +1986,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" @@ -1982,6 +2071,7 @@ source = { editable = "." } dependencies = [ { name = "backports-strenum", marker = "python_full_version < '3.11'" }, { name = "black" }, + { name = "click" }, { name = "dask" }, { name = "datamodel-code-generator" }, { name = "httpx" }, @@ -1998,11 +2088,13 @@ dependencies = [ { name = "pybars3-wheel" }, { name = "pydantic", extra = ["email"] }, { name = "pyld" }, + { name = "python-dotenv" }, { name = "pyyaml" }, { name = "rdflib" }, { name = "requests" }, { name = "sparqlwrapper" }, { name = "tqdm" }, + { name = "typer" }, { name = "typing-extensions" }, ] @@ -2011,6 +2103,7 @@ all = [ { name = "boto3" }, { name = "deepl" }, { name = "geopy" }, + { name = "mcp" }, { name = "mwparserfromhell" }, { name = "openpyxl" }, { name = "psycopg2" }, @@ -2026,6 +2119,9 @@ db = [ { name = "psycopg2" }, { name = "sqlalchemy" }, ] +mcp = [ + { name = "mcp" }, +] s3 = [ { name = "boto3" }, ] @@ -2056,7 +2152,7 @@ dev = [ { name = "mike" }, { name = "mkdocstrings-python" }, { name = "mwparserfromhell" }, - { name = "osw", extra = ["workflow"] }, + { name = "osw", extra = ["mcp", "workflow"] }, { name = "pre-commit" }, { name = "psycopg2-binary" }, { name = "pytest" }, @@ -2076,6 +2172,7 @@ requires-dist = [ { name = "backports-strenum", marker = "python_full_version < '3.11'" }, { name = "black" }, { name = "boto3", marker = "extra == 's3'" }, + { name = "click" }, { name = "dask" }, { name = "datamodel-code-generator", specifier = "==0.51.0" }, { name = "deepl", marker = "extra == 'dataimport'" }, @@ -2083,6 +2180,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" }, @@ -2092,22 +2190,24 @@ requires-dist = [ { name = "opensemantic-base", specifier = ">=0.42.7" }, { name = "opensemantic-core", specifier = ">=0.57.4" }, { name = "osw", extras = ["dataimport"], marker = "extra == 'tutorial'" }, - { name = "osw", extras = ["dataimport", "db", "ui", "s3", "wikitext"], marker = "extra == 'all'" }, + { name = "osw", extras = ["dataimport", "db", "ui", "s3", "wikitext", "mcp"], marker = "extra == 'all'" }, { name = "prefect", marker = "extra == 'workflow'", specifier = ">=2.20.25,<3.0" }, { name = "psycopg2", marker = "extra == 'db'" }, { name = "pybars3-wheel" }, { name = "pydantic", extras = ["email"], specifier = ">=2.12.0" }, { name = "pyld" }, { name = "pysimplegui", marker = "extra == 'ui'", specifier = ">=6" }, + { name = "python-dotenv", 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 = [ @@ -2120,7 +2220,7 @@ dev = [ { name = "mike", git = "https://github.com/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 = "osw", extras = ["workflow", "mcp"] }, { name = "pre-commit", specifier = ">=4.0.0" }, { name = "psycopg2-binary" }, { name = "pytest" }, @@ -2563,6 +2663,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.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyld" version = "3.1.0" @@ -2700,6 +2817,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.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + [[package]] name = "python-gitlab" version = "8.4.0" @@ -3441,6 +3567,32 @@ asyncio = [ { name = "greenlet" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + [[package]] name = "text-unidecode" version = "1.3" @@ -3525,6 +3677,15 @@ 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 50218f41..23f9685e 100644 --- a/zensical.toml +++ b/zensical.toml @@ -14,6 +14,12 @@ nav = [ { "Home" = "index.md" }, { "About" = "about.md" }, { "Get Started" = "get-started.md" }, + { "Tools" = [ + { "Overview" = "tools/index.md" }, + { "CLI" = "tools/cli.md" }, + { "MCP" = "tools/mcp.md" }, + { "Configuration" = "tools/configuration.md" }, + ]}, { "API Reference" = [ { "Overview" = "api/index.md" }, { "OSW" = "api/core.md" },