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.
@@ -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
+ )