Files
chemenu/tools/chemenu/telemetry/policy.py
T
torbenandClaude Opus 5.5 893db6e640
CI / verify (push) Successful in 6m17s
CI / pwsh (push) Successful in 2m0s
Release / release (push) Successful in 34s
feat: the shapes of the four hand-written .wikitool-*.json files live in one record each (config_shapes.py); loaders, doctor and docs/reference/configuration.md read them (#193)
The telemetry, remotes, upload and tasks loaders take field names, defaults, coercions and their Expected: text from the record and accept exactly what they did before. doctor WARNs on a telemetry file the policy silently reads differently. configuration.md carries one generated region per file, also checked in an instance. docs reference --apply now refreshes several regions in one page in sequence instead of keeping only the last.

Files changed:
- CHANGES.md
- VERSION
- docs/how-to/set-up-the-mcp-server.md
- docs/reference/README.md
- docs/reference/commands/instance-health.md
- docs/reference/commands/types-instructions-and-docs.md
- docs/reference/configuration.md
- instructions/dev/doc-pull-through.md
- instructions/setup-instance.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/config.py
- tools/chemenu/config_shapes.py
- tools/chemenu/reference_doc.py
- tools/chemenu/tasks/caldav.py
- tools/chemenu/tasks/config.py
- tools/chemenu/tasks/superproductivity.py
- tools/chemenu/telemetry/policy.py
- tools/chemenu/tests/test_config_shapes.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_reference_doc.py
- tools/chemenu/upload.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-07 08:44:50 +02:00

167 lines
5.7 KiB
Python

"""Telemetry on/off and its two quantity caps, resolved once per root.
One place to answer three questions, because `writer.py`, `wikitool doctor`
and the MCP server's start-up guard must agree on the same answer for the
same checkout - a second copy of this logic is exactly how the MCP server
used to drift from the writer (it read `WIKI_TRACE` itself; fixed alongside
this module, see AGENTS.md invariant 8).
**Precedence for `enabled`:** `WIKI_TRACE` (either direction) beats
`.wikitool-telemetry.json`, which beats the installation-form default. The
form is read off `config.RELEASE_STAMP_FILENAME`: present means an exported/
distributed tree (default *off* - an operator never asked for telemetry),
absent means the dev checkout this package ships from (default *on* - the
traces are this stack's own measuring instrument, see
docs/explanation/telemetry-and-evaluation.md).
**Caching.** Only the filesystem-derived half - the release stamp and the
config file - is cached, keyed on the resolved root. `WIKI_TRACE` and the two
`WIKI_TRACE_MAX_SESSION_BYTES`/`WIKI_TRACE_KEEP_SESSIONS` overrides are read
fresh on every call: they are cheap (no I/O) and a test that flips one
mid-run must see the new value immediately, not a cached one. A test that
rewrites the release stamp or the config file at an already-resolved root
calls `reset_cache()` itself, the same discipline `conventions.reset_cache()`
already follows.
Stdlib only, like the rest of `chemenu.telemetry` - a hook handler imports
this package on every tool call and must not need the venv.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from chemenu import config, config_shapes
# The file's shape - and these two defaults with it - is the record in
# `chemenu.config_shapes`, which is stdlib-only for exactly this import.
DEFAULT_MAX_SESSION_BYTES = config_shapes.TELEMETRY.field("max_session_bytes").default
DEFAULT_KEEP_SESSIONS = config_shapes.TELEMETRY.field("keep_sessions").default
ENV_ENABLED = "WIKI_TRACE"
ENV_MAX_SESSION_BYTES = "WIKI_TRACE_MAX_SESSION_BYTES"
ENV_KEEP_SESSIONS = "WIKI_TRACE_KEEP_SESSIONS"
@dataclass(frozen=True)
class Policy:
enabled: bool
reason: str # human-readable - what decided `enabled`, for `doctor`
max_session_bytes: int
keep_sessions: int
@dataclass(frozen=True)
class _RootDefaults:
form_default: bool
form_reason: str
file_enabled: bool | None
file_reason: str | None
file_max_bytes: int | None
file_keep_sessions: int | None
_cache: dict[Path, _RootDefaults] = {}
def reset_cache() -> None:
_cache.clear()
def _positive_int(value: object) -> int | None:
try:
parsed = int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def _given(checked: "config_shapes.Checked", name: str) -> int | None:
"""A cap the file sets and the record accepts, else None - so the
environment and the default decide, as for an absent file."""
return checked.values[name] if name in checked.given else None
def _read_config(root: Path) -> dict:
try:
raw = (root / config.TELEMETRY_FILENAME).read_text(encoding="utf-8")
except OSError:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {}
return data if isinstance(data, dict) else {}
def _root_defaults(root: Path) -> _RootDefaults:
cached = _cache.get(root)
if cached is not None:
return cached
distribution = (root / config.RELEASE_STAMP_FILENAME).is_file()
form_default = not distribution
form_reason = (
f"no {config.RELEASE_STAMP_FILENAME} - dev checkout, default on"
if form_default
else f"{config.RELEASE_STAMP_FILENAME} present - distributed instance, default off"
)
# Silent on the hot path: a value the record does not accept is dropped,
# never an error. `doctor` reports it (`config_shapes.deviations`).
checked = config_shapes.check(config_shapes.TELEMETRY, _read_config(root))
file_enabled = checked.values.get("enabled")
file_reason = (
f"{config.TELEMETRY_FILENAME} sets enabled={file_enabled}"
if file_enabled is not None
else None
)
result = _RootDefaults(
form_default=form_default,
form_reason=form_reason,
file_enabled=file_enabled,
file_reason=file_reason,
file_max_bytes=_given(checked, "max_session_bytes"),
file_keep_sessions=_given(checked, "keep_sessions"),
)
_cache[root] = result
return result
def resolve(root: "Path | str | None" = None) -> Policy:
"""The effective policy for `root` (default: `config.ROOT`)."""
resolved_root = Path(root).resolve() if root is not None else Path(config.ROOT).resolve()
defaults = _root_defaults(resolved_root)
env_enabled = os.environ.get(ENV_ENABLED)
if env_enabled is not None:
enabled = env_enabled != "0"
reason = f"{ENV_ENABLED}={env_enabled!r} overrides"
elif defaults.file_enabled is not None:
enabled = defaults.file_enabled
reason = defaults.file_reason # type: ignore[assignment]
else:
enabled = defaults.form_default
reason = defaults.form_reason
max_bytes = (
_positive_int(os.environ.get(ENV_MAX_SESSION_BYTES))
or defaults.file_max_bytes
or DEFAULT_MAX_SESSION_BYTES
)
keep_sessions = (
_positive_int(os.environ.get(ENV_KEEP_SESSIONS))
or defaults.file_keep_sessions
or DEFAULT_KEEP_SESSIONS
)
return Policy(
enabled=enabled,
reason=reason,
max_session_bytes=max_bytes,
keep_sessions=keep_sessions,
)