Files changed: - CHANGES.md - INSTALL.md - VERSION - docs/how-to/README.md - docs/how-to/fetch-pages-behind-a-login.md - docs/how-to/ingest-a-source.md - docs/reference/commands/README.md - docs/reference/commands/instance-health.md - docs/reference/commands/raw-material-and-uploads.md - docs/reference/configuration.md - docs/reference/exit-codes.md - instructions/preflight.md - instructions/wiki-ingest/SKILL.md - raw/CONTRACT.md - sites/CONTRACT.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/browser.py - tools/chemenu/cli_contract.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/raw/__init__.py - tools/chemenu/commands/raw/fetch.py - tools/chemenu/commands/raw/login.py - tools/chemenu/config_shapes.py - tools/chemenu/fetch_routes.py - tools/chemenu/reference_doc.py - tools/chemenu/settings.py - tools/chemenu/sites/__init__.py - tools/chemenu/sites/data.py - tools/chemenu/sites/site.schema.json - tools/chemenu/tests/test_cli.py - tools/chemenu/tests/test_config_shapes.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_preflight.py - tools/chemenu/tests/test_preflight_pwsh.py - tools/chemenu/tests/test_raw_fetch_routes.py - tools/chemenu/tests/test_raw_login.py - tools/chemenu/tests/test_site_files.py - tools/chemenu/web_capture.py - tools/chemenu/web_session.py - tools/preflight.ps1 - tools/preflight.sh - tools/requirements-browser.txt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
308 lines
12 KiB
Python
308 lines
12 KiB
Python
"""`chemenu.config_shapes` - one record per section of the tiered settings,
|
|
which the loaders read and `docs reference` renders.
|
|
|
|
The loaders take their field names, required fields, defaults, coercions and
|
|
tiers from the record: a test changes the record and the loader follows.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import dataclasses
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import typer
|
|
from typer.testing import CliRunner
|
|
|
|
from chemenu import blocks, config, config_shapes, reference_doc
|
|
from chemenu.cli import app
|
|
from chemenu.commands import docs_verify, git_publish
|
|
from chemenu.errors import ValidationError
|
|
from chemenu.tasks import caldav, superproductivity
|
|
from chemenu.tasks import config as tasks_config
|
|
from chemenu.telemetry import policy
|
|
from chemenu.tests.conftest import write_settings
|
|
|
|
runner = CliRunner()
|
|
|
|
PACKAGE = Path(config_shapes.__file__).parent
|
|
|
|
UPLOAD_OK = {
|
|
"identity_header": "X-User", "max_bytes": 1000, "allowed_extensions": [".md", ".PDF"],
|
|
"quota_submissions_per_day": 5, "quota_bytes_per_day": 5000,
|
|
}
|
|
TASKS_OK = {"provider": "caldav", "stalled_waiting_days": 14, "unpaged_project_weeks": 3,
|
|
"someday_stale_months": 5}
|
|
CALDAV_OK = {"url": "https://dav.example/", "username": "u", "app_password": "pw",
|
|
"inbox_list": "Inbox", "someday_list": "Someday"}
|
|
|
|
|
|
@pytest.fixture
|
|
def root(tmp_path, monkeypatch):
|
|
tree = tmp_path / "instance"
|
|
tree.mkdir()
|
|
monkeypatch.setattr(config, "ROOT", tree)
|
|
return tree
|
|
|
|
|
|
# --- the module itself -------------------------------------------------------
|
|
|
|
|
|
def test_the_module_imports_only_the_stdlib_and_chemenu_config():
|
|
"""A hook handler imports `chemenu.telemetry` on every tool call without
|
|
the venv, and the telemetry policy imports this module."""
|
|
tree = ast.parse(Path(config_shapes.__file__).read_text(encoding="utf-8"))
|
|
imported = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
imported |= {alias.name for alias in node.names}
|
|
elif isinstance(node, ast.ImportFrom):
|
|
if node.module == "chemenu":
|
|
imported |= {f"chemenu.{alias.name}" for alias in node.names}
|
|
elif node.module != "__future__":
|
|
imported.add(node.module)
|
|
foreign = {
|
|
name for name in imported
|
|
if name != "chemenu.config" and name.split(".")[0] not in sys.stdlib_module_names
|
|
}
|
|
assert foreign == set()
|
|
|
|
|
|
def test_the_telemetry_policy_reads_the_record():
|
|
assert policy.config_shapes is config_shapes
|
|
assert policy.DEFAULT_MAX_SESSION_BYTES == config_shapes.TELEMETRY.field("max_session_bytes").default
|
|
assert policy.DEFAULT_KEEP_SESSIONS == config_shapes.TELEMETRY.field("keep_sessions").default
|
|
|
|
|
|
def test_one_record_per_section():
|
|
assert [shape.key for shape in config_shapes.SETTINGS_SECTIONS] == [
|
|
"telemetry", "remotes", "upload", "tasks", "superproductivity", "caldav", "sites", "fetch",
|
|
]
|
|
|
|
|
|
LOADERS = ("telemetry/policy.py", "commands/git_publish.py", "upload.py", "tasks/config.py",
|
|
"tasks/superproductivity.py", "tasks/caldav.py")
|
|
|
|
|
|
def test_no_hand_written_expected_text_is_left_in_a_loader():
|
|
pattern = re.compile(r"_EXPECTED_\w*\s*=|\bexpected\s*=\s*\(|Expected: \{\\?\"")
|
|
offenders = [
|
|
relative for relative in LOADERS
|
|
if pattern.search((PACKAGE / relative).read_text(encoding="utf-8"))
|
|
]
|
|
assert offenders == []
|
|
|
|
|
|
def test_the_known_names_follow_the_records():
|
|
assert tasks_config.KNOWN_PROVIDERS == ("superproductivity", "caldav")
|
|
assert superproductivity.KNOWN_ACCESS == ("api", "snapshot")
|
|
|
|
|
|
# --- the loaders take their expectations from the record --------------------
|
|
|
|
|
|
def _add_required(shape, name="added", tier=config_shapes.CHECKOUT):
|
|
return dataclasses.replace(
|
|
shape, fields=shape.fields + (config_shapes.Field(name, "string", "x", required=True, tier=tier),)
|
|
)
|
|
|
|
|
|
def _not_required(shape, name, default):
|
|
fields = tuple(
|
|
dataclasses.replace(f, required=False, default=default) if f.name == name else f
|
|
for f in shape.fields
|
|
)
|
|
return dataclasses.replace(shape, fields=fields)
|
|
|
|
|
|
def _swap(monkeypatch, attr, shape):
|
|
monkeypatch.setattr(config_shapes, attr, shape)
|
|
monkeypatch.setattr(
|
|
config_shapes, "SETTINGS_SECTIONS",
|
|
tuple(shape if s.key == shape.key else s for s in config_shapes.SETTINGS_SECTIONS),
|
|
)
|
|
|
|
|
|
def test_upload_follows_a_field_made_required_or_optional(root, monkeypatch):
|
|
from chemenu import upload
|
|
|
|
original = config_shapes.UPLOAD
|
|
write_settings(root, monkeypatch, upload=UPLOAD_OK)
|
|
assert upload.read_config(root).max_bytes == 1000
|
|
_swap(monkeypatch, "UPLOAD", _add_required(original))
|
|
with pytest.raises(ValidationError, match="added is missing"):
|
|
upload.read_config(root)
|
|
|
|
_swap(monkeypatch, "UPLOAD", original)
|
|
(root / config.LOCAL_SETTINGS_FILENAME).unlink()
|
|
(root / config.INSTANCE_SETTINGS_FILENAME).unlink()
|
|
write_settings(root, monkeypatch, upload={k: v for k, v in UPLOAD_OK.items() if k != "max_bytes"})
|
|
with pytest.raises(ValidationError):
|
|
upload.read_config(root)
|
|
_swap(monkeypatch, "UPLOAD", _not_required(original, "max_bytes", 99))
|
|
assert upload.read_config(root).max_bytes == 99
|
|
|
|
|
|
def test_tasks_follows_a_field_made_required(root, monkeypatch):
|
|
write_settings(root, monkeypatch, tasks=TASKS_OK, caldav=CALDAV_OK)
|
|
assert tasks_config.read_config(root) is not None
|
|
_swap(monkeypatch, "TASKS", _add_required(config_shapes.TASKS, tier=config_shapes.INSTANCE))
|
|
with pytest.raises(ValidationError, match="added is missing"):
|
|
tasks_config.read_config(root)
|
|
|
|
|
|
def test_remotes_follows_a_field_made_required(root, monkeypatch):
|
|
write_settings(root, monkeypatch, remotes={"allowed_push_urls": ["u"]})
|
|
assert git_publish.read_allowed_push_urls() == ["u"]
|
|
_swap(monkeypatch, "REMOTES", _add_required(config_shapes.REMOTES))
|
|
with pytest.raises(typer.Exit):
|
|
git_publish.read_allowed_push_urls()
|
|
|
|
|
|
def test_caldav_follows_a_field_made_required(monkeypatch):
|
|
from chemenu import settings
|
|
|
|
monkeypatch.setenv("WIKITOOL_SECRET_X", "pw")
|
|
data = dict(CALDAV_OK, app_password={"ref": "x"})
|
|
assert caldav.CalDAVConfig.from_section(
|
|
settings.Section.from_data(config_shapes.CALDAV, data)).exclude_lists == ()
|
|
fields = tuple(
|
|
dataclasses.replace(f, required=True) if f.name == "exclude_lists" else f
|
|
for f in config_shapes.CALDAV.fields
|
|
)
|
|
shape = dataclasses.replace(config_shapes.CALDAV, fields=fields)
|
|
with pytest.raises(ValidationError, match="exclude_lists is missing"):
|
|
settings.Section.from_data(shape, data)
|
|
|
|
|
|
def test_telemetry_follows_a_condition_in_the_record(root, monkeypatch):
|
|
write_settings(root, monkeypatch, telemetry={"keep_sessions": 500})
|
|
policy.reset_cache()
|
|
assert policy.resolve(root).keep_sessions == 500
|
|
shape = config_shapes.TELEMETRY
|
|
fields = tuple(
|
|
dataclasses.replace(f, check=lambda v: None if v < 100 else "too many") if f.name == "keep_sessions" else f
|
|
for f in shape.fields
|
|
)
|
|
_swap(monkeypatch, "TELEMETRY", dataclasses.replace(shape, fields=fields))
|
|
policy.reset_cache()
|
|
assert policy.resolve(root).keep_sessions == policy.DEFAULT_KEEP_SESSIONS
|
|
policy.reset_cache()
|
|
|
|
|
|
# --- deviations: what doctor reports for telemetry -------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("data,field", [
|
|
({"enabled": "yes"}, "enabled"),
|
|
({"enabled": 1}, "enabled"),
|
|
({"max_session_bytes": 0}, "max_session_bytes"),
|
|
({"keep_sessions": "250"}, "keep_sessions"),
|
|
({"keep_sessions": 2.5}, "keep_sessions"),
|
|
({"enabled": True, "verbose": True}, "verbose"),
|
|
])
|
|
def test_a_telemetry_deviation_names_its_field(data, field):
|
|
found = config_shapes.deviations(config_shapes.TELEMETRY, data)
|
|
assert found and all(field in entry for entry in found)
|
|
|
|
|
|
def test_a_conforming_telemetry_file_has_no_deviation():
|
|
for example in config_shapes.TELEMETRY.examples:
|
|
assert config_shapes.deviations(config_shapes.TELEMETRY, example) == []
|
|
assert config_shapes.deviations(config_shapes.TELEMETRY, {"enabled": False}) == []
|
|
|
|
|
|
def test_every_example_passes_its_own_record():
|
|
from chemenu import settings
|
|
|
|
for shape in config_shapes.SETTINGS_SECTIONS:
|
|
for example in shape.examples:
|
|
settings.Section.from_data(shape, example)
|
|
|
|
|
|
# --- the generated regions --------------------------------------------------
|
|
|
|
|
|
def _page() -> str:
|
|
return (config.ROOT / reference_doc.CONFIGURATION).read_text(encoding="utf-8")
|
|
|
|
|
|
def _all_fields(shape):
|
|
for spec in shape.fields:
|
|
yield spec.name
|
|
|
|
|
|
def test_the_page_carries_one_region_per_file_and_every_field_in_it():
|
|
page = _page()
|
|
for name, attr in reference_doc.CONFIG_REGIONS:
|
|
shape = getattr(config_shapes, attr)
|
|
region = blocks.find(page, name)
|
|
assert region is not None, name
|
|
for path in _all_fields(shape):
|
|
assert f"`{path}`" in region, (name, path)
|
|
|
|
|
|
@pytest.mark.parametrize("relative", [reference_doc.CONFIGURATION, "docs/how-to/set-up-the-mcp-server.md"])
|
|
def test_no_json_example_for_these_files_stands_outside_a_region(relative):
|
|
text = blocks.mask_regions((config.ROOT / relative).read_text(encoding="utf-8"))
|
|
keys = {f.name for shape in config_shapes.SETTINGS_SECTIONS for f in shape.fields}
|
|
for block in re.findall(r"```json\n(.*?)```", text, re.S):
|
|
assert not any(f'"{key}"' in block for key in keys), block
|
|
|
|
|
|
def _only_config_regions(monkeypatch):
|
|
real = reference_doc.regions
|
|
monkeypatch.setattr(
|
|
reference_doc, "regions",
|
|
lambda *a, **k: [r for r in real(*a, **k) if r.path == reference_doc.CONFIGURATION],
|
|
)
|
|
|
|
|
|
def _hand_written(text: str) -> str:
|
|
for name, _ in reference_doc.CONFIG_REGIONS:
|
|
text = re.sub(
|
|
re.escape(blocks.open_marker(name)) + ".*?" + re.escape(blocks.close_marker(name)),
|
|
blocks.open_marker(name), text, flags=re.S,
|
|
)
|
|
return text
|
|
|
|
|
|
def test_apply_fills_every_region_of_the_page_and_a_second_run_changes_nothing(tmp_path, monkeypatch):
|
|
"""Four regions in one file: each is refreshed into the text the one
|
|
before it left, or only the last would survive."""
|
|
target = tmp_path / reference_doc.CONFIGURATION
|
|
target.parent.mkdir(parents=True)
|
|
real = _page()
|
|
emptied = real
|
|
for name, _ in reference_doc.CONFIG_REGIONS:
|
|
emptied = blocks.replace(emptied, name, blocks.open_marker(name) + "\n" + blocks.close_marker(name) + "\n")
|
|
target.write_text(emptied, encoding="utf-8")
|
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
|
_only_config_regions(monkeypatch)
|
|
|
|
result = runner.invoke(app, ["docs", "reference", "--apply"])
|
|
assert result.exit_code == 0, result.output
|
|
applied = target.read_text(encoding="utf-8")
|
|
assert applied == real
|
|
assert _hand_written(applied) == _hand_written(emptied)
|
|
|
|
result = runner.invoke(app, ["docs", "reference", "--apply"])
|
|
assert "already current" in result.output
|
|
assert target.read_text(encoding="utf-8") == applied
|
|
|
|
|
|
def test_a_changed_record_without_apply_fails_docs_verify(monkeypatch):
|
|
assert not [i for i in docs_verify.check_reference_regions() if "configuration.md" in i]
|
|
_swap(monkeypatch, "REMOTES", _add_required(config_shapes.REMOTES))
|
|
issues = [i for i in docs_verify.check_reference_regions() if reference_doc.CONFIGURATION in i]
|
|
assert len(issues) == 1
|
|
assert "reference-config-remotes" in issues[0] and "docs reference --apply" in issues[0]
|
|
|
|
|
|
def test_the_configuration_regions_are_checked_in_an_instance_too():
|
|
regions = [r for r in reference_doc.regions() if r.path == reference_doc.CONFIGURATION]
|
|
assert [r.name for r in regions] == [name for name, _ in reference_doc.CONFIG_REGIONS]
|
|
assert not any(r.origin_only for r in regions)
|