sites: the instance writes its own site handlers as JSON files under sites/, the stack's learn.microsoft.com handlers become data files in the same format (#210); 10.0.0-beta.4
CI / verify (push) Successful in 8m26s
CI / pwsh (push) Successful in 2m35s
Release / release (push) Successful in 35s
Release / wiki-mirror (push) Skipped

Files changed:
- .gitignore
- AGENTS.md
- CHANGES.md
- VERSION
- docs/explanation/architecture.md
- docs/how-to/README.md
- docs/how-to/write-a-site-handler.md
- docs/reference/commands/instance-health.md
- docs/reference/commands/raw-material-and-uploads.md
- docs/reference/configuration.md
- instructions/dev/doc-pull-through.md
- raw/CONTRACT.md
- sites/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- 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/raw/fetch.py
- tools/chemenu/ownership.py
- tools/chemenu/sites/__init__.py
- tools/chemenu/sites/data.py
- tools/chemenu/sites/handlers/learn-microsoft-powershell-module.json
- tools/chemenu/sites/handlers/learn-microsoft.json
- tools/chemenu/sites/learn_microsoft.py
- tools/chemenu/sites/site.schema.json
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_site_files.py
- tools/chemenu/toc.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Code committed 2026-10-11 15:17:17 +02:00
1 parent 2213d68900
commit 0f39cfdb49
31 files changed
+916 -124

No files matched your search

+10 -1
View File
@@ -155,7 +155,7 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
(docs_dir / "dev").mkdir()
(docs_dir / "dev" / "development.md").write_text("# Developing this stack\n", encoding="utf-8")
for relative in ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md"):
for relative in ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md", "sites/CONTRACT.md"):
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"# {relative}\n", encoding="utf-8")
@@ -405,6 +405,7 @@ def test_find_leaks_is_silent_on_a_clean_plan(repo):
"instructions/dev/commonplace-kb.md",
"kb/entities/aurora.md",
"raw/notes/personal-note.md",
"sites/example.json",
# Both bind every page and both are the instance's to write, so the
# filled name must never cross - only the `.template` beside it does.
"kb/CONVENTIONS.md",
@@ -453,6 +454,14 @@ def test_plan_ships_collection_contracts_as_templates_not_pages(repo):
assert plan["kb/entities/COLLECTION.md.template"].content == "# entities collection\n"
def test_plan_ships_the_sites_contract_and_no_handler_file_of_the_instance(repo):
(repo / "sites").mkdir(exist_ok=True)
(repo / "sites" / "example.json").write_text("{}", encoding="utf-8")
plan = dist_cmd.build_plan()
assert "sites/CONTRACT.md" in plan
assert [relative for relative in plan if relative.startswith("sites/")] == ["sites/CONTRACT.md"]
def test_plan_ships_the_conventions_template_and_not_the_filled_file(repo):
plan = dist_cmd.build_plan()
assert "kb/CONVENTIONS.md.template" in plan
+1 -1
View File
@@ -61,7 +61,7 @@ def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
(root / "SOUL.md").write_text("# SOUL.md - Fixture\n", encoding="utf-8")
for relative in ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md",
"instructions/CONTRACT.md", "types/type-spec.md"):
"sites/CONTRACT.md", "instructions/CONTRACT.md", "types/type-spec.md"):
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("# contract\n", encoding="utf-8")
+289
View File
@@ -0,0 +1,289 @@
"""The instance's own site handlers: JSON files in `sites/` (`chemenu.sites.data`).
The stack's handlers are the same kind of file, so the checks of `test_sites.py`
over the Learn handlers cover the format itself; this module is about what is
particular to a file the instance wrote - that it takes effect, that it can replace
a stack handler, and that one that cannot be used stops the fetch instead of being
skipped.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
import typer
import yaml
from chemenu import sites, web_capture
from chemenu.commands import doctor
from chemenu.commands.raw.fetch import raw_fetch_command
from chemenu.errors import ValidationError
from chemenu.tests.conftest import write_settings
PAGE = (Path(__file__).parent / "fixtures/learn_microsoft/oslicense-module.html").read_bytes()
EXAMPLE = {
"host": "docs.example.org",
"summary": "Example docs: drops the action bar and says the source is elsewhere.",
"drop": [{"id": "action-panel"}, {"attr": "data-list-type", "value": "plan"}],
"checks": [{"text": "The page keeps its source at {meta}.", "meta": "original_content_git_url"}],
}
@pytest.fixture
def tree(kb_dir):
root = kb_dir.parent
(root / "raw").mkdir()
(root / "incoming").mkdir(exist_ok=True)
return root
@pytest.fixture
def served(monkeypatch):
asked: list[str] = []
def fake(url, user_agent, timeout=0, max_bytes=0):
asked.append(url)
return web_capture.Response(url, url, 200, "text/html; charset=utf-8", PAGE)
monkeypatch.setattr(web_capture, "fetch", fake)
return asked
def _handler(root: Path, name: str, content) -> Path:
directory = root / "sites"
directory.mkdir(exist_ok=True)
path = directory / f"{name}.json"
path.write_text(content if isinstance(content, str) else json.dumps(content), encoding="utf-8")
return path
def _fetch(url, name=None, html=None):
return raw_fetch_command(url=None if html else url, html=html, source_url=url if html else None, name=name)
def _refused(url, **kwargs) -> None:
with pytest.raises(typer.Exit) as excinfo:
_fetch(url, **kwargs)
assert excinfo.value.exit_code == 1
def _md(path: Path) -> tuple[dict, str]:
head, _, text = path.read_text(encoding="utf-8")[4:].partition("\n---\n")
return yaml.safe_load(head), text
# --- a handler of the instance takes effect ----------------------------------
def test_an_instance_handler_cleans_the_page_and_says_so_in_the_header(tree, served, capsys):
_handler(tree, "example", EXAMPLE)
_fetch("https://docs.example.org/guide", name="guide")
header, text = _md(tree / "incoming/guide.md")
assert header["site"] == "example (instance)"
assert "Add to Plans" not in text and "Exit editor mode" in text
checks = [line for line in capsys.readouterr().out.splitlines() if line.startswith("CHECK ")]
assert len(checks) == 1 and "docs-source/blob/live" in checks[0]
def test_an_instance_handler_rewrites_the_url_like_a_stack_handler(tree, served):
_handler(tree, "example", {
**EXAMPLE,
"rewrites": [{"pattern": r"^(https://docs\.example\.org/)[a-z]{2}/", "replacement": r"\1{locale}/"}],
})
_fetch("https://docs.example.org/de/guide", name="guide")
header, _ = _md(tree / "incoming/guide.md")
assert served == ["https://docs.example.org/en-us/guide"]
assert header["requested_url"] == "https://docs.example.org/de/guide"
def test_a_handler_for_another_host_does_not_touch_the_page(tree, served):
_handler(tree, "example", EXAMPLE)
_fetch("https://example.org/guide", name="guide")
header, text = _md(tree / "incoming/guide.md")
assert "site" not in header and "Add to Plans" in text
def test_the_handler_applies_to_a_saved_page_too(tree, monkeypatch):
monkeypatch.setattr(web_capture, "fetch", lambda *a, **k: pytest.fail("no network"))
_handler(tree, "example", EXAMPLE)
saved = tree / "incoming/guide.html"
saved.write_bytes(PAGE)
_fetch("https://docs.example.org/guide", html=saved)
header, text = _md(tree / "incoming/guide.md")
assert header["site"] == "example (instance)" and "Add to Plans" not in text
def test_an_instance_handler_may_inherit_from_a_stack_handler(tree, served):
_handler(tree, "learn-extra", {
"host": "learn.microsoft.com",
"summary": "Also drops the plan button.",
"parent": "learn-microsoft",
"path": "/en-us/azure/",
"drop": [{"attr": "data-list-type", "value": "plan"}],
})
_fetch("https://learn.microsoft.com/en-us/azure/overview", name="azure")
header, text = _md(tree / "incoming/azure.md")
assert header["site"] == "learn-extra (instance)"
assert "Add to Plans" not in text and "Exit editor mode" not in text
def test_without_a_sites_directory_nothing_changes(tree):
preferences = sites.read_preferences(tree)
assert preferences.instance == () and preferences.broken == ()
# --- replacing a stack handler -----------------------------------------------
def test_a_handler_for_the_same_host_and_path_replaces_the_stack_handler(tree, served):
_handler(tree, "mine", {"host": "learn.microsoft.com", "summary": "Keeps everything.", "drop": [{"id": "action-panel"}]})
_fetch("https://learn.microsoft.com/en-us/azure/overview", name="azure")
header, text = _md(tree / "incoming/azure.md")
assert header["site"] == "mine (instance)"
assert "Exit editor mode" in text
assert "learn-microsoft" not in [s.name for s in sites.catalogue(sites.read_preferences(tree).instance)]
def test_the_stack_handlers_that_inherited_from_a_replaced_one_keep_what_they_inherited(tree, served):
_handler(tree, "mine", {"host": "learn.microsoft.com", "summary": "Replaces the general one."})
_fetch("https://learn.microsoft.com/de-de/powershell/module/oslicense/", name="module")
header, text = _md(tree / "incoming/module.md")
assert header["site"] == "learn-microsoft-powershell-module"
assert "Exit editor mode" not in text
def test_doctor_lists_the_replacement(tree):
_handler(tree, "mine", {"host": "learn.microsoft.com", "summary": "Replaces the general one."})
check = doctor.check_site_handlers()
assert check.status == "OK"
assert "mine (replaces the stack's learn-microsoft)" in check.detail
def test_a_replaced_stack_handler_can_no_longer_be_disabled_by_its_name(tree, monkeypatch):
_handler(tree, "mine", {"host": "learn.microsoft.com", "summary": "Replaces the general one."})
write_settings(tree, monkeypatch, sites={"disabled": ["learn-microsoft"]})
with pytest.raises(ValidationError, match="no handler has"):
sites.read_preferences(tree)
# --- a file that cannot be used ----------------------------------------------
BROKEN = {
"unknown field": {**EXAMPLE, "colour": "red"},
"missing summary": {"host": "docs.example.org"},
"a rule that matches everything": {**EXAMPLE, "drop": [{"inside": "td"}]},
"a value without an attr": {**EXAMPLE, "drop": [{"tag": "a", "value": "x"}]},
"more than three checks": {**EXAMPLE, "checks": [{"text": "a"}] * 4},
"a pattern that does not compile": {**EXAMPLE, "rewrites": [{"pattern": "(", "replacement": "x"}]},
"a replacement naming a group that is not there": {**EXAMPLE, "rewrites": [{"pattern": "a", "replacement": r"\1"}]},
"an upper-case host": {**EXAMPLE, "host": "Docs.Example.org"},
"a placeholder without its field": {**EXAMPLE, "checks": [{"text": "see {count}"}]},
"a parent that does not exist": {**EXAMPLE, "parent": "nobody"},
"a parent for another host": {**EXAMPLE, "parent": "learn-microsoft"},
}
@pytest.mark.parametrize("case", sorted(BROKEN))
def test_a_file_that_breaks_a_rule_fails_doctor_and_stops_a_fetch_of_its_host(tree, served, case):
_handler(tree, "example", BROKEN[case])
host = BROKEN[case]["host"].lower()
check = doctor.check_site_handlers()
assert check.status == "FAIL" and "sites/example.json" in check.detail
_refused(f"https://{host}/guide", name="guide")
assert served == [] and list((tree / "incoming").iterdir()) == []
def test_a_stack_name_for_another_host_is_refused(tree, served):
_handler(tree, "learn-microsoft", {"host": "other.example.org", "summary": "x"})
check = doctor.check_site_handlers()
assert check.status == "FAIL" and "another host or path" in check.detail
_refused("https://other.example.org/guide", name="guide")
assert served == []
def test_a_file_that_is_not_json_stops_every_fetch(tree, served):
_handler(tree, "example", "{not json")
assert doctor.check_site_handlers().status == "FAIL"
_refused("https://example.org/guide", name="guide")
assert served == [] and list((tree / "incoming").iterdir()) == []
def test_a_broken_file_for_another_host_does_not_stop_a_fetch(tree, served):
_handler(tree, "example", {**EXAMPLE, "colour": "red"})
_fetch("https://example.org/guide", name="guide")
assert served == ["https://example.org/guide"]
def test_a_saved_page_is_refused_the_same_way(tree):
_handler(tree, "example", {**EXAMPLE, "colour": "red"})
saved = tree / "incoming/guide.html"
saved.write_bytes(PAGE)
_refused("https://docs.example.org/guide", html=saved)
assert not (tree / "incoming/guide.md").exists()
def test_a_file_whose_name_is_not_a_handler_name_is_broken(tree):
_handler(tree, "My Handler", EXAMPLE)
assert doctor.check_site_handlers().status == "FAIL"
def test_two_files_for_the_same_host_and_path_are_an_error(tree):
_handler(tree, "a", EXAMPLE)
_handler(tree, "b", EXAMPLE)
check = doctor.check_site_handlers()
assert check.status == "FAIL" and "sites/b.json" in check.detail and "same host and path" in check.detail
assert [s.name for s in sites.read_instance(tree)[0]] == ["a"]
def test_parents_that_form_a_cycle_are_broken(tree):
_handler(tree, "a", {**EXAMPLE, "path": "/a", "parent": "b"})
_handler(tree, "b", {**EXAMPLE, "path": "/b", "parent": "a"})
instance, broken = sites.read_instance(tree)
assert instance == () and {b.file for b in broken} == {"sites/a.json", "sites/b.json"}
def test_a_child_of_a_broken_parent_is_broken_too(tree):
_handler(tree, "base", {**EXAMPLE, "colour": "red"})
_handler(tree, "child", {**EXAMPLE, "path": "/x", "parent": "base"})
instance, broken = sites.read_instance(tree)
assert instance == () and {b.file for b in broken} == {"sites/base.json", "sites/child.json"}
def test_a_symlink_is_not_followed(tree):
target = tree / "elsewhere.json"
target.write_text(json.dumps(EXAMPLE), encoding="utf-8")
(tree / "sites").mkdir()
(tree / "sites" / "example.json").symlink_to(target)
assert doctor.check_site_handlers().status == "FAIL"
def test_doctor_is_quiet_without_a_sites_directory(tree):
assert doctor.check_site_handlers().status == "OK"
# --- the stack's handlers are files, too -------------------------------------
def test_the_stack_handlers_are_data_files_that_pass_their_own_schema():
from chemenu.sites import data
names = {path.stem for path in data.STACK_DIRECTORY.glob("*.json")}
assert {"learn-microsoft", "learn-microsoft-powershell-module"} <= names
assert {s.name for s in sites.all_sites()} >= names
assert all(s.origin == "stack" for s in sites.all_sites())