Files
chemenu/tools/chemenu/config.py
T
torben a6d07f97c4
CI / verify (push) Successful in 5m19s
CI / pwsh (push) Successful in 1m55s
Release / release (push) Successful in 36s
feat!: installation only from a release, into an empty folder; upstream merge/verify and private-instance.md removed, dist adopt, shell-neutral instructions (#153)
Files changed:
- .gitea/workflows/ci.yml
- .gitea/workflows/release.yml
- AGENTS.md
- CHANGES.md
- DEVELOPMENT.md
- EVALS.md
- INSTALL.md
- README.md
- VERSION
- docs/ownership-and-templates.md
- instructions/CONTRACT.md
- instructions/bootstrap.md
- instructions/dev/dev-setup.md
- instructions/dev/stack-dev/SKILL.md
- instructions/gates.md
- instructions/ingest-large-tree.md
- instructions/kb-profiles.md
- instructions/mcp-read-server.md
- instructions/migrations/3.0.0-authoring-conventions.md
- instructions/preflight.md
- instructions/private-instance.md
- instructions/session-setup.md
- instructions/setup-instance.md
- instructions/upgrade-instance.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/cli.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/upstream_cmd.py
- tools/chemenu/commands/work_cmd.py
- tools/chemenu/config.py
- tools/chemenu/ownership.py
- tools/chemenu/tests/test_cli.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_instructions_shell.py
- tools/chemenu/tests/test_preflight.py
- tools/chemenu/tests/test_preflight_pwsh.py
- tools/chemenu/tests/test_run_budget.py
- tools/chemenu/tests/test_upstream_cmd.py
- tools/chemenu/toc.py
- tools/preflight.ps1
- tools/preflight.sh
2026-10-01 22:12:09 +02:00

330 lines
15 KiB
Python

"""Repo layout constants for Chemenu, mirroring AGENTS.md.
The repo is a pipeline: `raw/` (untrusted input) -> `types/` + `tools/` (schema and
compiler) -> `kb/` (compiled knowledge) -> `reports/` (derived output). Only `kb/` is
divided into collections; the other three stages are single-purpose directories.
Repo root is resolved in three steps - an explicit argument to `resolve_root()`, then
`$CHEMENU_ROOT`, then a walk up from this file's location (tools/chemenu/config.py ->
tools/ -> repo root). The walk-up stays the default, so `tools/wikitool` behaves exactly
as it always has; the two steps in front of it are what lets an in-process caller point
this package at a corpus it does not itself live inside.
**Nothing below is bound at import time.** `ROOT` and every path derived from it are
resolved on each attribute access, through the module `__getattr__` at the bottom. They
used to be module constants, which had a failure mode worse than the limitation itself:
`monkeypatch.setattr(config, "ROOT", other)` repointed `ROOT` and left `KB_DIR` and
`RAW_DIR` aimed at wherever this file happens to sit, so a caller that believed it was
working on a target tree was in fact answering out of the developer's checkout. Resolving
on access makes the derived paths follow whatever `ROOT` currently is - including a
monkeypatched one - so the half-repointed state cannot be constructed.
"""
import os
import subprocess
from contextlib import contextmanager
from pathlib import Path
# The last resort, and the default every existing caller gets: the checkout this
# file is part of.
_PACKAGE_ROOT = Path(__file__).resolve().parents[2]
# Points this package at a corpus other than its own checkout. Registered in
# `_WIKITOOL_ENV` (tools/chemenu/tests/conftest.py), so the suite runs with it
# cleared and a test that wants it sets it itself.
ENV_ROOT = "CHEMENU_ROOT"
def resolve_root(explicit: "Path | str | None" = None) -> Path:
"""The repo root, by the documented precedence: argument, then
`$CHEMENU_ROOT`, then the checkout this package lives in.
An explicit argument wins because a caller serving two corpora cannot use a
process-wide variable to tell them apart; the variable exists for the case
where the caller is a whole process (a server, a CI job) and there is
nothing to pass it through.
"""
if explicit is not None:
return Path(explicit).expanduser().resolve()
from_env = os.environ.get(ENV_ROOT, "").strip()
if from_env:
return Path(from_env).expanduser().resolve()
return _PACKAGE_ROOT
def _root() -> Path:
"""`ROOT` as it stands right now, honouring an assignment onto this module.
Reads the module dict directly rather than `resolve_root()` so that a test
(or any caller) setting `config.ROOT` is what the derived paths follow.
That assignment is why the derived paths are computed here at all.
"""
assigned = globals().get("ROOT")
return Path(assigned) if assigned is not None else resolve_root()
# Everything under the root, as a name -> relative-path table rather than as
# assignments. One place to read, and the only place that has to know a derived
# path exists at all.
_DERIVED = {
"RAW_DIR": ("raw",),
"KB_DIR": ("kb",),
"TYPES_DIR": ("types",),
"REPORTS_DIR": ("reports",),
"WORK_DIR": ("work",),
"INSTRUCTIONS_DIR": ("instructions",),
# The MCP upload quarantine (Gitea #32) - never `raw/` and never `incoming/`,
# see raw/CONTRACT.md "Getting a file in". Gitignored; the server process is
# the only writer.
"UPLOAD_DIR": ("mcp-upload",),
# Generated copies of the skill directories under `instructions/`. Both are
# gitignored: they are build output, and a fresh clone publishes them with
# `wikitool instructions sync` (see instructions/bootstrap.md).
"AGENTS_SKILLS_DIR": (".agents", "skills"),
"CLAUDE_SKILLS_DIR": (".claude", "skills"),
}
# The generated files, derived from `KB_DIR` rather than from the root: a
# caller that repoints only the corpus directory must not be left with a log
# and a catalog belonging to a different tree.
_KB_DERIVED = {
"INDEX_FILE": "index.md",
"LOG_FILE": "log.md",
"PROVENANCE_FILE": "provenance.md",
}
# Every name this module resolves rather than stores. Assigning one is
# supported - that is what makes the paths repointable at all - but the
# assignment has to be taken back afterwards, or it outlives the caller that
# made it. See `reset()`.
MANAGED_PATHS = ("ROOT", *_DERIVED, *_KB_DERIVED)
@contextmanager
def rooted(root: "Path | str"):
"""Resolve every managed path under `root` for the duration of the block.
Some things below the read core reach for `config` directly rather than
taking a root - the module-level `TypeResolver` singleton, which has to
find `types/`, is the one that matters - so pointing this package at
another corpus means pointing `config` at it, not only the functions that
accept an argument.
**Process-wide while it is open, and therefore not thread-safe.** A caller
serving several corpora at once holds a lock around it, the same discipline
`CorpusCache` documents. That is a real constraint and not a hidden one:
`$CHEMENU_ROOT` is process-wide for the same reason, and the server this
exists for (Gitea #19) serves one checkout that a `git reset --hard` keeps
clean.
Restores exactly what was there, including "nothing was assigned" - it must
not leave `ROOT` bound behind it, or it recreates the stale-binding bug in
the shape `reset()` describes.
"""
previous = {name: globals()[name] for name in MANAGED_PATHS if name in globals()}
reset()
globals()["ROOT"] = Path(root)
try:
yield Path(root)
finally:
reset()
globals().update(previous)
def reset() -> None:
"""Drop every assignment onto a managed path name, back to resolution.
The test suite calls this between tests, and it is not optional there.
`monkeypatch.setattr(config, "KB_DIR", tmp)` records the old value by
*reading* it - which resolves it - and its undo then writes that resolved
path back as a real attribute. The name is bound from then on, so the next
caller to repoint only `ROOT` gets a `KB_DIR` still aimed at the previous
tree: exactly the half-repointed state this module was rewritten to make
unconstructible, rebuilt by the cleanup rather than by the test.
"""
for name in MANAGED_PATHS:
globals().pop(name, None)
def __getattr__(name: str):
"""Resolve `ROOT` and the paths under it on access (PEP 562).
Only reached for names *not* in the module dict, so an explicit assignment
- `monkeypatch.setattr(config, "ROOT", tmp)` - keeps working and now also
carries the derived paths with it, which is the bug this replaces.
"""
if name == "ROOT":
return resolve_root()
if name in _DERIVED:
return _root().joinpath(*_DERIVED[name])
if name in _KB_DERIVED:
kb_dir = globals().get("KB_DIR")
base = Path(kb_dir) if kb_dir is not None else _root() / "kb"
return base / _KB_DERIVED[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__() -> list[str]:
return sorted([*globals(), "ROOT", *_DERIVED, *_KB_DERIVED])
# Files/patterns to ignore when scanning raw/ for ingest coverage.
# CONTRACT.md is the layer's source contract, not source material.
RAW_IGNORE_NAMES = {".gitkeep", ".DS_Store", "CONTRACT.md"}
# Per-instance personalization: who operates this wiki (`USER.md`) and how this
# instance sounds while doing it (`SOUL.md`). Both are read every session and
# are therefore an operating requirement - but their content belongs to one
# instance and one person, so `dist export` ships only the `.template` files
# and the Personalization step of instructions/setup-instance.md fills them in.
# `wikitool doctor` FAILs on a missing file, and on one that still carries the
# sentinel - a renamed template is not a filled one.
PERSONALIZATION_FILES = ("USER.md", "SOUL.md")
PERSONALIZATION_TEMPLATES = tuple(f"{name}.template" for name in PERSONALIZATION_FILES)
TEMPLATE_SENTINEL = "wikitool:template-unfilled"
# Per-checkout environment notes: which harness, skills, MCP servers,
# connectors and remotes this working copy actually works through. Constant
# for long stretches, but re-asked every session as long as nothing records
# them - which is the whole reason the file exists.
#
# Unlike the personalization pair it is **optional**: a checkout without it
# works, it just answers those questions the slow way, so `doctor` reports it
# and never FAILs on it. It is gitignored rather than committed, because two
# clones of the same repo are two different environments; the template ships
# with `dist export` the same way the personalization templates do.
ENVIRONMENT_FILE = "ENVIRONMENT.md"
ENVIRONMENT_TEMPLATE = f"{ENVIRONMENT_FILE}.template"
# The repository is dual-licensed, and both halves travel with every export:
# `LICENSE` (AGPL-3.0) covers the stack, `LICENSE-CONTENT` (CC-BY-4.0) covers
# the content, `NOTICE` names the boundary and the third-party attribution the
# CC-BY terms require. `LICENSE` carries the copyleft half because that is what
# a forge reports for the repository, and a reader who under-notices a copyleft
# obligation is harmed in a way one who over-notices it is not.
#
# Which half a given file belongs to is not restated anywhere: it is the plan
# `dist export` already computes (AGENTS.md invariant 8). See NOTICE.
LICENSE_FILES = ("LICENSE", "LICENSE-CONTENT", "NOTICE")
# Written by `dist export` into every distribution; presence means "this tree
# is an exported instance", absence means "this is the dev checkout the stack
# ships from". Defined here rather than only in `version.py`, because
# `chemenu.telemetry` (`telemetry/policy.py`) needs the same marker to answer
# opt-in vs. opt-out and must stay importable without the venv - a hook
# handler imports it on every tool call. `version.py` re-exports this name
# rather than defining its own, per AGENTS.md invariant 8.
RELEASE_STAMP_FILENAME = ".wikitool-release.json"
# Per-checkout telemetry opt-in/opt-out plus its two quantity caps (see
# `telemetry/policy.py`). Same shape as `PUBLISH_REMOTES_FILENAME` below: it
# answers a question about *this* checkout, so it is per-checkout and
# gitignored, ships no `.template`, and its absence is a legitimate state -
# the installation-form default (keyed off `RELEASE_STAMP_FILENAME`) applies.
# `instructions/setup-instance.md`'s Telemetry decision point writes it from
# the operator's answer.
TELEMETRY_FILENAME = ".wikitool-telemetry.json"
# Which push targets `publish` may write to, for a checkout that says so. The
# danger this addresses is one checkout's content reaching another checkout's
# remote - a private instance pushing its own `kb/` to a public repository,
# where it cannot be taken back.
#
# It pins **URLs, not remote names**: a name-based list would pass a `publish`
# whose `origin` had been repointed, which is the failure it exists to catch.
#
# Per-checkout and gitignored, like `ENVIRONMENT.md` and for the same reason:
# two clones of this repo push to two different places, so a committed copy
# would hand the second one an answer that is wrong rather than missing. Absent
# means unrestricted - `doctor` reports it, and the Publish-Remote Gate simply
# does not apply. A checkout that holds private content should have one; see
# instructions/gates.md.
PUBLISH_REMOTES_FILENAME = ".wikitool-remotes.json"
# Opt-in for the MCP server's `submit` tool (Gitea #32): identity header name,
# size deckel, extension allowlist, per-submitter quota. Same shape as the two
# above - per-checkout, gitignored, no `.template` - but its absence means
# something stronger than "unrestricted": **the write path does not exist at
# all**, the tool is not registered. The safe direction, and a structural
# opt-in rather than a flag - see `chemenu.upload.read_config`.
UPLOAD_CONFIG_FILENAME = ".wikitool-upload.json"
# The task-tracker provider opt-in (Gitea #124, D25/D30): which provider this
# instance's GTD review reads/writes through, its connection details, and the
# review's three staleness thresholds. Same shape as the three files above -
# per-checkout, gitignored once a credential lands in it, no `.template` - and
# its absence is a legitimate state, the same posture `UPLOAD_CONFIG_FILENAME`
# takes: an instance with no tracker configured runs `doctor` and everything
# else just fine, it only can't run the weekly review (#125, not yet built).
TASKS_CONFIG_FILENAME = ".wikitool-tasks.json"
# Reads a tracker configuration from another file than `<root>/.wikitool-tasks.json`
# (Gitea #156), so one checkout can be run against several trackers one after the
# other without swapping a file. Registered in `_WIKITOOL_ENV`
# (tools/chemenu/tests/conftest.py). A relative value is taken from the working
# directory. Set but pointing at nothing is an error, never "no tracker".
ENV_TASKS_CONFIG = "WIKITOOL_TASKS_CONFIG"
def tasks_config_override() -> "Path | None":
"""The file `$WIKITOOL_TASKS_CONFIG` names, or `None` when it is unset or blank."""
value = os.environ.get(ENV_TASKS_CONFIG, "").strip()
return Path(value).expanduser().resolve() if value else None
def tasks_config_path(root: "Path | str") -> Path:
"""The tracker configuration file this process reads: the override when set,
else `<root>/.wikitool-tasks.json`."""
override = tasks_config_override()
return override if override is not None else Path(root) / TASKS_CONFIG_FILENAME
def tasks_config_label(root: "Path | str") -> str:
"""How a message names the file `tasks_config_path` reads - the bare file
name by default, the actual path and the variable when the override is on."""
override = tasks_config_override()
if override is None:
return TASKS_CONFIG_FILENAME
return f"{override} (from {ENV_TASKS_CONFIG})"
def default_author() -> str | None:
"""The author to stamp a new source page with, per instance.
`$WIKI_AUTHOR` overrides; otherwise this instance's own `git config
user.name` (there is no separate author config - identity lives in git,
the way `wikitool doctor` and `instructions/setup-instance.md` set it
up). Returns None if neither resolves, so the caller can fail loudly
instead of silently stamping a placeholder.
"""
override = os.environ.get("WIKI_AUTHOR", "").strip()
if override:
return override
from chemenu import toolpaths # local: toolpaths imports this module
try:
result = subprocess.run(
[toolpaths.git(), "config", "user.name"],
cwd=_root(),
capture_output=True,
text=True,
encoding="utf-8",
timeout=5,
check=False,
)
except (OSError, subprocess.SubprocessError):
return None
name = result.stdout.strip()
return name or None
def iter_raw_files(raw_dir: Path):
"""Yield every real file under raw_dir (recursively), skipping dotfiles and
the ignore list. Directories are never yielded - only concrete files."""
for path in sorted(raw_dir.rglob("*")):
if not path.is_file():
continue
if path.name in RAW_IGNORE_NAMES or path.name.startswith("."):
continue
yield path