witan-core 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- witan_core-0.2.0/.gitignore +38 -0
- witan_core-0.2.0/CHANGELOG.md +57 -0
- witan_core-0.2.0/PKG-INFO +77 -0
- witan_core-0.2.0/README.md +55 -0
- witan_core-0.2.0/pyproject.toml +74 -0
- witan_core-0.2.0/tests/__init__.py +0 -0
- witan_core-0.2.0/tests/test_cli.py +102 -0
- witan_core-0.2.0/tests/test_detach.py +41 -0
- witan_core-0.2.0/tests/test_elicit.py +72 -0
- witan_core-0.2.0/tests/test_import.py +23 -0
- witan_core-0.2.0/tests/test_maintenance.py +87 -0
- witan_core-0.2.0/tests/test_omnigraph.py +165 -0
- witan_core-0.2.0/tests/test_omnigraph_install.py +144 -0
- witan_core-0.2.0/tests/test_remote_oidc.py +305 -0
- witan_core-0.2.0/tests/test_remote_proxy.py +100 -0
- witan_core-0.2.0/tests/test_repo_key.py +59 -0
- witan_core-0.2.0/tests/test_timeutil.py +10 -0
- witan_core-0.2.0/uv.lock +1600 -0
- witan_core-0.2.0/witan_core/__init__.py +29 -0
- witan_core-0.2.0/witan_core/_detach.py +25 -0
- witan_core-0.2.0/witan_core/cli.py +113 -0
- witan_core-0.2.0/witan_core/elicit.py +59 -0
- witan_core-0.2.0/witan_core/maintenance.py +90 -0
- witan_core-0.2.0/witan_core/omnigraph.py +353 -0
- witan_core-0.2.0/witan_core/omnigraph_install.py +145 -0
- witan_core-0.2.0/witan_core/remote/__init__.py +18 -0
- witan_core-0.2.0/witan_core/remote/oidc.py +317 -0
- witan_core-0.2.0/witan_core/remote/proxy.py +134 -0
- witan_core-0.2.0/witan_core/repo_key.py +52 -0
- witan_core-0.2.0/witan_core/timeutil.py +10 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
.venv/
|
|
4
|
+
__pycache__/
|
|
5
|
+
*.pyc
|
|
6
|
+
|
|
7
|
+
# Environment / secrets
|
|
8
|
+
.env
|
|
9
|
+
.env.local
|
|
10
|
+
.env.*.local
|
|
11
|
+
*.secret
|
|
12
|
+
|
|
13
|
+
# Editor
|
|
14
|
+
.vscode/settings.json
|
|
15
|
+
.idea/
|
|
16
|
+
*.swp
|
|
17
|
+
*.swo
|
|
18
|
+
|
|
19
|
+
# OS
|
|
20
|
+
.DS_Store
|
|
21
|
+
Thumbs.db
|
|
22
|
+
|
|
23
|
+
# Build artifacts
|
|
24
|
+
dist/
|
|
25
|
+
build/
|
|
26
|
+
*.egg-info/
|
|
27
|
+
# omnigraph binary downloaded by hatch_build.py — not committed
|
|
28
|
+
_bin/
|
|
29
|
+
# omnigraph cluster state (populated by `omnigraph cluster import`)
|
|
30
|
+
__cluster/
|
|
31
|
+
|
|
32
|
+
# MCP server logs / caches
|
|
33
|
+
.mcp-cache/
|
|
34
|
+
mcp-server.log
|
|
35
|
+
.ruff_cache/
|
|
36
|
+
|
|
37
|
+
# agent-config-kit remote skill/hook fetch cache
|
|
38
|
+
.agent-config-kit-cache/
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `witan-core` are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/) (pre-1.0:
|
|
7
|
+
a MINOR bump may include breaking changes).
|
|
8
|
+
|
|
9
|
+
## [0.2.0] - 2026-07-16
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Remote-access layer (`witan_core.remote`, the `remote` extra) — the
|
|
14
|
+
transport-agnostic ADR-0005 path-a client stack, so a second deployed server
|
|
15
|
+
can reuse it: `DeviceAuth` (`witan_core.remote.oidc`) drives the OIDC
|
|
16
|
+
device-authorization grant (RFC 8628) + a 0600 token cache, parameterized by
|
|
17
|
+
cache path and login hint; `RemoteMCPProxy` (`witan_core.remote.proxy`) mirrors
|
|
18
|
+
a FastMCP server's tool surface over `streamable-http` (positional→name arg
|
|
19
|
+
mapping, `{"result": …}` envelope unwrap), with subclass hooks for the
|
|
20
|
+
admin-tool refusal set, client-side repo resolution, and error wording.
|
|
21
|
+
witan-council now binds its policy (cache location, `witan login` hint,
|
|
22
|
+
`_ADMIN_ONLY`, `repo.detect`) via thin shims instead of owning the mechanism.
|
|
23
|
+
- CLI scaffolding (`witan_core.cli`, the `cli` extra): `AgentName`/`AGENT_NAMES`
|
|
24
|
+
(the supported coding-agent constants), `make_app` (the `--version`-wired
|
|
25
|
+
cyclopts app factory), `resolve_author` (`--author` → `git config user.name` →
|
|
26
|
+
`$USER` → `"unknown"`), and `report_install` (the agent-config install-result
|
|
27
|
+
printer — styled with a rich `console`, plain `print` without one). Both
|
|
28
|
+
servers' `setup` commands now share these instead of carrying divergent copies.
|
|
29
|
+
- `agent-config-kit` is now a dependency of the `cli` extra (supplies
|
|
30
|
+
`resolve_version` and the `InstallResult` type) — a valid leaf→leaf edge, since
|
|
31
|
+
both servers already depend on it.
|
|
32
|
+
|
|
33
|
+
## [0.1.0] - 2026-07-15
|
|
34
|
+
|
|
35
|
+
Initial release of the shared `witan-core` package (import `witan_core`),
|
|
36
|
+
mirroring `agent-config-kit`. Both witan MCP servers now depend on it via a
|
|
37
|
+
`[tool.uv.sources]` editable path plus a `witan-core>=0.1,<1` PyPI range. This
|
|
38
|
+
establishes the package and the deliberate reversal of the "no cross-package
|
|
39
|
+
import" convention. See `docs/design/witan-core-extraction-spec.md`.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- `popen_detached` (`witan_core._detach`) — cross-platform detached subprocess spawning.
|
|
44
|
+
- `install_omnigraph` (`witan_core.omnigraph_install`) — the pinned omnigraph
|
|
45
|
+
binary installer and single source of `_OMNIGRAPH_VERSION` (`rich` imported lazily).
|
|
46
|
+
- `confirm`/`text` (`witan_core.elicit`) — MCP elicitation primitives (the `mcp` extra).
|
|
47
|
+
- `normalise`/`find_git_config` (`witan_core.repo_key`) — the cross-layer
|
|
48
|
+
repo-key canonicalizer, with a golden contract test.
|
|
49
|
+
- `now_iso` (`witan_core.timeutil`).
|
|
50
|
+
- Throttled-optimize mechanics (`witan_core.maintenance`) — interval parsing,
|
|
51
|
+
atomic last-run stamp, and due-check.
|
|
52
|
+
- `OmnigraphClient` base (`witan_core.omnigraph`) — the omnigraph-CLI subprocess
|
|
53
|
+
wrapper (write lock, retry/repair, per-actor admission-cap backoff,
|
|
54
|
+
`OmnigraphConflict`); each server subclasses it for its own tail.
|
|
55
|
+
|
|
56
|
+
Extracted from the duplicated surface of `witan` (witan-council) and
|
|
57
|
+
`witan-code`; the now-duplicated copies were deleted from both servers.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: witan-core
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Shared core for the witan MCP servers (witan-council + witan-code)
|
|
5
|
+
Project-URL: Homepage, https://github.com/mitodl/agent-kit/tree/main/packages/witan-core
|
|
6
|
+
Project-URL: Repository, https://github.com/mitodl/agent-kit
|
|
7
|
+
Project-URL: Issues, https://github.com/mitodl/agent-kit/issues
|
|
8
|
+
License-Expression: BSD-3-Clause
|
|
9
|
+
Keywords: agent,coding-agent,mcp,omnigraph,shared
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Provides-Extra: cli
|
|
13
|
+
Requires-Dist: agent-config-kit<1,>=0.4; extra == 'cli'
|
|
14
|
+
Requires-Dist: cyclopts<5,>=4; extra == 'cli'
|
|
15
|
+
Requires-Dist: rich>=13; extra == 'cli'
|
|
16
|
+
Provides-Extra: mcp
|
|
17
|
+
Requires-Dist: fastmcp<4,>=3.4.2; extra == 'mcp'
|
|
18
|
+
Provides-Extra: remote
|
|
19
|
+
Requires-Dist: fastmcp<4,>=3.4.2; extra == 'remote'
|
|
20
|
+
Requires-Dist: httpx>=0.27; extra == 'remote'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# witan-core
|
|
24
|
+
|
|
25
|
+
Shared core for the two witan MCP servers — `witan` (dist `witan-council`,
|
|
26
|
+
`mcp/servers/witan`) and `witan-code` (`mcp/servers/witan-code`). It is the third
|
|
27
|
+
shared `packages/` sibling alongside
|
|
28
|
+
[`agent-config-kit`](../agent-config-kit/README.md), wired into both servers via
|
|
29
|
+
a `[tool.uv.sources]` editable path (dev/CI) plus a published PyPI version range.
|
|
30
|
+
|
|
31
|
+
## Why
|
|
32
|
+
|
|
33
|
+
The two servers were built copy-paste-and-diverge and carried an explicit
|
|
34
|
+
"deliberately duplicated — no cross-package import" convention. As the shared
|
|
35
|
+
surface grew, fixes had to be applied twice and silently drifted — including code
|
|
36
|
+
that is *contractually required* to stay identical (the repo-key canonicalizer
|
|
37
|
+
behind the cross-layer symbol join key; the pinned omnigraph binary version, kept
|
|
38
|
+
in lockstep by a fragile Renovate custom manager).
|
|
39
|
+
|
|
40
|
+
`witan-core` deliberately reverses that convention. The full rationale, scope,
|
|
41
|
+
and per-extraction contracts live in
|
|
42
|
+
[`docs/design/witan-core-extraction-spec.md`](../../docs/design/witan-core-extraction-spec.md).
|
|
43
|
+
|
|
44
|
+
## Invariant
|
|
45
|
+
|
|
46
|
+
`witan_core` imports **neither** `witan` nor `witan_code`. It is a leaf below
|
|
47
|
+
both, preserving the one-directional `witan` → `witan_code` optional-mount DAG
|
|
48
|
+
(`witan` mounts `witan-code` as `witan code`; `witan-code` never imports `witan`).
|
|
49
|
+
|
|
50
|
+
## Dependencies
|
|
51
|
+
|
|
52
|
+
The base package is stdlib-only. Heavier concerns are gated behind extras so
|
|
53
|
+
neither server pulls weight it doesn't use:
|
|
54
|
+
|
|
55
|
+
- `witan-core[cli]` → `cyclopts`, `rich` (CLI scaffolding, styled installer output)
|
|
56
|
+
- `witan-core[mcp]` → `fastmcp` (MCP elicitation primitives)
|
|
57
|
+
|
|
58
|
+
## What's here
|
|
59
|
+
|
|
60
|
+
Extracted so far (each deletes the duplicated copies from both servers):
|
|
61
|
+
|
|
62
|
+
- `_detach.popen_detached` — cross-platform detached subprocess spawning
|
|
63
|
+
- `omnigraph_install` — the pinned-omnigraph-binary installer (single source of
|
|
64
|
+
the version; `rich` imported lazily)
|
|
65
|
+
- `elicit` — the `confirm`/`text` MCP elicitation primitives (needs the `mcp`
|
|
66
|
+
extra; not re-exported from the package root)
|
|
67
|
+
- `repo_key` — `normalise` + `find_git_config`, the cross-layer repo-key
|
|
68
|
+
canonicalizer, with a golden contract test
|
|
69
|
+
- `timeutil.now_iso`
|
|
70
|
+
- `maintenance` — the throttled-optimize stamp/interval/due mechanics
|
|
71
|
+
- `omnigraph.OmnigraphClient` — the omnigraph-CLI subprocess wrapper base
|
|
72
|
+
(write lock, retry/repair, admission-cap backoff); each server subclasses it
|
|
73
|
+
(witan adds `apply_schema`; witan-code adds branch ops + bulk `load`)
|
|
74
|
+
|
|
75
|
+
Still local to each server (intentionally): the CLI scaffolding — its
|
|
76
|
+
extraction coordinates with the in-flight multi-user deployment work (see the
|
|
77
|
+
spec).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# witan-core
|
|
2
|
+
|
|
3
|
+
Shared core for the two witan MCP servers — `witan` (dist `witan-council`,
|
|
4
|
+
`mcp/servers/witan`) and `witan-code` (`mcp/servers/witan-code`). It is the third
|
|
5
|
+
shared `packages/` sibling alongside
|
|
6
|
+
[`agent-config-kit`](../agent-config-kit/README.md), wired into both servers via
|
|
7
|
+
a `[tool.uv.sources]` editable path (dev/CI) plus a published PyPI version range.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
The two servers were built copy-paste-and-diverge and carried an explicit
|
|
12
|
+
"deliberately duplicated — no cross-package import" convention. As the shared
|
|
13
|
+
surface grew, fixes had to be applied twice and silently drifted — including code
|
|
14
|
+
that is *contractually required* to stay identical (the repo-key canonicalizer
|
|
15
|
+
behind the cross-layer symbol join key; the pinned omnigraph binary version, kept
|
|
16
|
+
in lockstep by a fragile Renovate custom manager).
|
|
17
|
+
|
|
18
|
+
`witan-core` deliberately reverses that convention. The full rationale, scope,
|
|
19
|
+
and per-extraction contracts live in
|
|
20
|
+
[`docs/design/witan-core-extraction-spec.md`](../../docs/design/witan-core-extraction-spec.md).
|
|
21
|
+
|
|
22
|
+
## Invariant
|
|
23
|
+
|
|
24
|
+
`witan_core` imports **neither** `witan` nor `witan_code`. It is a leaf below
|
|
25
|
+
both, preserving the one-directional `witan` → `witan_code` optional-mount DAG
|
|
26
|
+
(`witan` mounts `witan-code` as `witan code`; `witan-code` never imports `witan`).
|
|
27
|
+
|
|
28
|
+
## Dependencies
|
|
29
|
+
|
|
30
|
+
The base package is stdlib-only. Heavier concerns are gated behind extras so
|
|
31
|
+
neither server pulls weight it doesn't use:
|
|
32
|
+
|
|
33
|
+
- `witan-core[cli]` → `cyclopts`, `rich` (CLI scaffolding, styled installer output)
|
|
34
|
+
- `witan-core[mcp]` → `fastmcp` (MCP elicitation primitives)
|
|
35
|
+
|
|
36
|
+
## What's here
|
|
37
|
+
|
|
38
|
+
Extracted so far (each deletes the duplicated copies from both servers):
|
|
39
|
+
|
|
40
|
+
- `_detach.popen_detached` — cross-platform detached subprocess spawning
|
|
41
|
+
- `omnigraph_install` — the pinned-omnigraph-binary installer (single source of
|
|
42
|
+
the version; `rich` imported lazily)
|
|
43
|
+
- `elicit` — the `confirm`/`text` MCP elicitation primitives (needs the `mcp`
|
|
44
|
+
extra; not re-exported from the package root)
|
|
45
|
+
- `repo_key` — `normalise` + `find_git_config`, the cross-layer repo-key
|
|
46
|
+
canonicalizer, with a golden contract test
|
|
47
|
+
- `timeutil.now_iso`
|
|
48
|
+
- `maintenance` — the throttled-optimize stamp/interval/due mechanics
|
|
49
|
+
- `omnigraph.OmnigraphClient` — the omnigraph-CLI subprocess wrapper base
|
|
50
|
+
(write lock, retry/repair, admission-cap backoff); each server subclasses it
|
|
51
|
+
(witan adds `apply_schema`; witan-code adds branch ops + bulk `load`)
|
|
52
|
+
|
|
53
|
+
Still local to each server (intentionally): the CLI scaffolding — its
|
|
54
|
+
extraction coordinates with the in-flight multi-user deployment work (see the
|
|
55
|
+
spec).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "witan-core"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Shared core for the witan MCP servers (witan-council + witan-code)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "BSD-3-Clause"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
keywords = ["mcp", "agent", "omnigraph", "shared", "coding-agent"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Programming Language :: Python :: 3",
|
|
11
|
+
]
|
|
12
|
+
# Base modules (_detach, repo_key, timeutil, omnigraph, maintenance) are
|
|
13
|
+
# stdlib-only on purpose so neither server pulls weight it doesn't use. Heavier
|
|
14
|
+
# concerns are gated behind the extras below.
|
|
15
|
+
dependencies = []
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
# cli.py (make_app/report_install/resolve_author) + the omnigraph installer's
|
|
19
|
+
# styled output. agent-config-kit supplies resolve_version (--version wiring)
|
|
20
|
+
# and the InstallResult type report_install prints; it is a sibling leaf package
|
|
21
|
+
# both servers already depend on, so this stays a valid leaf→leaf edge.
|
|
22
|
+
cli = ["cyclopts>=4,<5", "rich>=13", "agent-config-kit>=0.4,<1"]
|
|
23
|
+
# elicit.py (fastmcp.server.elicitation.AcceptedElicitation).
|
|
24
|
+
mcp = ["fastmcp>=3.4.2,<4"]
|
|
25
|
+
# remote/ — the OIDC device-auth + token cache (httpx) and the MCP-client proxy
|
|
26
|
+
# (fastmcp). ADR-0005 path-a client stack; a server's CLI uses it to reach its
|
|
27
|
+
# deployed self. httpx is also a transitive fastmcp dep, relied on directly.
|
|
28
|
+
remote = ["httpx>=0.27", "fastmcp>=3.4.2,<4"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/mitodl/agent-kit/tree/main/packages/witan-core"
|
|
32
|
+
Repository = "https://github.com/mitodl/agent-kit"
|
|
33
|
+
Issues = "https://github.com/mitodl/agent-kit/issues"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["hatchling"]
|
|
37
|
+
build-backend = "hatchling.build"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["witan_core"]
|
|
41
|
+
|
|
42
|
+
[tool.bumpversion]
|
|
43
|
+
current_version = "0.2.0"
|
|
44
|
+
allow_dirty = true
|
|
45
|
+
|
|
46
|
+
[[tool.bumpversion.files]]
|
|
47
|
+
filename = "pyproject.toml"
|
|
48
|
+
search = 'version = "{current_version}"'
|
|
49
|
+
replace = 'version = "{new_version}"'
|
|
50
|
+
|
|
51
|
+
[dependency-groups]
|
|
52
|
+
# rich: the omnigraph installer imports it lazily; the installer tests exercise
|
|
53
|
+
# that path. fastmcp: witan_core.elicit + remote.proxy import it (the `mcp` /
|
|
54
|
+
# `remote` extras). cyclopts + agent-config-kit: the `cli` extra; the cli tests
|
|
55
|
+
# exercise make_app/report_install. httpx: remote.oidc (the `remote` extra); the
|
|
56
|
+
# device-auth tests drive it via httpx.MockTransport. All test-only — the base
|
|
57
|
+
# package requires none of them.
|
|
58
|
+
test = [
|
|
59
|
+
"pytest>=8",
|
|
60
|
+
"rich>=13",
|
|
61
|
+
"fastmcp>=3.4.2,<4",
|
|
62
|
+
"cyclopts>=4,<5",
|
|
63
|
+
"agent-config-kit>=0.4,<1",
|
|
64
|
+
"httpx>=0.27",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
[tool.uv.sources]
|
|
68
|
+
# Resolve the sibling shared package from the monorepo checkout for local dev
|
|
69
|
+
# and CI; PyPI still serves it for external installs via the version range above.
|
|
70
|
+
agent-config-kit = { path = "../agent-config-kit", editable = true }
|
|
71
|
+
|
|
72
|
+
[tool.pytest.ini_options]
|
|
73
|
+
pythonpath = ["."]
|
|
74
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""CLI scaffolding shared by both servers' ``setup`` commands.
|
|
2
|
+
|
|
3
|
+
The two servers rendered install results differently (rich markup vs plain
|
|
4
|
+
print) and each carried its own copy of the agent-name constants and author
|
|
5
|
+
resolution; these tests pin the shared behavior so a future divergence is caught
|
|
6
|
+
here rather than in one server only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from witan_core.cli import (
|
|
16
|
+
AGENT_NAMES,
|
|
17
|
+
AgentName,
|
|
18
|
+
make_app,
|
|
19
|
+
report_install,
|
|
20
|
+
resolve_author,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _result(planned, skipped):
|
|
25
|
+
# report_install only reads .planned and .skipped, so a stub stands in for
|
|
26
|
+
# agent_config_kit.InstallResult (keeps the base test deps light).
|
|
27
|
+
return SimpleNamespace(planned=planned, skipped=skipped)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_agent_names_cover_the_literal():
|
|
31
|
+
# Every AgentName except the "all" fan-out has a display label.
|
|
32
|
+
literals = set(AgentName.__args__) - {"all"}
|
|
33
|
+
assert literals == set(AGENT_NAMES)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_resolve_author_prefers_explicit():
|
|
37
|
+
assert resolve_author("Ada Lovelace") == "Ada Lovelace"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_resolve_author_falls_back_to_git(monkeypatch):
|
|
41
|
+
monkeypatch.setattr("subprocess.check_output", lambda *a, **k: "Git Name\n")
|
|
42
|
+
assert resolve_author(None) == "Git Name"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_resolve_author_falls_back_to_user_env(monkeypatch):
|
|
46
|
+
def _no_git(*a, **k):
|
|
47
|
+
raise FileNotFoundError
|
|
48
|
+
|
|
49
|
+
monkeypatch.setattr("subprocess.check_output", _no_git)
|
|
50
|
+
monkeypatch.setenv("USER", "env_user")
|
|
51
|
+
assert resolve_author(None) == "env_user"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_resolve_author_last_resort_unknown(monkeypatch):
|
|
55
|
+
monkeypatch.setattr("subprocess.check_output", lambda *a, **k: " \n")
|
|
56
|
+
monkeypatch.delenv("USER", raising=False)
|
|
57
|
+
assert resolve_author(None) == "unknown"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_report_install_plain(capsys):
|
|
61
|
+
report_install(
|
|
62
|
+
"claude",
|
|
63
|
+
_result(planned=["a.json"], skipped=[("b.json", "exists")]),
|
|
64
|
+
dry_run=False,
|
|
65
|
+
)
|
|
66
|
+
out = capsys.readouterr().out
|
|
67
|
+
assert "Claude Code" in out
|
|
68
|
+
assert "-> a.json" in out
|
|
69
|
+
assert "skip b.json — exists" in out
|
|
70
|
+
assert "[green]" not in out # plain branch: no rich markup
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_report_install_dry_run_tag(capsys):
|
|
74
|
+
report_install("pi", _result(planned=["x"], skipped=[]), dry_run=True)
|
|
75
|
+
assert "(dry-run)" in capsys.readouterr().out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_report_install_rich_uses_console_markup():
|
|
79
|
+
calls: list[str] = []
|
|
80
|
+
console = SimpleNamespace(print=lambda s: calls.append(s))
|
|
81
|
+
report_install(
|
|
82
|
+
"claude",
|
|
83
|
+
_result(planned=["a.json"], skipped=[("b.json", "exists")]),
|
|
84
|
+
dry_run=False,
|
|
85
|
+
console=console,
|
|
86
|
+
)
|
|
87
|
+
joined = "\n".join(calls)
|
|
88
|
+
assert "[bold]Claude Code[/bold]" in joined
|
|
89
|
+
assert "[green]→[/green] a.json" in joined
|
|
90
|
+
assert "[yellow]skip[/yellow] b.json — exists" in joined
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_make_app_sets_version(capsys):
|
|
94
|
+
app = make_app(
|
|
95
|
+
name="witan-core-test",
|
|
96
|
+
help_text="test app",
|
|
97
|
+
version_dist="witan-core",
|
|
98
|
+
)
|
|
99
|
+
with pytest.raises(SystemExit):
|
|
100
|
+
app(["--version"])
|
|
101
|
+
# resolve_version returns *some* string for the installed distribution.
|
|
102
|
+
assert capsys.readouterr().out.strip()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from witan_core import _detach
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_popen_detached_uses_start_new_session_on_posix(monkeypatch):
|
|
8
|
+
captured = {}
|
|
9
|
+
|
|
10
|
+
def fake_popen(args, **kwargs):
|
|
11
|
+
captured["args"] = args
|
|
12
|
+
captured["kwargs"] = kwargs
|
|
13
|
+
return "proc"
|
|
14
|
+
|
|
15
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
16
|
+
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
|
17
|
+
|
|
18
|
+
result = _detach.popen_detached(["echo", "hi"])
|
|
19
|
+
|
|
20
|
+
assert result == "proc"
|
|
21
|
+
assert captured["kwargs"]["start_new_session"] is True
|
|
22
|
+
assert "creationflags" not in captured["kwargs"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_popen_detached_uses_creationflags_on_windows(monkeypatch):
|
|
26
|
+
captured = {}
|
|
27
|
+
|
|
28
|
+
def fake_popen(args, **kwargs):
|
|
29
|
+
captured["kwargs"] = kwargs
|
|
30
|
+
return "proc"
|
|
31
|
+
|
|
32
|
+
monkeypatch.setattr(sys, "platform", "win32")
|
|
33
|
+
monkeypatch.setattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x200, raising=False)
|
|
34
|
+
monkeypatch.setattr(subprocess, "DETACHED_PROCESS", 0x8, raising=False)
|
|
35
|
+
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
|
36
|
+
|
|
37
|
+
result = _detach.popen_detached(["echo", "hi"])
|
|
38
|
+
|
|
39
|
+
assert result == "proc"
|
|
40
|
+
assert captured["kwargs"]["creationflags"] == 0x208
|
|
41
|
+
assert "start_new_session" not in captured["kwargs"]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Tests for the shared elicitation primitives.
|
|
2
|
+
|
|
3
|
+
Moved here from the two servers' test_elicit.py (where confirm/text were
|
|
4
|
+
duplicated). Each server keeps only its own composed helper's tests
|
|
5
|
+
(``repo_or_detect`` for witan, ``choose_repo`` for witan-code).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
from fastmcp.server.elicitation import AcceptedElicitation, DeclinedElicitation
|
|
11
|
+
|
|
12
|
+
from witan_core import elicit
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _AcceptCtx:
|
|
16
|
+
def __init__(self, data):
|
|
17
|
+
self._data = data
|
|
18
|
+
|
|
19
|
+
async def elicit(self, message, response_type=None, **kwargs):
|
|
20
|
+
return AcceptedElicitation(data=self._data)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _DeclineCtx:
|
|
24
|
+
async def elicit(self, message, response_type=None, **kwargs):
|
|
25
|
+
return DeclinedElicitation()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _RaiseCtx:
|
|
29
|
+
async def elicit(self, *args, **kwargs):
|
|
30
|
+
raise RuntimeError("unsupported")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_confirm_no_ctx_or_error_returns_default():
|
|
34
|
+
assert (
|
|
35
|
+
asyncio.run(elicit.confirm(None, "q?", default_when_unsupported=True)) is True
|
|
36
|
+
)
|
|
37
|
+
assert (
|
|
38
|
+
asyncio.run(elicit.confirm(None, "q?", default_when_unsupported=False)) is False
|
|
39
|
+
)
|
|
40
|
+
assert (
|
|
41
|
+
asyncio.run(elicit.confirm(_RaiseCtx(), "q?", default_when_unsupported=True))
|
|
42
|
+
is True
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_confirm_accept_and_decline():
|
|
47
|
+
assert (
|
|
48
|
+
asyncio.run(
|
|
49
|
+
elicit.confirm(_AcceptCtx(True), "q?", default_when_unsupported=False)
|
|
50
|
+
)
|
|
51
|
+
is True
|
|
52
|
+
)
|
|
53
|
+
# accepting with a False value is still a "no"
|
|
54
|
+
assert (
|
|
55
|
+
asyncio.run(
|
|
56
|
+
elicit.confirm(_AcceptCtx(False), "q?", default_when_unsupported=True)
|
|
57
|
+
)
|
|
58
|
+
is False
|
|
59
|
+
)
|
|
60
|
+
assert (
|
|
61
|
+
asyncio.run(elicit.confirm(_DeclineCtx(), "q?", default_when_unsupported=True))
|
|
62
|
+
is False
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_text_no_ctx_error_or_empty_returns_default():
|
|
67
|
+
assert asyncio.run(elicit.text(None, "q?", default="d")) == "d"
|
|
68
|
+
assert asyncio.run(elicit.text(_RaiseCtx(), "q?", default="d")) == "d"
|
|
69
|
+
assert asyncio.run(elicit.text(_AcceptCtx(""), "q?", default="d")) == "d"
|
|
70
|
+
# whitespace-only is treated as empty → default; a real value is stripped
|
|
71
|
+
assert asyncio.run(elicit.text(_AcceptCtx(" "), "q?", default="d")) == "d"
|
|
72
|
+
assert asyncio.run(elicit.text(_AcceptCtx(" real "), "q?", default="d")) == "real"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Package-level invariants.
|
|
2
|
+
|
|
3
|
+
The leaf-package invariant (importing witan_core must pull in neither server) is
|
|
4
|
+
load-bearing: it is what lets both servers depend on witan_core without a cycle.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_public_surface_is_exported():
|
|
11
|
+
import witan_core
|
|
12
|
+
|
|
13
|
+
assert "popen_detached" in witan_core.__all__
|
|
14
|
+
for name in witan_core.__all__:
|
|
15
|
+
assert hasattr(witan_core, name), name
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_no_cross_package_import():
|
|
19
|
+
"""witan_core must import neither witan nor witan_code (leaf invariant)."""
|
|
20
|
+
import witan_core # noqa: F401
|
|
21
|
+
|
|
22
|
+
assert "witan" not in sys.modules
|
|
23
|
+
assert "witan_code" not in sys.modules
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from witan_core import maintenance
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_resolve_interval_env_override(monkeypatch):
|
|
5
|
+
monkeypatch.delenv("X_INTERVAL", raising=False)
|
|
6
|
+
assert (
|
|
7
|
+
maintenance.resolve_interval("X_INTERVAL")
|
|
8
|
+
== maintenance.DEFAULT_OPTIMIZE_INTERVAL
|
|
9
|
+
)
|
|
10
|
+
monkeypatch.setenv("X_INTERVAL", "3600")
|
|
11
|
+
assert maintenance.resolve_interval("X_INTERVAL") == 3600.0
|
|
12
|
+
monkeypatch.setenv("X_INTERVAL", "0")
|
|
13
|
+
assert maintenance.resolve_interval("X_INTERVAL") == 0.0
|
|
14
|
+
# non-numeric falls back to the default
|
|
15
|
+
monkeypatch.setenv("X_INTERVAL", "nope")
|
|
16
|
+
assert maintenance.resolve_interval("X_INTERVAL", 42.0) == 42.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_mark_run_last_run_atomic_roundtrip(tmp_path):
|
|
20
|
+
stamp = tmp_path / "stamp.json"
|
|
21
|
+
maintenance.mark_run(stamp, 12345.0)
|
|
22
|
+
assert maintenance.last_run(stamp) == 12345.0
|
|
23
|
+
# no leftover temp files
|
|
24
|
+
assert not list(tmp_path.glob("*.tmp"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_last_run_missing_or_corrupt_is_zero(tmp_path):
|
|
28
|
+
assert maintenance.last_run(tmp_path / "absent.json") == 0.0
|
|
29
|
+
corrupt = tmp_path / "corrupt.json"
|
|
30
|
+
corrupt.write_text("{ not json")
|
|
31
|
+
assert maintenance.last_run(corrupt) == 0.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_is_due_branches(tmp_path):
|
|
35
|
+
store = tmp_path / "graph.omni"
|
|
36
|
+
store.mkdir()
|
|
37
|
+
stamp = tmp_path / "stamp.json"
|
|
38
|
+
kw = dict(store=str(store), stamp_file=stamp, now=10_000.0)
|
|
39
|
+
|
|
40
|
+
# disabled
|
|
41
|
+
assert maintenance.is_due(interval=0, require_exists=False, **kw) is False
|
|
42
|
+
# remote store never fires
|
|
43
|
+
assert (
|
|
44
|
+
maintenance.is_due(
|
|
45
|
+
store="https://example.com/g",
|
|
46
|
+
stamp_file=stamp,
|
|
47
|
+
interval=3600,
|
|
48
|
+
now=10_000.0,
|
|
49
|
+
require_exists=False,
|
|
50
|
+
)
|
|
51
|
+
is False
|
|
52
|
+
)
|
|
53
|
+
# missing store when require_exists
|
|
54
|
+
assert (
|
|
55
|
+
maintenance.is_due(
|
|
56
|
+
store=str(tmp_path / "absent.omni"),
|
|
57
|
+
stamp_file=stamp,
|
|
58
|
+
interval=3600,
|
|
59
|
+
now=10_000.0,
|
|
60
|
+
require_exists=True,
|
|
61
|
+
)
|
|
62
|
+
is False
|
|
63
|
+
)
|
|
64
|
+
# due when never run
|
|
65
|
+
assert maintenance.is_due(interval=3600, require_exists=True, **kw) is True
|
|
66
|
+
# not due right after a run; due again after the window
|
|
67
|
+
maintenance.mark_run(stamp, 10_000.0)
|
|
68
|
+
assert (
|
|
69
|
+
maintenance.is_due(
|
|
70
|
+
interval=3600,
|
|
71
|
+
require_exists=True,
|
|
72
|
+
store=str(store),
|
|
73
|
+
stamp_file=stamp,
|
|
74
|
+
now=10_100.0,
|
|
75
|
+
)
|
|
76
|
+
is False
|
|
77
|
+
)
|
|
78
|
+
assert (
|
|
79
|
+
maintenance.is_due(
|
|
80
|
+
interval=3600,
|
|
81
|
+
require_exists=True,
|
|
82
|
+
store=str(store),
|
|
83
|
+
stamp_file=stamp,
|
|
84
|
+
now=14_000.0,
|
|
85
|
+
)
|
|
86
|
+
is True
|
|
87
|
+
)
|