Files
chemenu/tools/chemenu/config.py
T
torben 576df2cddd
CI / verify (push) Successful in 52s
Release / release (push) Successful in 37s
feat: MCP-Leseserver, Bibliotheksgrenze, Haertung des Lesepfads, Publish-Remote-Gate scharf (2.4.0)
Files changed:
- .gitea/workflows/ci.yml
- CHANGES.md
- README.md
- VERSION
- instructions/mcp-read-server.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/api.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/lint.py
- tools/chemenu/commands/search.py
- tools/chemenu/commands/types_cmd.py
- tools/chemenu/config.py
- tools/chemenu/corpus_cache.py
- tools/chemenu/errors.py
- tools/chemenu/frontmatter_io.py
- tools/chemenu/lint_core.py
- tools/chemenu/mcp/__init__.py
- tools/chemenu/mcp/__main__.py
- tools/chemenu/mcp/server.py
- tools/chemenu/page.py
- tools/chemenu/search/filters.py
- tools/chemenu/search/registry.py
- tools/chemenu/search/ripgrep.py
- tools/chemenu/search/service.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_api.py
- tools/chemenu/tests/test_corpus_cache.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_frontmatter_io.py
- tools/chemenu/tests/test_instructions_cmd.py
- tools/chemenu/tests/test_mcp_server.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_search.py
- tools/chemenu/type_resolver.py
- tools/chemenu/types_core.py
- tools/requirements-mcp.txt
2026-09-02 07:19:32 +02:00

259 lines
11 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",),
# 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")
# 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 upstream, 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"
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
try:
result = subprocess.run(
["git", "config", "user.name"],
cwd=_root(),
capture_output=True,
text=True,
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