settings: the four per-feature files .wikitool-remotes/-telemetry/-upload/-tasks.json move into the three tiers and disappear; new command config migrate; 10.0.0-beta.1 (#208)
CI / verify (push) Failing after 7m41s
CI / pwsh (push) Successful in 2m32s
Release / release (push) Successful in 37s
Release / wiki-mirror (push) Skipped

Files changed:
- .gitignore
- AGENTS.md
- CHANGES.md
- INSTALL.md
- VERSION
- docs/explanation/knowledge-and-commitment.md
- docs/explanation/telemetry-and-evaluation.md
- docs/explanation/why-gates-are-code.md
- docs/how-to/run-the-weekly-review.md
- docs/how-to/set-up-the-mcp-server.md
- docs/how-to/trace-and-score-sessions.md
- docs/how-to/troubleshoot.md
- docs/reference/commands/README.md
- docs/reference/commands/configuration.md
- docs/reference/commands/finding-and-checking.md
- docs/reference/commands/git.md
- docs/reference/commands/instance-health.md
- docs/reference/commands/pages.md
- docs/reference/commands/raw-material-and-uploads.md
- docs/reference/configuration.md
- docs/reference/exit-codes.md
- instructions/dev/doc-pull-through.md
- instructions/dev/tracker-testing.md
- instructions/gates.md
- instructions/ingest-queue.md
- instructions/mcp-read-server.md
- instructions/migrate-corpus.md
- instructions/migrations/10.0.0-settings-tiers.md
- instructions/setup-instance.md
- raw/CONTRACT.md
- reports/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/bugreport.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/_util.py
- tools/chemenu/commands/config_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/export_cmd.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/commands/task_cmd.py
- tools/chemenu/commands/upload_cmd.py
- tools/chemenu/config.py
- tools/chemenu/config_shapes.py
- tools/chemenu/legacy_settings.py
- tools/chemenu/mcp/__main__.py
- tools/chemenu/mcp/server.py
- tools/chemenu/reference_doc.py
- tools/chemenu/review.py
- tools/chemenu/settings.py
- tools/chemenu/tasks/__init__.py
- tools/chemenu/tasks/caldav.py
- tools/chemenu/tasks/config.py
- tools/chemenu/tasks/superproductivity.py
- tools/chemenu/telemetry/policy.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/record_sp_fixtures.py
- tools/chemenu/tests/test_bugreport.py
- tools/chemenu/tests/test_caldav.py
- tools/chemenu/tests/test_config_shapes.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_git_publish.py
- tools/chemenu/tests/test_legacy_settings.py
- tools/chemenu/tests/test_mcp_server.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_review.py
- tools/chemenu/tests/test_settings.py
- tools/chemenu/tests/test_sp_recorded.py
- tools/chemenu/tests/test_superproductivity.py
- tools/chemenu/tests/test_task_cmd.py
- tools/chemenu/tests/test_tasks_config.py
- tools/chemenu/tests/test_telemetry_policy.py
- tools/chemenu/tests/test_tracker_live.py
- tools/chemenu/tests/test_upload.py
- tools/chemenu/tests/tracker_live.py
- tools/chemenu/upload.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 06:54:35 +02:00
1 parent e085a8b6c1
commit db8f90c229
79 files changed
+2784 -1793

No files matched your search

+26 -1
View File
@@ -1,10 +1,11 @@
import json
import os
import subprocess
from pathlib import Path
import pytest
from chemenu import config, conventions, toolpaths
from chemenu import config, config_shapes, conventions, toolpaths
from chemenu.frontmatter_io import write_page
from chemenu.session import HARNESS_ENV_VARS
from chemenu.telemetry import policy as telemetry_policy
@@ -358,3 +359,27 @@ def kb_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
)
(kb / "log.md").write_text("# Wiki Log\n", encoding="utf-8")
return kb
def write_settings(root: Path, monkeypatch: pytest.MonkeyPatch, **sections: dict) -> None:
"""Put `sections` into the tiered settings files under `root`, each field
in the file its tier says. A plain value for a secret field is stood in by
a reference to `<section>_<field>` and the value goes into the environment
variable that reference reads - never into a file. Existing content is kept."""
files = {True: {"schema": 1, "instance_id": "testinstance"}, False: {"schema": 1}}
for in_instance, name in ((True, config.INSTANCE_SETTINGS_FILENAME), (False, config.LOCAL_SETTINGS_FILENAME)):
path = Path(root) / name
if path.is_file():
files[in_instance] = json.loads(path.read_text(encoding="utf-8"))
shapes = {shape.key: shape for shape in config_shapes.SETTINGS_SECTIONS}
for key, body in sections.items():
for field_name, value in body.items():
spec = shapes[key].field(field_name)
if spec.tier == config_shapes.SECRET and not (isinstance(value, dict) and "ref" in value):
ref = f"{key}_{field_name}"
monkeypatch.setenv(config.ENV_SECRET_PREFIX + ref.upper(), value)
value = {"ref": ref}
in_instance = spec.tier == config_shapes.INSTANCE
files[in_instance].setdefault(key, {})[field_name] = value
(Path(root) / config.INSTANCE_SETTINGS_FILENAME).write_text(json.dumps(files[True]), encoding="utf-8")
(Path(root) / config.LOCAL_SETTINGS_FILENAME).write_text(json.dumps(files[False]), encoding="utf-8")
+2 -2
View File
@@ -34,8 +34,8 @@ def main(out: Path) -> None:
work = Path(tempfile.mkdtemp(prefix="chemenu-record-sp-"))
process = sp_headless.start(executable, work / "profile", marker_project=PROJECT)
try:
cfg = sp.SuperProductivityConfig.from_dict(
{"access": "api", "api_base_url": process.base_url, "api_token": process.token}
cfg = sp.SuperProductivityConfig(
access=sp.ACCESS_API, backups_dir=None, api_base_url=process.base_url, api_token=process.token
)
reader = sp.SuperProductivityApiReader(cfg)
writer = sp.SuperProductivityWriter(cfg, reader)
+2 -2
View File
@@ -57,7 +57,7 @@ def checkout(tmp_path: Path, monkeypatch) -> Path:
(root / "kb" / "Concepts" / "CONTRACT.md").write_text("stack file\n")
(root / "raw" / "articles" / "Quelle Gamma.md").write_text("source\n")
(root / "VERSION").write_text("1.2.3\n")
(root / ".wikitool-upload.json").write_text(
(root / ".wikitool-local.json").write_text(
json.dumps({"schema": 1, "api_token": TOKEN, "enabled": True})
)
(root / ".wikitool-tools.json").write_text(
@@ -133,7 +133,7 @@ def test_secrets_are_removed_everywhere_including_the_archive(checkout, tmp_path
assert TOKEN not in text
assert PASSWORD not in text
stack = json.loads((bundle / "stack.json").read_text())
assert stack["config_files"][".wikitool-upload.json"]["api_token"] == "<removed>"
assert stack["config_files"][".wikitool-local.json"]["api_token"] == "<removed>"
archive = bundle.with_name(bundle.name + ".zip")
with zipfile.ZipFile(archive) as zf:
for name in zf.namelist():
+15 -10
View File
@@ -22,6 +22,7 @@ from xml.sax.saxutils import escape as _xml_escape
import icalendar
import pytest
from chemenu import config_shapes, settings
from chemenu.errors import ValidationError
from chemenu.tasks import caldav as cd
@@ -32,6 +33,10 @@ def _auth_header() -> str:
return "Basic " + base64.b64encode(f"{TOKEN[0]}:{TOKEN[1]}".encode()).decode("ascii")
def _from_dict(data) -> cd.CalDAVConfig:
return cd.CalDAVConfig.from_section(settings.Section.from_data(config_shapes.CALDAV, data))
def _cfg(port: int, *, path: str = "/cal/", **overrides) -> cd.CalDAVConfig:
data = {
"url": f"http://127.0.0.1:{port}{path}",
@@ -41,7 +46,7 @@ def _cfg(port: int, *, path: str = "/cal/", **overrides) -> cd.CalDAVConfig:
"someday_list": "Someday",
**overrides,
}
return cd.CalDAVConfig.from_dict(data)
return _from_dict(data)
# --- fixture builders --------------------------------------------------------------
@@ -338,18 +343,18 @@ def _fresh_state() -> dict:
return {"calendars": {}, "resources": {}, "created": []}
# --- CalDAVConfig.from_dict -----------------------------------------------------------
# --- CalDAVConfig.from_section -----------------------------------------------------------
def test_from_dict_requires_every_string_field():
with pytest.raises(ValidationError, match="url"):
cd.CalDAVConfig.from_dict({
_from_dict({
"username": "u", "app_password": "p", "inbox_list": "Inbox", "someday_list": "Someday",
})
def test_from_dict_rejects_unknown_fields():
with pytest.raises(ValidationError, match="does not take"):
cd.CalDAVConfig.from_dict({
with pytest.raises(ValidationError, match="not a field"):
_from_dict({
"url": "https://x/", "username": "u", "app_password": "p",
"inbox_list": "Inbox", "someday_list": "Someday", "backups_dir": "/x",
})
@@ -357,14 +362,14 @@ def test_from_dict_rejects_unknown_fields():
def test_from_dict_exclude_lists_must_be_a_list_of_strings():
with pytest.raises(ValidationError, match="exclude_lists"):
cd.CalDAVConfig.from_dict({
_from_dict({
"url": "https://x/", "username": "u", "app_password": "p",
"inbox_list": "Inbox", "someday_list": "Someday", "exclude_lists": "not-a-list",
})
def test_from_dict_exclude_lists_defaults_to_empty():
cfg = cd.CalDAVConfig.from_dict({
cfg = _from_dict({
"url": "https://x/", "username": "u", "app_password": "p",
"inbox_list": "Inbox", "someday_list": "Someday",
})
@@ -372,7 +377,7 @@ def test_from_dict_exclude_lists_defaults_to_empty():
def test_from_dict_normalizes_a_missing_trailing_slash():
cfg = cd.CalDAVConfig.from_dict({
cfg = _from_dict({
"url": "https://x/cal", "username": "u", "app_password": "p",
"inbox_list": "Inbox", "someday_list": "Someday",
})
@@ -588,7 +593,7 @@ def test_unparsable_vtodo_fails_loud_not_silent():
def test_unreachable_server_fails_loud():
cfg = cd.CalDAVConfig.from_dict({
cfg = _from_dict({
"url": "http://127.0.0.1:1/cal/", "username": "u", "app_password": "p",
"inbox_list": "Inbox", "someday_list": "Someday",
})
@@ -613,7 +618,7 @@ def test_probe_reports_authentication_failure():
def test_probe_reports_unreachable():
cfg = cd.CalDAVConfig.from_dict({
cfg = _from_dict({
"url": "http://127.0.0.1:1/cal/", "username": "u", "app_password": "p",
"inbox_list": "Inbox", "someday_list": "Someday",
})
+78 -379
View File
@@ -1,17 +1,13 @@
"""`chemenu.config_shapes` - one record per hand-written configuration file,
"""`chemenu.config_shapes` - one record per section of the tiered settings,
which the loaders read and `docs reference` renders.
The drop-in half compares every loader against a copy of the loader as it stood
before the records existed (the `_legacy_*` oracles below, kept verbatim in
what they accept): any file it accepted must still be accepted with the same
values, and any file it rejected must still be rejected. Only the wording of a
rejection may differ.
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 json
import re
import sys
from pathlib import Path
@@ -27,11 +23,29 @@ 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 -------------------------------------------------------
@@ -62,10 +76,9 @@ def test_the_telemetry_policy_reads_the_record():
assert policy.DEFAULT_KEEP_SESSIONS == config_shapes.TELEMETRY.field("keep_sessions").default
def test_one_record_per_file():
assert [shape.key for shape in config_shapes.SHAPES] == [
config.TELEMETRY_FILENAME, config.PUBLISH_REMOTES_FILENAME,
config.UPLOAD_CONFIG_FILENAME, config.TASKS_CONFIG_FILENAME,
def test_one_record_per_section():
assert [shape.key for shape in config_shapes.SETTINGS_SECTIONS] == [
"telemetry", "remotes", "upload", "tasks", "superproductivity", "caldav",
]
@@ -87,319 +100,12 @@ def test_the_known_names_follow_the_records():
assert superproductivity.KNOWN_ACCESS == ("api", "snapshot")
# --- the legacy oracles: the loaders as they were before the records --------
def _legacy_upload(data):
if not isinstance(data, dict):
raise ValidationError("not an object")
try:
identity_header = str(data.get("identity_header") or "X-Forwarded-User")
max_bytes = int(data["max_bytes"])
extensions = tuple(sorted({str(ext).lower() for ext in data["allowed_extensions"]}))
quota = data["quota"]
submissions_per_day = int(quota["submissions_per_day"])
bytes_per_day = int(quota["bytes_per_day"])
except (KeyError, TypeError, ValueError) as exc:
raise ValidationError("misshaped") from exc
if max_bytes <= 0 or submissions_per_day <= 0 or bytes_per_day <= 0:
raise ValidationError("not positive")
if not extensions or [ext for ext in extensions if not ext.startswith(".")]:
raise ValidationError("extensions")
return (identity_header, max_bytes, extensions, submissions_per_day, bytes_per_day)
def _legacy_tasks(data):
if not isinstance(data, dict):
raise ValidationError("not an object")
try:
provider = str(data["provider"])
raw = data["thresholds"]
thresholds = (int(raw["stalled_waiting_days"]), int(raw["unpaged_project_weeks"]),
int(raw["someday_stale_months"]))
except (KeyError, TypeError, ValueError) as exc:
raise ValidationError("misshaped") from exc
if provider not in ("superproductivity", "caldav"):
raise ValidationError("provider")
if any(value <= 0 for value in thresholds):
raise ValidationError("not positive")
if not isinstance(data.get(provider), dict):
raise ValidationError("section")
return (provider, thresholds, data.get(provider))
def _legacy_sp(data):
if not isinstance(data, dict):
raise ValidationError("not an object")
access = data.get("access")
if access not in ("api", "snapshot"):
raise ValidationError("access")
if access == "api":
if set(data) - {"access", "api_base_url", "api_token"}:
raise ValidationError("extra")
token = data.get("api_token")
if not isinstance(token, str) or not token:
raise ValidationError("token")
return ("api", None, str(data.get("api_base_url") or "http://127.0.0.1:3876"), token)
if set(data) - {"access", "backups_dir"}:
raise ValidationError("extra")
if not data.get("backups_dir"):
raise ValidationError("backups_dir")
return ("snapshot", Path(data["backups_dir"]).expanduser(), "http://127.0.0.1:3876", None)
_CALDAV_STRINGS = ("url", "username", "app_password", "inbox_list", "someday_list")
def _legacy_caldav(data):
if not isinstance(data, dict):
raise ValidationError("not an object")
if [k for k in _CALDAV_STRINGS if not isinstance(data.get(k), str) or not data[k]]:
raise ValidationError("missing")
if set(data) - {*_CALDAV_STRINGS, "exclude_lists"}:
raise ValidationError("extra")
exclude = data.get("exclude_lists", [])
if not isinstance(exclude, list) or not all(isinstance(x, str) for x in exclude):
raise ValidationError("exclude_lists")
url = data["url"] if data["url"].endswith("/") else data["url"] + "/"
return (url, data["username"], data["app_password"], data["inbox_list"], data["someday_list"],
tuple(exclude))
def _legacy_remotes(data):
urls = data.get("allowed_push_urls")
if not isinstance(urls, list) or not all(isinstance(u, str) for u in urls):
raise ValidationError("urls")
return urls
def _legacy_telemetry(data):
def positive(value):
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
data = data if isinstance(data, dict) else {}
return (
bool(data["enabled"]) if "enabled" in data else None,
positive(data.get("max_session_bytes")),
positive(data.get("keep_sessions")),
)
# --- the loaders today, reduced to the same tuples --------------------------
def _upload(data, tmp_path):
(tmp_path / config.UPLOAD_CONFIG_FILENAME).write_text(json.dumps(data), encoding="utf-8")
from chemenu import upload
cfg = upload.read_config(tmp_path)
return (cfg.identity_header, cfg.max_bytes, cfg.allowed_extensions, cfg.submissions_per_day,
cfg.bytes_per_day)
def _tasks(data, tmp_path):
(tmp_path / config.TASKS_CONFIG_FILENAME).write_text(json.dumps(data), encoding="utf-8")
cfg = tasks_config.read_config(tmp_path)
t = cfg.thresholds
return (cfg.provider, (t.stalled_waiting_days, t.unpaged_project_weeks, t.someday_stale_months),
cfg.provider_config)
def _sp(data, tmp_path):
cfg = superproductivity.SuperProductivityConfig.from_dict(data)
return (cfg.access, cfg.backups_dir, cfg.api_base_url, cfg.api_token)
def _caldav(data, tmp_path):
cfg = caldav.CalDAVConfig.from_dict(data)
return (cfg.url, cfg.username, cfg.app_password, cfg.inbox_list, cfg.someday_list,
cfg.exclude_lists)
def _remotes(data, tmp_path):
(tmp_path / config.PUBLISH_REMOTES_FILENAME).write_text(json.dumps(data), encoding="utf-8")
try:
return git_publish.read_allowed_push_urls()
except typer.Exit as exc:
raise ValidationError("fail()") from exc
def _telemetry(data, tmp_path):
(tmp_path / config.TELEMETRY_FILENAME).write_text(json.dumps(data), encoding="utf-8")
policy.reset_cache()
defaults = policy._root_defaults(tmp_path.resolve())
policy.reset_cache()
return (defaults.file_enabled, defaults.file_max_bytes, defaults.file_keep_sessions)
UPLOAD_OK = {
"schema": 1, "identity_header": "X-Forwarded-User", "max_bytes": 10485760,
"allowed_extensions": [".md", ".txt", ".pdf", ".html", ".csv", ".json", ".png", ".jpg"],
"quota": {"submissions_per_day": 20, "bytes_per_day": 52428800},
}
TASKS_OK = {
"schema": 1, "provider": "superproductivity",
"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5},
"superproductivity": {"access": "api", "api_base_url": "http://127.0.0.1:3876", "api_token": "t"},
}
CALDAV_OK = {
"url": "https://host/remote.php/dav/calendars/me", "username": "me", "app_password": "pw",
"inbox_list": "Inbox", "someday_list": "Someday", "exclude_lists": ["Shopping"],
}
def _with(base, **changes):
data = json.loads(json.dumps(base))
for key, value in changes.items():
target = data
*parents, leaf = key.split("__")
for parent in parents:
target = target[parent]
if value is _DROP:
del target[leaf]
else:
target[leaf] = value
return data
_DROP = object()
CASES = [
# upload: the documented file, then every tolerance and every rejection
("upload", UPLOAD_OK),
("upload", _with(UPLOAD_OK, schema=_DROP)),
("upload", _with(UPLOAD_OK, identity_header=_DROP)),
("upload", _with(UPLOAD_OK, identity_header="")),
("upload", _with(UPLOAD_OK, max_bytes="10485760")),
("upload", _with(UPLOAD_OK, quota__bytes_per_day="100")),
("upload", _with(UPLOAD_OK, allowed_extensions=[".MD", ".md", ".Pdf"])),
("upload", _with(UPLOAD_OK, surplus="ignored")),
("upload", _with(UPLOAD_OK, quota__surplus=1)),
("upload", _with(UPLOAD_OK, max_bytes=0)),
("upload", _with(UPLOAD_OK, max_bytes="ten")),
("upload", _with(UPLOAD_OK, max_bytes=_DROP)),
("upload", _with(UPLOAD_OK, allowed_extensions=["md"])),
("upload", _with(UPLOAD_OK, allowed_extensions=[])),
("upload", _with(UPLOAD_OK, allowed_extensions=".md")),
("upload", _with(UPLOAD_OK, quota=[1, 2])),
("upload", _with(UPLOAD_OK, quota__submissions_per_day=-1)),
("upload", [1, 2]),
# tasks
("tasks", TASKS_OK),
("tasks", _with(TASKS_OK, schema=_DROP)),
("tasks", _with(TASKS_OK, thresholds__stalled_waiting_days="14")),
("tasks", _with(TASKS_OK, caldav={"url": "unused"})),
("tasks", _with(TASKS_OK, surplus=True)),
("tasks", _with(TASKS_OK, thresholds__surplus=1)),
("tasks", _with(TASKS_OK, provider="asana")),
("tasks", _with(TASKS_OK, provider=_DROP)),
("tasks", _with(TASKS_OK, thresholds__someday_stale_months=0)),
("tasks", _with(TASKS_OK, thresholds__unpaged_project_weeks=_DROP)),
("tasks", _with(TASKS_OK, thresholds="soon")),
("tasks", _with(TASKS_OK, superproductivity=_DROP)),
("tasks", _with(TASKS_OK, superproductivity="api")),
("tasks", _with(TASKS_OK, provider="caldav")),
("tasks", "not an object"),
# the Super Productivity section
("sp", {"access": "api", "api_base_url": "http://127.0.0.1:3876", "api_token": "t"}),
("sp", {"access": "api", "api_token": "t"}),
("sp", {"access": "api", "api_token": "t", "api_base_url": ""}),
("sp", {"access": "snapshot", "backups_dir": "~/.config/superProductivity/backups"}),
("sp", {"access": "api"}),
("sp", {"access": "api", "api_token": ""}),
("sp", {"access": "api", "api_token": 7}),
("sp", {"access": "api", "api_token": "t", "backups_dir": "/x"}),
("sp", {"access": "snapshot", "backups_dir": "/x", "api_token": "t"}),
("sp", {"access": "snapshot"}),
("sp", {"access": "snapshot", "backups_dir": ""}),
("sp", {"access": "auto", "backups_dir": "/x"}),
("sp", {"backups_dir": "/x"}),
("sp", ["api"]),
# the CalDAV section
("caldav", CALDAV_OK),
("caldav", _with(CALDAV_OK, exclude_lists=_DROP)),
("caldav", _with(CALDAV_OK, url="https://host/dav/")),
("caldav", _with(CALDAV_OK, url=_DROP)),
("caldav", _with(CALDAV_OK, username="")),
("caldav", _with(CALDAV_OK, app_password=5)),
("caldav", _with(CALDAV_OK, exclude_lists="Shopping")),
("caldav", _with(CALDAV_OK, exclude_lists=[1])),
("caldav", _with(CALDAV_OK, backups_dir="/x")),
# remotes
("remotes", {"schema": 1, "allowed_push_urls": ["git@example.org:me/my-wiki.git"]}),
("remotes", {"allowed_push_urls": []}),
("remotes", {"allowed_push_urls": ["a"], "surplus": 1}),
("remotes", {"allowed_push_urls": "not-a-list"}),
("remotes", {"allowed_push_urls": [1]}),
("remotes", {}),
# telemetry: never rejected, only read differently
("telemetry", {"enabled": True, "max_session_bytes": 5242880, "keep_sessions": 250}),
("telemetry", {}),
("telemetry", {"enabled": "no"}),
("telemetry", {"enabled": 0}),
("telemetry", {"enabled": None}),
("telemetry", {"max_session_bytes": "100", "keep_sessions": 3.7}),
("telemetry", {"max_session_bytes": 0, "keep_sessions": -2}),
("telemetry", {"max_session_bytes": "lots", "keep_sessions": [1]}),
("telemetry", {"surplus": 1, "schema": 1}),
("telemetry", [True]),
]
ORACLES = {
"upload": (_legacy_upload, _upload),
"tasks": (_legacy_tasks, _tasks),
"sp": (_legacy_sp, _sp),
"caldav": (_legacy_caldav, _caldav),
"remotes": (_legacy_remotes, _remotes),
"telemetry": (_legacy_telemetry, _telemetry),
}
def _outcome(function, data, *args):
try:
return ("accepted", function(data, *args))
except ValidationError:
return ("rejected", None)
@pytest.mark.parametrize("loader,data", CASES, ids=[f"{n}-{i}" for i, (n, _) in enumerate(CASES)])
def test_every_loader_accepts_and_rejects_exactly_what_it_did_before(loader, data, tmp_path, monkeypatch):
legacy, current = ORACLES[loader]
monkeypatch.setattr(config, "ROOT", tmp_path)
assert _outcome(current, data, tmp_path) == _outcome(legacy, data)
def test_the_cases_cover_both_outcomes_for_every_loader(tmp_path):
for loader, (legacy, _) in ORACLES.items():
if loader == "telemetry":
continue
outcomes = {_outcome(legacy, data)[0] for name, data in CASES if name == loader}
assert outcomes == {"accepted", "rejected"}, loader
def test_a_rejection_keeps_its_cause_and_renders_the_expected_text(tmp_path):
(tmp_path / config.UPLOAD_CONFIG_FILENAME).write_text(
json.dumps(_with(UPLOAD_OK, max_bytes=0)), encoding="utf-8"
)
from chemenu import upload
with pytest.raises(ValidationError) as caught:
upload.read_config(tmp_path)
message = str(caught.value)
assert "max_bytes must be positive" in message
assert f"Expected: {config_shapes.render_expected(config_shapes.UPLOAD)}" in message
# --- the loaders take their expectations from the record --------------------
def _add_required(shape, name="added"):
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),)
shape, fields=shape.fields + (config_shapes.Field(name, "string", "x", required=True, tier=tier),)
)
@@ -411,78 +117,78 @@ def _not_required(shape, name, default):
return dataclasses.replace(shape, fields=fields)
def test_upload_follows_a_field_made_required_or_optional(tmp_path, monkeypatch):
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
(tmp_path / config.UPLOAD_CONFIG_FILENAME).write_text(json.dumps(UPLOAD_OK), encoding="utf-8")
monkeypatch.setattr(config_shapes, "UPLOAD", _add_required(config_shapes.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(tmp_path)
upload.read_config(root)
monkeypatch.undo()
(tmp_path / config.UPLOAD_CONFIG_FILENAME).write_text(
json.dumps(_with(UPLOAD_OK, max_bytes=_DROP)), encoding="utf-8"
)
_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(tmp_path)
monkeypatch.setattr(config_shapes, "UPLOAD", _not_required(config_shapes.UPLOAD, "max_bytes", 99))
assert upload.read_config(tmp_path).max_bytes == 99
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(tmp_path, monkeypatch):
(tmp_path / config.TASKS_CONFIG_FILENAME).write_text(json.dumps(TASKS_OK), encoding="utf-8")
assert tasks_config.read_config(tmp_path) is not None
monkeypatch.setattr(config_shapes, "TASKS", _add_required(config_shapes.TASKS))
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(tmp_path)
tasks_config.read_config(root)
def test_remotes_follows_a_field_made_required(tmp_path, monkeypatch):
monkeypatch.setattr(config, "ROOT", tmp_path)
(tmp_path / config.PUBLISH_REMOTES_FILENAME).write_text(
json.dumps({"allowed_push_urls": ["u"]}), encoding="utf-8"
)
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"]
monkeypatch.setattr(config_shapes, "REMOTES", _add_required(config_shapes.REMOTES))
_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):
data = _with(CALDAV_OK, exclude_lists=_DROP)
assert caldav.CalDAVConfig.from_dict(data).exclude_lists == ()
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
)
monkeypatch.setattr(config_shapes, "CALDAV", dataclasses.replace(config_shapes.CALDAV, fields=fields))
shape = dataclasses.replace(config_shapes.CALDAV, fields=fields)
with pytest.raises(ValidationError, match="exclude_lists is missing"):
caldav.CalDAVConfig.from_dict(data)
settings.Section.from_data(shape, data)
def test_superproductivity_follows_a_field_added_to_a_variant(monkeypatch):
data = {"access": "api", "api_token": "t", "timeout": "5"}
with pytest.raises(ValidationError, match="does not take"):
superproductivity.SuperProductivityConfig.from_dict(data)
shape = config_shapes.SUPERPRODUCTIVITY
api = dict(shape.variants)["api"] + (config_shapes.Field("timeout", "string", "x"),)
variants = (("api", api),) + tuple(v for v in shape.variants if v[0] != "api")
monkeypatch.setattr(config_shapes, "SUPERPRODUCTIVITY", dataclasses.replace(shape, variants=variants))
assert superproductivity.SuperProductivityConfig.from_dict(data).api_token == "t"
def test_telemetry_follows_a_condition_in_the_record(tmp_path, monkeypatch):
(tmp_path / config.TELEMETRY_FILENAME).write_text(json.dumps({"keep_sessions": 500}), encoding="utf-8")
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(tmp_path).keep_sessions == 500
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
)
monkeypatch.setattr(config_shapes, "TELEMETRY", dataclasses.replace(shape, fields=fields))
_swap(monkeypatch, "TELEMETRY", dataclasses.replace(shape, fields=fields))
policy.reset_cache()
assert policy.resolve(tmp_path).keep_sessions == policy.DEFAULT_KEEP_SESSIONS
assert policy.resolve(root).keep_sessions == policy.DEFAULT_KEEP_SESSIONS
policy.reset_cache()
@@ -505,16 +211,15 @@ def test_a_telemetry_deviation_names_its_field(data, field):
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, {"schema": 1, "enabled": False}) == []
assert config_shapes.deviations(config_shapes.TELEMETRY, {"enabled": False}) == []
def test_every_example_passes_its_own_record():
for shape in config_shapes.SHAPES:
from chemenu import settings
for shape in config_shapes.SETTINGS_SECTIONS:
for example in shape.examples:
assert config_shapes.check(shape, example).problems == [], shape.key
for section in config_shapes.TASKS.sections:
for example in section.examples:
assert config_shapes.check(section, example).problems == [], section.key
settings.Section.from_data(shape, example)
# --- the generated regions --------------------------------------------------
@@ -524,10 +229,9 @@ def _page() -> str:
return (config.ROOT / reference_doc.CONFIGURATION).read_text(encoding="utf-8")
def _all_fields(shape, prefix=""):
for spec in shape.fields + tuple(f for _, fs in shape.variants for f in fs):
yield prefix + spec.name
yield from (prefix + spec.name + "." + sub.name for sub in spec.fields)
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():
@@ -538,17 +242,12 @@ def test_the_page_carries_one_region_per_file_and_every_field_in_it():
assert region is not None, name
for path in _all_fields(shape):
assert f"`{path}`" in region, (name, path)
for section in shape.sections:
for path in _all_fields(section):
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.SHAPES for f in shape.fields if f.name != "schema"}
keys |= {f.name for s in config_shapes.TASKS.sections for f in s.fields}
keys |= {f.name for s in config_shapes.TASKS.sections for _, fs in s.variants for f in fs}
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
@@ -596,7 +295,7 @@ def test_apply_fills_every_region_of_the_page_and_a_second_run_changes_nothing(t
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]
monkeypatch.setattr(config_shapes, "REMOTES", _add_required(config_shapes.REMOTES))
_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]
+74 -88
View File
@@ -13,6 +13,7 @@ import pytest
from chemenu import config, conventions, prerequisites, toolpaths
from chemenu.commands import doctor, instructions_cmd
from chemenu.tests.conftest import write_settings
def _git(root: Path, *args: str) -> None:
@@ -311,25 +312,26 @@ def test_a_renamed_but_unfilled_environment_template_warns(instance):
assert "template" in next(c.detail for c in checks if c.name == "environment")
_THRESHOLDS = {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5}
def _remotes_detail(checks) -> str:
return next(c.detail for c in checks if c.name == "publish-remotes")
def test_publish_remotes_says_not_armed_when_the_file_is_absent(instance):
def test_publish_remotes_says_not_armed_when_the_section_is_absent(instance):
"""Absence stays OK - a single-remote checkout has nothing to protect - but
the line has to say the gate is off. AGENTS.md lists it among the limits
enforced in code, so "no .wikitool-remotes.json" alone leaves a reader
enforced in code, so "no remotes section" alone leaves a reader
trusting a safeguard that is not running."""
checks = doctor.run_doctor()
assert _status(checks, "publish-remotes") == "OK"
assert "not armed" in _remotes_detail(checks)
def test_publish_remotes_says_armed_when_the_file_lists_a_target(instance):
(config.ROOT / config.PUBLISH_REMOTES_FILENAME).write_text(
'{ "schema": 1, "allowed_push_urls": ["ssh://git@example.net/one.git"] }',
encoding="utf-8",
)
def test_publish_remotes_says_armed_when_the_section_lists_a_target(instance, monkeypatch):
write_settings(config.ROOT, monkeypatch,
remotes={"allowed_push_urls": ["ssh://git@example.net/one.git"]})
checks = doctor.run_doctor()
assert _status(checks, "publish-remotes") == "OK"
detail = _remotes_detail(checks)
@@ -345,18 +347,17 @@ def test_publish_remotes_warns_on_several_remotes_without_an_allowlist(instance)
assert "not armed" in _remotes_detail(checks)
def test_upload_intake_ok_when_no_config_file(instance):
def test_upload_intake_ok_when_no_section(instance):
checks = doctor.run_doctor()
assert _status(checks, "upload-intake") == "OK"
assert "not registered" in _detail(checks, "upload-intake")
def test_upload_intake_ok_and_armed_when_config_is_valid(instance):
(config.ROOT / config.UPLOAD_CONFIG_FILENAME).write_text(
'{"schema": 1, "max_bytes": 100, "allowed_extensions": [".md"], '
'"quota": {"submissions_per_day": 1, "bytes_per_day": 100}}',
encoding="utf-8",
)
def test_upload_intake_ok_and_armed_when_section_is_valid(instance, monkeypatch):
write_settings(config.ROOT, monkeypatch, upload={
"max_bytes": 100, "allowed_extensions": [".md"],
"quota_submissions_per_day": 1, "quota_bytes_per_day": 100,
})
checks = doctor.run_doctor()
assert _status(checks, "upload-intake") == "OK"
detail = _detail(checks, "upload-intake")
@@ -364,50 +365,37 @@ def test_upload_intake_ok_and_armed_when_config_is_valid(instance):
assert "0 submission(s) waiting" in detail
def test_upload_intake_fails_on_a_malformed_config(instance):
(config.ROOT / config.UPLOAD_CONFIG_FILENAME).write_text("{not json", encoding="utf-8")
def test_upload_intake_fails_on_a_malformed_section(instance, monkeypatch):
write_settings(config.ROOT, monkeypatch, upload={"max_bytes": "many"})
checks = doctor.run_doctor()
assert _status(checks, "upload-intake") == "FAIL"
def test_tasks_provider_ok_when_no_config_file(instance):
def test_tasks_provider_ok_when_no_section(instance):
checks = doctor.run_doctor()
assert _status(checks, "tasks-provider") == "OK"
assert "no task tracker configured" in _detail(checks, "tasks-provider")
def test_tasks_provider_fails_on_a_malformed_config(instance):
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text("{not json", encoding="utf-8")
def test_tasks_provider_fails_on_a_malformed_settings_file(instance):
(config.ROOT / config.LOCAL_SETTINGS_FILENAME).write_text("{not json", encoding="utf-8")
checks = doctor.run_doctor()
assert _status(checks, "tasks-provider") == "FAIL"
def test_tasks_provider_fails_on_a_bad_superproductivity_section(instance):
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
'{"schema": 1, "provider": "superproductivity", "thresholds": '
'{"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5}, '
'"superproductivity": {}}',
encoding="utf-8",
)
def test_tasks_provider_fails_on_a_bad_superproductivity_section(instance, monkeypatch):
write_settings(config.ROOT, monkeypatch, tasks={"provider": "superproductivity", **_THRESHOLDS})
checks = doctor.run_doctor()
assert _status(checks, "tasks-provider") == "FAIL"
def test_tasks_provider_ok_but_names_the_unready_read_path_when_configured(instance):
def test_tasks_provider_ok_but_names_the_unready_read_path_when_configured(instance, monkeypatch):
"""`access: snapshot` - only the read path is a finding; the API is not
even attempted (Gitea #133)."""
backups = config.ROOT / "sp-backups"
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
json.dumps({
"schema": 1, "provider": "superproductivity",
"thresholds": {
"stalled_waiting_days": 14, "unpaged_project_weeks": 3,
"someday_stale_months": 5,
},
"superproductivity": {"access": "snapshot", "backups_dir": str(backups)},
}),
encoding="utf-8",
)
write_settings(config.ROOT, monkeypatch,
tasks={"provider": "superproductivity", **_THRESHOLDS},
superproductivity={"access": "snapshot", "backups_dir": str(backups)})
checks = doctor.run_doctor()
assert _status(checks, "tasks-provider") == "OK"
detail = _detail(checks, "tasks-provider")
@@ -415,20 +403,13 @@ def test_tasks_provider_ok_but_names_the_unready_read_path_when_configured(insta
assert "not ready" in detail
def test_tasks_provider_ok_but_names_api_unreachable_when_configured(instance):
def test_tasks_provider_ok_but_names_api_unreachable_when_configured(instance, monkeypatch):
"""`access: api` - the API reachability is the finding; the (irrelevant)
backup file is not even looked at (Gitea #133)."""
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
json.dumps({
"schema": 1, "provider": "superproductivity",
"thresholds": {
"stalled_waiting_days": 14, "unpaged_project_weeks": 3,
"someday_stale_months": 5,
},
"superproductivity": {"access": "api", "api_base_url": "http://127.0.0.1:1", "api_token": "t"},
}),
encoding="utf-8",
)
write_settings(config.ROOT, monkeypatch,
tasks={"provider": "superproductivity", **_THRESHOLDS},
superproductivity={"access": "api", "api_base_url": "http://127.0.0.1:1",
"api_token": "t"})
checks = doctor.run_doctor()
assert _status(checks, "tasks-provider") == "OK"
detail = _detail(checks, "tasks-provider")
@@ -436,32 +417,17 @@ def test_tasks_provider_ok_but_names_api_unreachable_when_configured(instance):
assert "not reachable" in detail
def test_tasks_provider_fails_on_a_bad_caldav_section(instance):
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
'{"schema": 1, "provider": "caldav", "thresholds": '
'{"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5}, '
'"caldav": {}}',
encoding="utf-8",
)
def test_tasks_provider_fails_on_a_bad_caldav_section(instance, monkeypatch):
write_settings(config.ROOT, monkeypatch, tasks={"provider": "caldav", **_THRESHOLDS})
checks = doctor.run_doctor()
assert _status(checks, "tasks-provider") == "FAIL"
def test_tasks_provider_ok_but_names_caldav_unreachable_when_configured(instance):
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
json.dumps({
"schema": 1, "provider": "caldav",
"thresholds": {
"stalled_waiting_days": 14, "unpaged_project_weeks": 3,
"someday_stale_months": 5,
},
"caldav": {
"url": "http://127.0.0.1:1/cal/", "username": "u", "app_password": "p",
"inbox_list": "Inbox", "someday_list": "Someday",
},
}),
encoding="utf-8",
)
def test_tasks_provider_ok_but_names_caldav_unreachable_when_configured(instance, monkeypatch):
write_settings(config.ROOT, monkeypatch, tasks={"provider": "caldav", **_THRESHOLDS}, caldav={
"url": "http://127.0.0.1:1/cal/", "username": "u", "app_password": "p",
"inbox_list": "Inbox", "someday_list": "Someday",
})
checks = doctor.run_doctor()
assert _status(checks, "tasks-provider") == "OK"
detail = _detail(checks, "tasks-provider")
@@ -469,6 +435,17 @@ def test_tasks_provider_ok_but_names_caldav_unreachable_when_configured(instance
assert "unreachable" in detail
@pytest.mark.parametrize("name", [".wikitool-remotes.json", ".wikitool-telemetry.json",
".wikitool-upload.json", ".wikitool-tasks.json"])
def test_a_leftover_settings_file_of_an_earlier_version_fails(instance, name):
(config.ROOT / name).write_text("{}", encoding="utf-8")
checks = doctor.run_doctor()
failing = [c for c in checks if c.name == "legacy-settings"]
assert [c.status for c in failing] == ["FAIL"]
assert name in failing[0].detail
assert "tools/wikitool config migrate" in (failing[0].fix or "")
def test_missing_generated_file_fails(instance):
config.LOG_FILE.unlink()
checks = doctor.run_doctor()
@@ -521,16 +498,19 @@ def test_telemetry_never_fails_either_way(instance):
assert _status(doctor.run_doctor(), "telemetry") == "OK"
def _telemetry_with(text: "str | None"):
"""`check_telemetry` against a `.wikitool-telemetry.json` holding `text`
(absent for None), plus what the policy itself makes of it."""
def _telemetry_with(section: "str | None", raw: "str | None" = None):
"""`check_telemetry` against a `telemetry` section of `.wikitool-local.json`
holding the JSON text `section` (absent for None; `raw` replaces the whole
file instead), plus what the policy itself makes of it."""
from chemenu.telemetry import policy
path = config.ROOT / config.TELEMETRY_FILENAME
if text is None:
path = config.ROOT / config.LOCAL_SETTINGS_FILENAME
if raw is not None:
path.write_text(raw, encoding="utf-8")
elif section is None:
path.unlink(missing_ok=True)
else:
path.write_text(text, encoding="utf-8")
path.write_text('{"schema": 1, "telemetry": %s}' % section, encoding="utf-8")
policy.reset_cache()
try:
return doctor.check_telemetry(), policy.resolve(config.ROOT)
@@ -538,32 +518,38 @@ def _telemetry_with(text: "str | None"):
policy.reset_cache()
@pytest.mark.parametrize("text", [None, '{"enabled": false}', '{"schema": 1, "enabled": true, '
@pytest.mark.parametrize("text", [None, '{"enabled": false}', '{"enabled": true, '
'"max_session_bytes": 1000, "keep_sessions": 9}'])
def test_telemetry_stays_ok_for_an_absent_or_conforming_file(instance, text):
def test_telemetry_stays_ok_for_an_absent_or_conforming_section(instance, text):
check, _ = _telemetry_with(text)
assert check.status == "OK"
assert check.fix is None
@pytest.mark.parametrize("text,named,policy_reads", [
("{not json", "unreadable", (True, 5 * 1024 * 1024, 250)),
("[true]", "not a JSON object", (True, 5 * 1024 * 1024, 250)),
("[true]", "object", (True, 5 * 1024 * 1024, 250)),
('{"enabled": "no"}', "enabled", (True, 5 * 1024 * 1024, 250)),
('{"max_session_bytes": 0}', "max_session_bytes", (True, 5 * 1024 * 1024, 250)),
('{"keep_sessions": "12"}', "keep_sessions", (True, 5 * 1024 * 1024, 12)),
('{"enabled": false, "keep_session": 12}', "keep_session", (False, 5 * 1024 * 1024, 250)),
])
def test_telemetry_warns_on_what_the_policy_reads_differently(instance, text, named, policy_reads):
"""The policy stays silent and reads the file exactly as before; `doctor`
"""The policy stays silent and reads the section exactly as before; `doctor`
is where the deviation becomes visible, by field."""
check, pol = _telemetry_with(text)
assert check.status == "WARN"
assert named in check.detail
assert config.TELEMETRY_FILENAME in check.fix
assert "telemetry" in check.fix
assert (pol.enabled, pol.max_session_bytes, pol.keep_sessions) == policy_reads
def test_telemetry_warns_on_an_unreadable_settings_file_and_the_policy_stays_on_its_default(instance):
check, pol = _telemetry_with(None, raw="{not json")
assert check.status == "WARN"
assert "unreadable" in check.detail
assert (pol.enabled, pol.max_session_bytes, pol.keep_sessions) == (True, 5 * 1024 * 1024, 250)
def test_telemetry_reports_the_current_session_count_and_byte_total(instance, monkeypatch):
from chemenu.telemetry.writer import TRACE_FILE, trace_root
@@ -612,14 +598,14 @@ def test_tasks_provider_names_an_active_override(instance, monkeypatch):
profile = config.ROOT / "profile.json"
profile.write_text(
json.dumps({
"schema": 1, "provider": "superproductivity",
"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3,
"someday_stale_months": 5},
"schema": 1,
"tasks": {"provider": "superproductivity", **_THRESHOLDS},
"superproductivity": {"access": "api", "api_base_url": "http://127.0.0.1:1",
"api_token": "t"},
"api_token": {"ref": "profile_token"}},
}),
encoding="utf-8",
)
monkeypatch.setenv("WIKITOOL_SECRET_PROFILE_TOKEN", "t")
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
checks = doctor.run_doctor()
assert _status(checks, "tasks-provider") == "OK"
+5 -5
View File
@@ -1306,8 +1306,8 @@ def test_a_commit_left_behind_while_offline_goes_out_with_the_next_reachable_pub
def _allowlist(root, *urls):
(root / config.PUBLISH_REMOTES_FILENAME).write_text(
json.dumps({"schema": 1, "allowed_push_urls": list(urls)}), encoding="utf-8"
(root / config.LOCAL_SETTINGS_FILENAME).write_text(
json.dumps({"schema": 1, "remotes": {"allowed_push_urls": list(urls)}}), encoding="utf-8"
)
@@ -1385,14 +1385,14 @@ def test_no_push_skips_the_gate(repo):
def test_unreadable_allowlist_fails_instead_of_falling_open(repo):
"""A broken file must not be read as 'no restriction' - that would turn a
corrupted safeguard into a silently disabled one."""
(repo / config.PUBLISH_REMOTES_FILENAME).write_text("{not json", encoding="utf-8")
(repo / config.LOCAL_SETTINGS_FILENAME).write_text("{not json", encoding="utf-8")
with pytest.raises(typer.Exit):
git_publish.read_allowed_push_urls()
def test_allowlist_without_a_usable_list_fails(repo):
(repo / config.PUBLISH_REMOTES_FILENAME).write_text(
json.dumps({"schema": 1, "allowed_push_urls": "not-a-list"}), encoding="utf-8"
(repo / config.LOCAL_SETTINGS_FILENAME).write_text(
json.dumps({"schema": 1, "remotes": {"allowed_push_urls": "not-a-list"}}), encoding="utf-8"
)
with pytest.raises(typer.Exit):
git_publish.read_allowed_push_urls()
+423
View File
@@ -0,0 +1,423 @@
"""The move out of the per-feature settings files of earlier versions
(`chemenu.legacy_settings`, `wikitool config migrate`), and what a leftover of
them does to a command.
The old files are written here by hand, in the shapes they had. Their readers
no longer exist, so "the same effective value" is held against literals that
state what each old file meant - including the defaults of every field it left
out - and read back through the readers the commands use today.
"""
from __future__ import annotations
import json
import subprocess
from dataclasses import asdict
from pathlib import Path
import pytest
from typer.testing import CliRunner
from chemenu import config, config_shapes, legacy_settings, settings, upload
from chemenu.cli import app
from chemenu.tasks import caldav, config as tasks_config, superproductivity
from chemenu.telemetry import policy
runner = CliRunner()
TOKEN = "sekrit-token-value-0451"
APP_PASSWORD = "sekrit-app-password-0815"
THRESHOLDS = {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5}
# name -> (old files by file name, what is in effect afterwards)
CASES = {
"telemetry-full": (
{".wikitool-telemetry.json": {"schema": 1, "enabled": False, "max_session_bytes": 1000,
"keep_sessions": 9}},
{"telemetry": {"enabled": False, "max_session_bytes": 1000, "keep_sessions": 9}},
),
"telemetry-defaults": (
{".wikitool-telemetry.json": {"keep_sessions": 12}},
{"telemetry": {"enabled": True, "max_session_bytes": 5 * 1024 * 1024, "keep_sessions": 12}},
),
"telemetry-coerced": (
{".wikitool-telemetry.json": {"enabled": 0, "keep_sessions": "7"}},
{"telemetry": {"enabled": False, "max_session_bytes": 5 * 1024 * 1024, "keep_sessions": 7}},
),
"remotes": (
{".wikitool-remotes.json": {"schema": 1, "allowed_push_urls": ["ssh://git@example.net/a.git",
"ssh://git@example.net/b.git"]}},
{"remotes": ["ssh://git@example.net/a.git", "ssh://git@example.net/b.git"]},
),
"upload-full": (
{".wikitool-upload.json": {
"schema": 1, "identity_header": "X-Forwarded-User", "max_bytes": 2048,
"allowed_extensions": [".md", ".txt"],
"quota": {"submissions_per_day": 4, "bytes_per_day": 9000}}},
{"upload": {"identity_header": "X-Forwarded-User", "max_bytes": 2048,
"allowed_extensions": (".md", ".txt"), "submissions_per_day": 4,
"bytes_per_day": 9000}},
),
"upload-defaults": (
{".wikitool-upload.json": {"max_bytes": 100, "allowed_extensions": [".md"],
"quota": {"submissions_per_day": 1, "bytes_per_day": 5}}},
{"upload": "identity-header-by-default"},
),
"superproductivity-api": (
{".wikitool-tasks.json": {
"schema": 1, "provider": "superproductivity", "thresholds": THRESHOLDS,
"superproductivity": {"access": "api", "api_base_url": "http://127.0.0.1:3876",
"api_token": TOKEN}}},
{"tasks": {"provider": "superproductivity", "thresholds": THRESHOLDS,
"provider_values": {"access": "api", "api_base_url": "http://127.0.0.1:3876",
"api_token": TOKEN, "backups_dir": None}}},
),
"superproductivity-snapshot": (
{".wikitool-tasks.json": {
"schema": 1, "provider": "superproductivity", "thresholds": THRESHOLDS,
"superproductivity": {"access": "snapshot", "backups_dir": "/var/backups/sp"}}},
{"tasks": {"provider": "superproductivity", "thresholds": THRESHOLDS,
"provider_values": {"access": "snapshot", "api_base_url": "http://127.0.0.1:3876",
"api_token": None, "backups_dir": "/var/backups/sp"}}},
),
"caldav": (
{".wikitool-tasks.json": {
"schema": 1, "provider": "caldav", "thresholds": THRESHOLDS,
"caldav": {"url": "https://dav.example.net/u/cal", "username": "anna",
"app_password": APP_PASSWORD, "inbox_list": "Eingang",
"someday_list": "Irgendwann", "exclude_lists": ["Privat", "Archiv"]}}},
{"tasks": {"provider": "caldav", "thresholds": THRESHOLDS,
"provider_values": {"url": "https://dav.example.net/u/cal/", "username": "anna",
"app_password": APP_PASSWORD, "inbox_list": "Eingang",
"someday_list": "Irgendwann",
"exclude_lists": ("Privat", "Archiv")}}},
),
"everything-at-once": (
{
".wikitool-telemetry.json": {"enabled": False},
".wikitool-remotes.json": {"allowed_push_urls": ["ssh://git@example.net/a.git"]},
".wikitool-upload.json": {"max_bytes": 100, "allowed_extensions": [".md"],
"quota": {"submissions_per_day": 1, "bytes_per_day": 5}},
".wikitool-tasks.json": {
"provider": "caldav", "thresholds": THRESHOLDS,
"caldav": {"url": "https://dav.example.net/u/", "username": "anna",
"app_password": APP_PASSWORD, "inbox_list": "Inbox",
"someday_list": "Someday"}},
},
{
"telemetry": {"enabled": False, "max_session_bytes": 5 * 1024 * 1024, "keep_sessions": 250},
"remotes": ["ssh://git@example.net/a.git"],
"upload": "identity-header-by-default",
"tasks": {"provider": "caldav", "thresholds": THRESHOLDS,
"provider_values": {"url": "https://dav.example.net/u/", "username": "anna",
"app_password": APP_PASSWORD, "inbox_list": "Inbox",
"someday_list": "Someday", "exclude_lists": ()}},
},
),
}
def _default(section: str, name: str):
return settings.sections()[section].field(name).default
@pytest.fixture
def checkout(tmp_path, monkeypatch):
"""A git checkout under `tmp_path/work`, with the secrets file kept outside it."""
root = tmp_path / "work"
root.mkdir()
monkeypatch.setattr(config, "ROOT", root)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
subprocess.run(["git", "init", "-b", "main"], cwd=root, check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "a@example.net"], cwd=root, check=True)
subprocess.run(["git", "config", "user.name", "A"], cwd=root, check=True)
policy.reset_cache()
yield root
policy.reset_cache()
def _write_old(root: Path, files: dict) -> dict[str, bytes]:
for name, data in files.items():
(root / name).write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
return {name: (root / name).read_bytes() for name in files}
def _snapshot(root: Path, extra: Path | None = None) -> dict[str, bytes]:
found = {str(p.relative_to(root)): p.read_bytes() for p in root.rglob("*")
if p.is_file() and ".git" not in p.relative_to(root).parts}
if extra is not None and extra.exists():
found.update({f"<xdg>/{p.relative_to(extra)}": p.read_bytes() for p in extra.rglob("*") if p.is_file()})
return found
def _effective(root: Path) -> dict:
"""What is in effect for `root`, read through the readers the commands use."""
policy.reset_cache()
pol = policy.resolve(root)
out: dict = {"telemetry": {"enabled": pol.enabled, "max_session_bytes": pol.max_session_bytes,
"keep_sessions": pol.keep_sessions}}
remotes = settings.load(root, only=("remotes",)).section("remotes")
out["remotes"] = list(remotes["allowed_push_urls"]) if remotes.configured else None
cfg = upload.read_config(root)
out["upload"] = None if cfg is None else asdict(cfg)
tasks = tasks_config.read_config(root)
if tasks is None:
out["tasks"] = None
else:
section = tasks.provider_config
if tasks.provider == "caldav":
values = asdict(caldav.CalDAVConfig.from_section(section))
else:
values = asdict(superproductivity.SuperProductivityConfig.from_section(section))
values["backups_dir"] = None if values["backups_dir"] is None else str(values["backups_dir"])
out["tasks"] = {"provider": tasks.provider, "thresholds": asdict(tasks.thresholds),
"provider_values": values}
return out
def _expected(case: dict) -> dict:
"""The `_effective` shape for what a case says is in effect; a feature the
case does not mention is at its defaults."""
out: dict = {
"telemetry": {"enabled": True, "max_session_bytes": 5 * 1024 * 1024, "keep_sessions": 250},
"remotes": None, "upload": None, "tasks": None,
}
for key, value in case.items():
if key == "upload" and isinstance(value, str):
value = {
"identity_header": _default("upload", "identity_header"),
"max_bytes": 100, "allowed_extensions": (".md",),
"submissions_per_day": 1, "bytes_per_day": 5,
}
out[key] = value
return out
def _normal(effective: dict) -> dict:
"""Tuples and lists compare equal."""
return json.loads(json.dumps(effective))
# --- a: the same effective value after the move -------------------------------
@pytest.mark.parametrize("case", CASES)
def test_every_shape_keeps_its_effective_value(checkout, case):
files, expected = CASES[case]
_write_old(checkout, files)
result = legacy_settings.migrate(checkout)
assert sorted(result.removed) == sorted(files)
assert _normal(_effective(checkout)) == _normal(_expected(expected))
# --- b: only the tools file remains; no secret in the tree or the index -------
def test_after_the_move_no_old_file_is_left_and_no_secret_is_in_the_tree_or_the_index(checkout, tmp_path):
files, _ = CASES["everything-at-once"]
files = {**files, ".wikitool-tasks.json": CASES["superproductivity-api"][0][".wikitool-tasks.json"]}
_write_old(checkout, files)
(checkout / ".wikitool-tools.json").write_text('{"schema": 1}', encoding="utf-8")
legacy_settings.migrate(checkout)
assert legacy_settings.present(checkout) == []
assert (checkout / ".wikitool-tools.json").exists()
subprocess.run(["git", "add", "-A", "-f"], cwd=checkout, check=True, capture_output=True)
for secret in (TOKEN, APP_PASSWORD):
grep = subprocess.run(["git", "grep", "--cached", "-n", secret], cwd=checkout,
capture_output=True, text=True)
assert grep.returncode == 1, grep.stdout
for path in checkout.rglob("*"):
if path.is_file() and ".git" not in path.relative_to(checkout).parts:
assert secret not in path.read_text(encoding="utf-8", errors="ignore"), path
secrets_files = list((tmp_path / "xdg").rglob("secrets.json"))
assert len(secrets_files) == 1
assert TOKEN in secrets_files[0].read_text(encoding="utf-8")
def test_the_output_names_settings_and_never_a_secret_value(checkout):
_write_old(checkout, CASES["superproductivity-api"][0])
result = runner.invoke(app, ["config", "migrate"])
assert result.exit_code == 0, result.output
assert TOKEN not in result.output
assert "superproductivity.api_token" in result.output
# --- c: an abort leaves the old files byte-identical --------------------------
@pytest.mark.parametrize("fail_on_write", [2, 3])
def test_a_fault_after_the_first_write_leaves_everything_as_it_was(checkout, tmp_path, monkeypatch, fail_on_write):
files = {
".wikitool-remotes.json": {"allowed_push_urls": ["ssh://git@example.net/a.git"]},
".wikitool-tasks.json": CASES["superproductivity-api"][0][".wikitool-tasks.json"],
}
_write_old(checkout, files)
(checkout / "wikitool.json").write_text(
json.dumps({"schema": 1, "instance_id": "inst-1"}) + "\n", encoding="utf-8")
before = _snapshot(checkout, tmp_path / "xdg")
real, calls = legacy_settings._write, []
def broken(path, data):
calls.append(path)
if len(calls) == fail_on_write:
raise OSError("disk full")
real(path, data)
monkeypatch.setattr(legacy_settings, "_write", broken)
with pytest.raises(legacy_settings.ValidationError) as caught:
legacy_settings.migrate(checkout)
assert "Nothing was changed" in str(caught.value)
assert len(calls) == fail_on_write
assert _snapshot(checkout, tmp_path / "xdg") == before
def test_a_broken_old_file_changes_nothing_and_names_the_file(checkout, tmp_path):
_write_old(checkout, {".wikitool-remotes.json": {"allowed_push_urls": ["ssh://git@example.net/a.git"]}})
(checkout / ".wikitool-upload.json").write_text('{"max_bytes": "many"}', encoding="utf-8")
before = _snapshot(checkout, tmp_path / "xdg")
result = runner.invoke(app, ["config", "migrate"])
assert result.exit_code == 1
assert ".wikitool-upload.json" in result.output
assert _snapshot(checkout, tmp_path / "xdg") == before
def test_a_value_that_collides_with_the_new_settings_changes_nothing(checkout, tmp_path):
_write_old(checkout, {".wikitool-remotes.json": {"allowed_push_urls": ["ssh://git@example.net/a.git"]}})
(checkout / ".wikitool-local.json").write_text(
json.dumps({"schema": 1, "remotes": {"allowed_push_urls": ["ssh://git@example.net/other.git"]}}),
encoding="utf-8")
before = _snapshot(checkout, tmp_path / "xdg")
with pytest.raises(legacy_settings.ValidationError, match="already holds a different value"):
legacy_settings.migrate(checkout)
assert _snapshot(checkout, tmp_path / "xdg") == before
# --- d: a second run changes nothing ------------------------------------------
def test_a_second_run_exits_0_and_changes_no_file(checkout, tmp_path):
_write_old(checkout, CASES["everything-at-once"][0])
first = runner.invoke(app, ["config", "migrate"])
assert first.exit_code == 0, first.output
after_first = _snapshot(checkout, tmp_path / "xdg")
second = runner.invoke(app, ["config", "migrate"])
assert second.exit_code == 0, second.output
assert "Nothing to migrate" in second.output
assert _snapshot(checkout, tmp_path / "xdg") == after_first
def test_a_migrated_setting_that_is_already_there_with_the_same_value_is_not_a_collision(checkout):
_write_old(checkout, {".wikitool-remotes.json": {"allowed_push_urls": ["ssh://git@example.net/a.git"]}})
(checkout / ".wikitool-local.json").write_text(
json.dumps({"schema": 1, "remotes": {"allowed_push_urls": ["ssh://git@example.net/a.git"]}}),
encoding="utf-8")
result = legacy_settings.migrate(checkout)
assert result.removed == [".wikitool-remotes.json"]
# --- e: a leftover is exit 42, FAIL in doctor, silent in the hook -------------
@pytest.mark.parametrize("args,name", [
(["task", "list", "--project", "x"], ".wikitool-tasks.json"),
(["task", "new", "--title", "x", "--inbox"], ".wikitool-tasks.json"),
(["review"], ".wikitool-tasks.json"),
(["publish", "--message", "x"], ".wikitool-remotes.json"),
])
def test_a_leftover_makes_the_command_that_needs_the_setting_exit_42(checkout, monkeypatch, args, name):
(checkout / "kb").mkdir()
monkeypatch.setattr(config, "KB_DIR", checkout / "kb")
(checkout / name).write_text("{}", encoding="utf-8")
result = runner.invoke(app, args)
assert result.exit_code == 42, result.output
assert "tools/wikitool config migrate" in " ".join(result.output.split())
def test_a_tracker_profile_makes_the_old_tasks_file_beside_it_irrelevant(checkout, monkeypatch):
(checkout / ".wikitool-tasks.json").write_text("{}", encoding="utf-8")
profile = checkout / "profile.json"
profile.write_text(json.dumps({"schema": 1, "tasks": {"provider": "caldav", **THRESHOLDS},
"caldav": {"url": "http://127.0.0.1:1/", "username": "u",
"app_password": {"ref": "profile_pw"},
"inbox_list": "I", "someday_list": "S"}}), encoding="utf-8")
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
monkeypatch.setenv("WIKITOOL_SECRET_PROFILE_PW", "pw")
assert tasks_config.read_config(checkout).provider == "caldav"
def test_the_mcp_server_refuses_to_start_on_an_old_upload_file(checkout, capsys):
pytest.importorskip("mcp")
from chemenu.mcp.__main__ import main
(checkout / ".wikitool-upload.json").write_text('{"max_bytes": 100}', encoding="utf-8")
assert main(["--root", str(checkout)]) == 1
assert "tools/wikitool config migrate" in capsys.readouterr().err
def test_doctor_reports_each_old_file_as_a_fail(checkout):
from chemenu.commands import doctor
_write_old(checkout, {name: {} for name in legacy_settings.FILES.values()})
checks = [c for c in doctor.check_legacy_settings()]
assert [c.status for c in checks] == ["FAIL"] * 4
assert all("tools/wikitool config migrate" in (c.fix or "") for c in checks)
def test_the_telemetry_hook_neither_reads_nor_refuses_over_an_old_file(checkout):
(checkout / ".wikitool-telemetry.json").write_text('{"enabled": false}', encoding="utf-8")
assert policy.resolve(checkout).enabled is True
# --- g: `config set` and the gate flags ---------------------------------------
@pytest.mark.parametrize("key,value", [
("upload.max_bytes", "100"),
("remotes.allowed_push_urls", '["ssh://git@example.net/a.git"]'),
])
def test_config_set_on_a_gated_section_exits_42_and_writes_nothing(checkout, key, value):
result = runner.invoke(app, ["config", "set", key, value])
assert result.exit_code == 42, result.output
assert not (checkout / config.LOCAL_SETTINGS_FILENAME).exists()
def test_config_set_on_telemetry_writes_the_local_file(checkout):
result = runner.invoke(app, ["config", "set", "telemetry.keep_sessions", "7"])
assert result.exit_code == 0, result.output
data = json.loads((checkout / config.LOCAL_SETTINGS_FILENAME).read_text(encoding="utf-8"))
assert data["telemetry"] == {"keep_sessions": 7}
# --- h: the access-path rules of Super Productivity ---------------------------
@pytest.mark.parametrize("section,message", [
({"access": "api"}, "api_token"),
({"access": "api", "api_token": "t", "backups_dir": "/x"}, "backups_dir"),
({"access": "snapshot", "backups_dir": "/x", "api_token": "t"}, "api_token"),
({"access": "snapshot"}, "backups_dir"),
])
def test_a_superproductivity_section_with_the_wrong_fields_for_its_access_fails(checkout, section, message):
_write_old(checkout, {".wikitool-tasks.json": {
"provider": "superproductivity", "thresholds": THRESHOLDS, "superproductivity": section}})
with pytest.raises(legacy_settings.ValidationError, match=message):
legacy_settings.migrate(checkout)
assert (checkout / ".wikitool-tasks.json").exists()
# ... and exactly so when it is read from the new settings
from chemenu.tests.conftest import write_settings
(checkout / ".wikitool-tasks.json").unlink()
mp = pytest.MonkeyPatch()
try:
write_settings(checkout, mp, tasks={"provider": "superproductivity", **THRESHOLDS},
superproductivity=section)
with pytest.raises(legacy_settings.ChemenuError, match=message):
superproductivity.SuperProductivityConfig.from_section(
tasks_config.read_config(checkout).provider_config)
finally:
mp.undo()
+10 -7
View File
@@ -9,7 +9,7 @@ The acceptance criteria that are not about a value coming back:
importable from the server - structural, not filtered;
- every response carries the commit it was computed from;
- telemetry cannot land inside the served checkout;
- the sixth tool, `submit`, exists only when `.wikitool-upload.json` opts a
- the sixth tool, `submit`, exists only when the `upload` section of `.wikitool-local.json` opts a
checkout in, writes only under `mcp-upload/`, and never resolves an
identity from anywhere but the request's own headers.
"""
@@ -44,10 +44,13 @@ from chemenu.mcp.server import ( # noqa: E402 - after the skip guard
_UPLOAD_CFG = {
"schema": 1,
"identity_header": "X-Forwarded-User",
"max_bytes": 1024,
"allowed_extensions": [".md"],
"quota": {"submissions_per_day": 10, "bytes_per_day": 100_000},
"upload": {
"identity_header": "X-Forwarded-User",
"max_bytes": 1024,
"allowed_extensions": [".md"],
"quota_submissions_per_day": 10,
"quota_bytes_per_day": 100_000,
},
}
@@ -348,7 +351,7 @@ def test_a_refused_trace_destination_stops_the_process_with_a_message(monkeypatc
# --- submit (Gitea #32) --------------------------------------------------------
def _arm_upload(corpus: Path) -> None:
(corpus / ".wikitool-upload.json").write_text(json.dumps(_UPLOAD_CFG), encoding="utf-8")
(corpus / ".wikitool-local.json").write_text(json.dumps(_UPLOAD_CFG), encoding="utf-8")
(corpus / "incoming").mkdir(exist_ok=True)
@@ -366,7 +369,7 @@ def test_submit_is_present_once_armed(corpus):
def test_a_malformed_upload_config_refuses_to_build(corpus):
(corpus / ".wikitool-upload.json").write_text("{not json", encoding="utf-8")
(corpus / ".wikitool-local.json").write_text("{not json", encoding="utf-8")
with pytest.raises(ValidationError):
build_server(corpus, check_trace=False)
+31 -23
View File
@@ -23,32 +23,40 @@ _TASKS_THRESHOLDS = {
_API_TOKEN = "test-token"
@pytest.fixture(autouse=True)
def _tracker_token(monkeypatch):
monkeypatch.setenv("WIKITOOL_SECRET_SP_TEST_TOKEN", _API_TOKEN)
def _write_tracker_settings(root, superproductivity: dict) -> None:
"""The tracker's settings in the tiered files: the instance sections in
`wikitool.json`, the connection in `.wikitool-local.json`. Duplicated
locally rather than imported since test files in this suite do not import
each other (see `instructions/dev/testing-conventions.md`)."""
api = superproductivity.get("access") == "api"
(root / "wikitool.json").write_text(json.dumps({
"schema": 1, "instance_id": "testinstance",
"tasks": {"provider": "superproductivity", **_TASKS_THRESHOLDS},
"superproductivity": {"access": superproductivity["access"]},
}), encoding="utf-8")
local = {k: v for k, v in superproductivity.items() if k != "access"}
if api:
local["api_token"] = {"ref": "sp_test_token"}
(root / ".wikitool-local.json").write_text(
json.dumps({"schema": 1, "superproductivity": local}), encoding="utf-8")
def _write_tasks_config(root, base_url: str) -> None:
"""A minimal `.wikitool-tasks.json` pointing the superproductivity
provider's `access: "api"` path at `base_url` (Gitea #133) - `new
project` only ever has a write path on that access mode. Duplicated
locally rather than imported since test files in this suite do not
import each other (see `instructions/dev/testing-conventions.md` for the
isolation this mirrors one layer up)."""
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
"superproductivity": {"access": "api", "api_base_url": base_url, "api_token": _API_TOKEN},
}),
encoding="utf-8",
)
"""A minimal tracker setup pointing the superproductivity provider's
`access: "api"` path at `base_url` - `new project` only ever has a write
path on that access mode."""
_write_tracker_settings(root, {"access": "api", "api_base_url": base_url})
def _write_tasks_config_snapshot(root, backups_dir) -> None:
"""The other access path (Gitea #133) - used only by the tests below
that confirm `new project` refuses entirely against it."""
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
"superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
}),
encoding="utf-8",
)
"""The other access path - used only by the tests below that confirm
`new project` refuses entirely against it."""
_write_tracker_settings(root, {"access": "snapshot", "backups_dir": str(backups_dir)})
def _write_sp_backup(root, project_titles: list[str]) -> Path:
@@ -252,7 +260,7 @@ def test_new_project_refuses_a_responsibility_outside_the_enum(monkeypatch, kb_d
def test_new_project_says_explicitly_when_no_tracker_is_configured(monkeypatch, kb_dir):
"""No `.wikitool-tasks.json` at all - the page-only state is legitimate
"""No tracker settings at all - the page-only state is legitimate
(#126's own AC) but must be said, not left implicit."""
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Ohne Tracker", "--set", "responsibility=haus",
+18 -20
View File
@@ -21,7 +21,7 @@ from chemenu.cli import app
from chemenu.commands.run_budget import is_exempt
from chemenu.errors import ValidationError
from chemenu.frontmatter_io import write_page
from chemenu.tests.conftest import use_shipped_type_specs
from chemenu.tests.conftest import use_shipped_type_specs, write_settings
from chemenu.review import (
CHECK_NO_OPEN_LOOP,
CHECK_PROJECT_AGE_UNKNOWN,
@@ -79,29 +79,27 @@ def _write_snapshot(root: Path, projects: dict, tasks: dict, tags: dict) -> Path
def _write_tasks_config(root: Path, backups_dir: Path, thresholds: dict | None = None) -> None:
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1,
"provider": "superproductivity",
"thresholds": thresholds or THRESHOLDS,
"superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
}),
encoding="utf-8",
write_settings(
root, None,
tasks={"provider": "superproductivity", **(thresholds or THRESHOLDS)},
superproductivity={"access": "snapshot", "backups_dir": str(backups_dir)},
)
@pytest.fixture(autouse=True)
def _caldav_password(monkeypatch):
monkeypatch.setenv("WIKITOOL_SECRET_REVIEW_CALDAV_PASSWORD", _CALDAV_TOKEN[1])
def _write_caldav_tasks_config(root: Path, *, url: str, thresholds: dict | None = None) -> None:
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1,
"provider": "caldav",
"thresholds": thresholds or THRESHOLDS,
"caldav": {
"url": url, "username": _CALDAV_TOKEN[0], "app_password": _CALDAV_TOKEN[1],
"inbox_list": "Inbox", "someday_list": "Someday",
},
}),
encoding="utf-8",
write_settings(
root, None,
tasks={"provider": "caldav", **(thresholds or THRESHOLDS)},
caldav={
"url": url, "username": _CALDAV_TOKEN[0],
"app_password": {"ref": "review_caldav_password"},
"inbox_list": "Inbox", "someday_list": "Someday",
},
)
+2 -6
View File
@@ -1,8 +1,8 @@
"""The tiered settings (`chemenu.settings`), `wikitool config set`, and what
`doctor` and `docs verify` report about them.
No real section ships yet, so every test stands in `TRACKER`, a section with one
field of each kind: an instance value, a private checkout value, a secret, a
The tests stand in `TRACKER`, a section of their own with one field of each kind
(the real sections are tested where their feature is): an instance value, a private checkout value, a secret, a
field that names a program, and a plain checkout value. 1Password is a fake `op`
script written into the test's own directory - never a real account; the one
exception is the CI smoke test at the bottom, which only runs where CI has
@@ -57,7 +57,6 @@ TRACKER = Shape(
Field("label", "string", "Plain checkout value.", tier=CHECKOUT, json_type="string"),
),
examples=({},),
unknown_keys=config_shapes.REJECT,
)
@@ -167,9 +166,6 @@ def test_an_unknown_tier_is_refused():
def test_every_shipped_settings_section_is_one():
for shape in config_shapes.SETTINGS_SECTIONS:
assert settings.section_problems(shape) == []
# The four per-feature files predate the tiers and declare none.
for shape in config_shapes.SHAPES:
assert all(not spec.tier for spec in shape.fields)
# --- reading: the right file ----------------------------------------------------
+7 -5
View File
@@ -85,8 +85,8 @@ def test_the_manifest_names_the_version_and_the_day():
def test_the_api_reader_reads_the_apps_own_answers():
with _replay_server() as base_url:
cfg = sp.SuperProductivityConfig.from_dict(
{"access": "api", "api_base_url": base_url, "api_token": "irrelevant"}
cfg = sp.SuperProductivityConfig(
access=sp.ACCESS_API, backups_dir=None, api_base_url=base_url, api_token="irrelevant"
)
reader = sp.SuperProductivityApiReader(cfg)
@@ -106,8 +106,8 @@ def test_the_api_reader_reads_the_apps_own_answers():
def test_the_inbox_item_is_invisible_to_every_project():
with _replay_server() as base_url:
cfg = sp.SuperProductivityConfig.from_dict(
{"access": "api", "api_base_url": base_url, "api_token": "irrelevant"}
cfg = sp.SuperProductivityConfig(
access=sp.ACCESS_API, backups_dir=None, api_base_url=base_url, api_token="irrelevant"
)
reader = sp.SuperProductivityApiReader(cfg)
seen = {i.title for p in reader.projects() for i in reader.open_items(p.name).items}
@@ -118,7 +118,9 @@ def test_the_snapshot_reader_reads_a_backup_the_app_wrote(tmp_path):
backups = tmp_path / "backups"
backups.mkdir()
shutil.copyfile(FIXTURES / "seed-backup.json", backups / "2026-01-01_000000.json")
cfg = sp.SuperProductivityConfig.from_dict({"access": "snapshot", "backups_dir": str(backups)})
cfg = sp.SuperProductivityConfig(
access=sp.ACCESS_SNAPSHOT, backups_dir=backups, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None
)
reader = sp.SuperProductivityReader(cfg)
assert {p.name for p in reader.projects()} == PROJECTS
+15 -9
View File
@@ -129,51 +129,57 @@ def cfg(tmp_path) -> sp.SuperProductivityConfig:
)
def _from_dict(data):
from chemenu import config_shapes, settings
return sp.SuperProductivityConfig.from_section(
settings.Section.from_data(config_shapes.SUPERPRODUCTIVITY, data))
# --- from_dict -----------------------------------------------------------------
def test_from_dict_requires_access():
with pytest.raises(ValidationError, match="access"):
sp.SuperProductivityConfig.from_dict({"backups_dir": "/x"})
_from_dict({"backups_dir": "/x"})
def test_from_dict_rejects_an_unknown_access_value():
with pytest.raises(ValidationError, match="access"):
sp.SuperProductivityConfig.from_dict({"access": "auto", "backups_dir": "/x"})
_from_dict({"access": "auto", "backups_dir": "/x"})
def test_from_dict_api_requires_a_token():
with pytest.raises(ValidationError, match="api_token"):
sp.SuperProductivityConfig.from_dict({"access": "api"})
_from_dict({"access": "api"})
def test_from_dict_api_defaults_the_base_url():
cfg_ = sp.SuperProductivityConfig.from_dict({"access": "api", "api_token": "t"})
cfg_ = _from_dict({"access": "api", "api_token": "t"})
assert cfg_.api_base_url == sp.DEFAULT_API_BASE_URL
assert cfg_.access == sp.ACCESS_API
def test_from_dict_api_rejects_a_snapshot_field():
with pytest.raises(ValidationError, match="backups_dir"):
sp.SuperProductivityConfig.from_dict(
_from_dict(
{"access": "api", "api_token": "t", "backups_dir": "/x"}
)
def test_from_dict_snapshot_requires_backups_dir():
with pytest.raises(ValidationError, match="backups_dir"):
sp.SuperProductivityConfig.from_dict({"access": "snapshot"})
_from_dict({"access": "snapshot"})
def test_from_dict_snapshot_rejects_an_api_field():
with pytest.raises(ValidationError, match="api_token"):
sp.SuperProductivityConfig.from_dict(
_from_dict(
{"access": "snapshot", "backups_dir": "/x", "api_token": "t"}
)
def test_from_dict_snapshot_expands_user_in_backups_dir(monkeypatch):
monkeypatch.setenv("HOME", "/home/fixture")
cfg_ = sp.SuperProductivityConfig.from_dict({"access": "snapshot", "backups_dir": "~/backups"})
cfg_ = _from_dict({"access": "snapshot", "backups_dir": "~/backups"})
assert cfg_.backups_dir == Path("/home/fixture/backups")
@@ -181,7 +187,7 @@ def test_from_dict_rejects_db_path_entirely():
"""`db_path` was #133's own casualty - it must not silently work as an
alias for `backups_dir` under either access mode."""
with pytest.raises(ValidationError):
sp.SuperProductivityConfig.from_dict({"access": "snapshot", "db_path": "/x/db.json"})
_from_dict({"access": "snapshot", "db_path": "/x/db.json"})
# --- latest_snapshot_path / schema drift ---------------------------------------
+31 -22
View File
@@ -1,7 +1,7 @@
"""Tests for `wikitool task new` (Gitea #132) - the CLI adapter over
`chemenu.tasks.protocol.TaskWriter.create_item`. Mirrors the fixture shape
`test_new_page.py` uses for `new project`'s own tracker calls: a stub Super
Productivity local REST API on a loopback socket, `.wikitool-tasks.json`
Productivity local REST API on a loopback socket, the tracker settings
pointed at it, and `config.ROOT` repointed at `tmp_path` via the shared
`kb_dir` fixture. No real Super Productivity instance is ever started
(`instructions/dev/testing-conventions.md`).
@@ -16,8 +16,12 @@ from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any
import pytest
from typer.testing import CliRunner
from chemenu import config
from chemenu.tests.conftest import write_settings
runner = CliRunner()
_TASKS_THRESHOLDS = {
@@ -30,13 +34,17 @@ def _ms(year: int, month: int, day: int) -> int:
return int(datetime(year, month, day, tzinfo=timezone.utc).timestamp() * 1000)
@pytest.fixture(autouse=True)
def _api_token_secret(monkeypatch):
monkeypatch.setenv("WIKITOOL_SECRET_TASKCMD_API_TOKEN", _API_TOKEN)
def _write_tasks_config(root: Path, base_url: str) -> None:
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
"superproductivity": {"access": "api", "api_base_url": base_url, "api_token": _API_TOKEN},
}),
encoding="utf-8",
write_settings(
root, None,
tasks={"provider": "superproductivity", **_TASKS_THRESHOLDS},
superproductivity={"access": "api", "api_base_url": base_url,
"api_token": {"ref": "taskcmd_api_token"}},
)
@@ -292,12 +300,10 @@ def test_snapshot_access_has_no_write_path(monkeypatch, kb_dir, tmp_path):
}),
encoding="utf-8",
)
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
"superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
}),
encoding="utf-8",
write_settings(
root, None,
tasks={"provider": "superproductivity", **_TASKS_THRESHOLDS},
superproductivity={"access": "snapshot", "backups_dir": str(backups_dir)},
)
result = _invoke(monkeypatch, kb_dir, [
"task", "new", "--title", "x", "--project", "Ship Chemenu 7.0",
@@ -377,12 +383,10 @@ def test_close_on_snapshot_access_is_refused_the_same_way_as_task_new(monkeypatc
}),
encoding="utf-8",
)
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
"superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
}),
encoding="utf-8",
write_settings(
root, None,
tasks={"provider": "superproductivity", **_TASKS_THRESHOLDS},
superproductivity={"access": "snapshot", "backups_dir": str(backups_dir)},
)
result = _invoke(monkeypatch, kb_dir, ["task", "close", "--id", "t1"])
assert result.exit_code == 1
@@ -415,10 +419,15 @@ def test_an_override_points_a_command_at_another_tracker_without_touching_the_ro
with _api_server(state) as server:
profile_dir = tmp_path / "profiles"
profile_dir.mkdir()
_write_tasks_config(profile_dir, _base_url(server))
profile = profile_dir / ".wikitool-tasks.json"
profile = profile_dir / "profile.json"
profile.write_text(json.dumps({
"schema": 1,
"tasks": {"provider": "superproductivity", **_TASKS_THRESHOLDS},
"superproductivity": {"access": "api", "api_base_url": _base_url(server),
"api_token": {"ref": "taskcmd_api_token"}},
}), encoding="utf-8")
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
result = _invoke(monkeypatch, kb_dir, ["task", "list", "--project", "Ship Chemenu 7.0"])
assert result.exit_code == 0, result.output
assert "Ueber das Profil" in result.output
assert not (root / ".wikitool-tasks.json").exists()
assert not (root / config.LOCAL_SETTINGS_FILENAME).exists()
+65 -47
View File
@@ -7,26 +7,25 @@ import json
import pytest
from chemenu import config, legacy_settings
from chemenu.errors import ValidationError
from chemenu.tasks import config as tasks_config
from chemenu.tests.conftest import write_settings
VALID = {
"schema": 1,
"provider": "superproductivity",
"thresholds": {
"stalled_waiting_days": 14,
"unpaged_project_weeks": 3,
"someday_stale_months": 5,
},
"superproductivity": {
"access": "snapshot",
"backups_dir": "/tmp/does-not-need-to-exist-for-parsing",
},
}
TASKS = {"provider": "superproductivity", "stalled_waiting_days": 14,
"unpaged_project_weeks": 3, "someday_stale_months": 5}
SP = {"access": "snapshot", "backups_dir": "/tmp/does-not-need-to-exist-for-parsing"}
VALID = {"tasks": TASKS, "superproductivity": SP}
def _write(root, data):
(root / ".wikitool-tasks.json").write_text(json.dumps(data), encoding="utf-8")
@pytest.fixture
def _write(monkeypatch):
def write(root, **changes):
"""The sections of `VALID` (with `changes` replacing whole sections)
into the files, each field where its tier says."""
sections = {**VALID, **changes}
write_settings(root, monkeypatch, **{k: v for k, v in sections.items() if v is not None})
return write
def test_absent_is_none(tmp_path):
@@ -34,13 +33,13 @@ def test_absent_is_none(tmp_path):
def test_malformed_json_is_an_error_not_unconfigured(tmp_path):
(tmp_path / ".wikitool-tasks.json").write_text("{not json", encoding="utf-8")
(tmp_path / config.INSTANCE_SETTINGS_FILENAME).write_text("{not json", encoding="utf-8")
with pytest.raises(ValidationError):
tasks_config.read_config(tmp_path)
def test_a_valid_config_parses(tmp_path):
_write(tmp_path, VALID)
def test_a_valid_config_parses(tmp_path, _write):
_write(tmp_path)
cfg = tasks_config.read_config(tmp_path)
assert cfg.provider == "superproductivity"
assert cfg.thresholds.stalled_waiting_days == 14
@@ -49,68 +48,69 @@ def test_a_valid_config_parses(tmp_path):
assert cfg.provider_config["backups_dir"] == "/tmp/does-not-need-to-exist-for-parsing"
def test_unknown_provider_is_rejected(tmp_path):
data = json.loads(json.dumps(VALID))
data["provider"] = "asana"
_write(tmp_path, data)
def test_unknown_provider_is_rejected(tmp_path, _write):
_write(tmp_path, tasks=dict(TASKS, provider="asana"))
with pytest.raises(ValidationError):
tasks_config.read_config(tmp_path)
def test_missing_threshold_field_is_rejected(tmp_path):
data = json.loads(json.dumps(VALID))
del data["thresholds"]["someday_stale_months"]
_write(tmp_path, data)
def test_missing_threshold_field_is_rejected(tmp_path, _write):
_write(tmp_path, tasks={k: v for k, v in TASKS.items() if k != "someday_stale_months"})
with pytest.raises(ValidationError):
tasks_config.read_config(tmp_path)
def test_non_positive_threshold_is_rejected(tmp_path):
data = json.loads(json.dumps(VALID))
data["thresholds"]["stalled_waiting_days"] = 0
_write(tmp_path, data)
def test_non_positive_threshold_is_rejected(tmp_path, _write):
_write(tmp_path, tasks=dict(TASKS, stalled_waiting_days=0))
with pytest.raises(ValidationError):
tasks_config.read_config(tmp_path)
def test_missing_provider_section_is_rejected(tmp_path):
data = json.loads(json.dumps(VALID))
del data["superproductivity"]
_write(tmp_path, data)
def test_missing_provider_section_is_rejected(tmp_path, _write):
_write(tmp_path, superproductivity=None)
with pytest.raises(ValidationError):
tasks_config.read_config(tmp_path)
def test_a_leftover_file_of_an_earlier_version_is_refused(tmp_path):
(tmp_path / ".wikitool-tasks.json").write_text("{}", encoding="utf-8")
with pytest.raises(legacy_settings.LegacySettingsLeftover, match="config migrate"):
tasks_config.read_config(tmp_path)
# --- the WIKITOOL_TASKS_CONFIG override (Gitea #156) --------------------------------
def test_override_is_read_instead_of_the_root_file(tmp_path, monkeypatch):
PROFILE = VALID
def _profile(path, data=PROFILE):
path.write_text(json.dumps(data), encoding="utf-8")
return path
def test_override_is_read_instead_of_the_root_files(tmp_path, monkeypatch, _write):
root = tmp_path / "root"
root.mkdir()
_write(root, {**VALID, "provider": "asana"}) # would be rejected if it were read
profile = tmp_path / "profile.json"
profile.write_text(json.dumps(VALID), encoding="utf-8")
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
_write(root, tasks=dict(TASKS, provider="asana")) # would be rejected if it were read
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(_profile(tmp_path / "profile.json")))
assert tasks_config.read_config(root).provider == "superproductivity"
def test_override_works_with_no_root_file_at_all(tmp_path, monkeypatch):
profile = tmp_path / "profile.json"
profile.write_text(json.dumps(VALID), encoding="utf-8")
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(_profile(tmp_path / "profile.json")))
assert tasks_config.read_config(tmp_path / "elsewhere") is not None
def test_a_relative_override_is_taken_from_the_working_directory(tmp_path, monkeypatch):
(tmp_path / "profile.json").write_text(json.dumps(VALID), encoding="utf-8")
_profile(tmp_path / "profile.json")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", "profile.json")
assert tasks_config.read_config(tmp_path / "elsewhere") is not None
def test_an_override_naming_a_missing_file_is_an_error_never_no_tracker(tmp_path, monkeypatch):
_write(tmp_path, VALID) # a root file must not be a fallback for a broken override
missing = tmp_path / "gone.json"
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(missing))
def test_an_override_naming_a_missing_file_is_an_error_never_no_tracker(tmp_path, monkeypatch, _write):
_write(tmp_path) # root files must not be a fallback for a broken override
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(tmp_path / "gone.json"))
with pytest.raises(ValidationError, match="gone.json"):
tasks_config.read_config(tmp_path)
@@ -126,3 +126,21 @@ def test_a_broken_override_file_names_its_own_path(tmp_path, monkeypatch):
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
with pytest.raises(ValidationError, match="profile.json"):
tasks_config.read_config(tmp_path)
def test_a_profile_replaces_exactly_the_tracker_sections_of_both_files(tmp_path, monkeypatch, _write):
_write(tmp_path, tasks=dict(TASKS, stalled_waiting_days=99))
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(_profile(tmp_path / "profile.json")))
assert tasks_config.read_config(tmp_path).thresholds.stalled_waiting_days == 14
def test_a_secret_in_a_profile_is_only_ever_a_reference(tmp_path, monkeypatch):
plain = {"tasks": TASKS, "superproductivity": {"access": "api", "api_token": "plain-token-value"}}
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(_profile(tmp_path / "profile.json", plain)))
with pytest.raises(ValidationError) as caught:
tasks_config.read_config(tmp_path)
assert "plain-token-value" not in str(caught.value)
ref = {"tasks": TASKS, "superproductivity": {"access": "api", "api_token": {"ref": "sp_token"}}}
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(_profile(tmp_path / "ref.json", ref)))
monkeypatch.setenv("WIKITOOL_SECRET_SP_TOKEN", "from-env")
assert tasks_config.read_config(tmp_path).provider_config["api_token"] == "from-env"
+20 -3
View File
@@ -26,7 +26,7 @@ def _stamp(root: Path) -> None:
def _config_file(root: Path, **fields) -> None:
import json
(root / ".wikitool-telemetry.json").write_text(json.dumps(fields), encoding="utf-8")
(root / ".wikitool-local.json").write_text(json.dumps({"schema": 1, "telemetry": fields}), encoding="utf-8")
def test_a_dev_checkout_defaults_on(tmp_path):
@@ -47,7 +47,7 @@ def test_the_config_file_overrides_the_form_default(tmp_path):
_config_file(tmp_path, enabled=True)
pol = policy.resolve(tmp_path)
assert pol.enabled is True
assert ".wikitool-telemetry.json" in pol.reason
assert ".wikitool-local.json" in pol.reason
def test_the_env_var_overrides_the_config_file_in_both_directions(tmp_path, monkeypatch):
@@ -92,7 +92,7 @@ def test_a_non_positive_or_unparsable_override_is_ignored(tmp_path, monkeypatch)
def test_a_malformed_config_file_is_treated_as_absent(tmp_path):
(tmp_path / ".wikitool-telemetry.json").write_text("{not json", encoding="utf-8")
(tmp_path / ".wikitool-local.json").write_text("{not json", encoding="utf-8")
pol = policy.resolve(tmp_path)
assert pol.enabled is True # falls through to the dev-checkout form default
@@ -125,3 +125,20 @@ def test_resolve_defaults_to_config_root(tmp_path, monkeypatch):
monkeypatch.setattr(config, "ROOT", tmp_path)
_stamp(tmp_path)
assert policy.resolve().enabled is False
def test_a_leftover_file_of_an_earlier_version_is_ignored_silently(tmp_path):
"""The hook never fails and never prompts: the old file counts for nothing
and the form default applies."""
(tmp_path / ".wikitool-telemetry.json").write_text('{"enabled": false}', encoding="utf-8")
pol = policy.resolve(tmp_path)
assert pol.enabled is True
assert ".wikitool-telemetry.json" not in pol.reason
def test_only_the_telemetry_section_is_read_and_leniently(tmp_path):
(tmp_path / ".wikitool-local.json").write_text(
'{"schema": 1, "upload": "garbage", "telemetry": {"keep_sessions": 7, "enabled": "yes"}}',
encoding="utf-8")
pol = policy.resolve(tmp_path)
assert pol.keep_sessions == 7
+7 -4
View File
@@ -77,7 +77,8 @@ def _provisioner(target: LiveTarget):
reader = live.reader_for(target.config)
from chemenu.tasks import caldav as cd
cfg = cd.CalDAVConfig.from_dict(target.config["caldav"])
cfg = cd.CalDAVConfig.from_section(
settings.Section.from_data(config_shapes.CALDAV, target.config["caldav"]))
writer = cd.CalDAVWriter(cfg, reader)
for name in (live.MARKER_PROJECT, cfg.inbox_list, cfg.someday_list):
if name not in {p.name for p in reader.projects()}:
@@ -97,7 +98,9 @@ def test_the_documented_workflow_runs_against_the_real_tracker(
root = kb_dir.parent
profile = tmp_path / "live-profile.json"
profile.write_text(json.dumps(target.config), encoding="utf-8")
assert "plain" not in profile.read_text(encoding="utf-8")
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
live.export_secrets(target, monkeypatch.setenv)
with capsys.disabled():
print(f"\n[tracker-live] {target.label} - version {target.version} - run {live.RUN_ID}")
record_property("tracker_kind", target.kind)
@@ -229,7 +232,7 @@ def test_a_profile_with_a_home_relative_path_is_refused(tmp_path, monkeypatch):
monkeypatch.setattr(live, "REPO_ROOT", tmp_path)
(tmp_path / live.PROFILE_DIR).mkdir()
(tmp_path / live.PROFILE_DIR / "mine.json").write_text(json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": live.THRESHOLDS,
"schema": 1, "tasks": {"provider": "superproductivity", **live.THRESHOLDS},
"superproductivity": {"access": "snapshot", "backups_dir": "~/.config/sp/backups"},
}))
with pytest.raises(LiveAbort, match="absolute"):
@@ -246,7 +249,7 @@ def test_a_profile_is_read_exactly_as_written(tmp_path, monkeypatch):
monkeypatch.setattr(live, "REPO_ROOT", tmp_path)
(tmp_path / live.PROFILE_DIR).mkdir()
(tmp_path / live.PROFILE_DIR / "mine.json").write_text(json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": live.THRESHOLDS,
"schema": 1, "tasks": {"provider": "superproductivity", **live.THRESHOLDS},
"superproductivity": {"access": "snapshot", "backups_dir": str(tmp_path)},
"live_test_version": "19.1.0",
}))
@@ -270,7 +273,7 @@ def test_caldav_target_needs_both_credentials():
"CHEMENU_LIVE_CALDAV_URL": "http://127.0.0.1:5232/u/",
"CHEMENU_LIVE_CALDAV_USER": "u", "CHEMENU_LIVE_CALDAV_PASSWORD": "p",
})
assert target is not None and target.config["provider"] == "caldav"
assert target is not None and target.config["tasks"]["provider"] == "caldav"
def test_item_titles_carry_the_run_prefix():
+26 -43
View File
@@ -13,7 +13,7 @@ from pathlib import Path
import pytest
from chemenu import upload
from chemenu import config, legacy_settings, upload
from chemenu.errors import ValidationError
CONTENT = b"hello world, this is a test submission\n"
@@ -59,72 +59,56 @@ def test_read_config_absent_is_none(tmp_path):
assert upload.read_config(tmp_path) is None
def _section(tmp_path, **fields):
(tmp_path / config.LOCAL_SETTINGS_FILENAME).write_text(
json.dumps({"schema": 1, "upload": fields}), encoding="utf-8")
GOOD = {"max_bytes": 100, "allowed_extensions": [".md"],
"quota_submissions_per_day": 1, "quota_bytes_per_day": 1}
def test_read_config_malformed_json_is_an_error_not_unrestricted(tmp_path):
(tmp_path / ".wikitool-upload.json").write_text("{not json", encoding="utf-8")
(tmp_path / config.LOCAL_SETTINGS_FILENAME).write_text("{not json", encoding="utf-8")
with pytest.raises(ValidationError):
upload.read_config(tmp_path)
def test_read_config_missing_field_is_an_error(tmp_path):
(tmp_path / ".wikitool-upload.json").write_text(
json.dumps({"schema": 1, "max_bytes": 100}), encoding="utf-8"
)
_section(tmp_path, max_bytes=100)
with pytest.raises(ValidationError):
upload.read_config(tmp_path)
def test_read_config_extension_without_dot_is_rejected(tmp_path):
(tmp_path / ".wikitool-upload.json").write_text(
json.dumps({
"schema": 1, "max_bytes": 100, "allowed_extensions": ["md"],
"quota": {"submissions_per_day": 1, "bytes_per_day": 1},
}),
encoding="utf-8",
)
_section(tmp_path, **dict(GOOD, allowed_extensions=["md"]))
with pytest.raises(ValidationError):
upload.read_config(tmp_path)
def test_read_config_rejects_a_non_object_json(tmp_path):
(tmp_path / ".wikitool-upload.json").write_text("[1, 2, 3]", encoding="utf-8")
(tmp_path / config.LOCAL_SETTINGS_FILENAME).write_text("[1, 2, 3]", encoding="utf-8")
with pytest.raises(ValidationError):
upload.read_config(tmp_path)
@pytest.mark.parametrize("value", [-1, 0])
def test_read_config_rejects_a_non_positive_max_bytes(tmp_path, value):
(tmp_path / ".wikitool-upload.json").write_text(
json.dumps({
"schema": 1, "max_bytes": value, "allowed_extensions": [".md"],
"quota": {"submissions_per_day": 1, "bytes_per_day": 1},
}),
encoding="utf-8",
)
_section(tmp_path, **dict(GOOD, max_bytes=value))
with pytest.raises(ValidationError):
upload.read_config(tmp_path)
@pytest.mark.parametrize("key,value", [("submissions_per_day", 0), ("bytes_per_day", -5)])
@pytest.mark.parametrize("key,value", [("quota_submissions_per_day", 0), ("quota_bytes_per_day", -5)])
def test_read_config_rejects_non_positive_quota_values(tmp_path, key, value):
quota = {"submissions_per_day": 1, "bytes_per_day": 1}
quota[key] = value
(tmp_path / ".wikitool-upload.json").write_text(
json.dumps({"schema": 1, "max_bytes": 100, "allowed_extensions": [".md"], "quota": quota}),
encoding="utf-8",
)
_section(tmp_path, **dict(GOOD, **{key: value}))
with pytest.raises(ValidationError):
upload.read_config(tmp_path)
def test_read_config_valid_file_parses(tmp_path):
(tmp_path / ".wikitool-upload.json").write_text(
json.dumps({
"schema": 1, "identity_header": "X-User", "max_bytes": 2048,
"allowed_extensions": [".MD", ".pdf"],
"quota": {"submissions_per_day": 3, "bytes_per_day": 999},
}),
encoding="utf-8",
)
_section(tmp_path, identity_header="X-User", max_bytes=2048, allowed_extensions=[".MD", ".pdf"],
quota_submissions_per_day=3, quota_bytes_per_day=999)
cfg = upload.read_config(tmp_path)
assert cfg.identity_header == "X-User"
assert cfg.max_bytes == 2048
@@ -134,17 +118,16 @@ def test_read_config_valid_file_parses(tmp_path):
def test_read_config_default_identity_header(tmp_path):
(tmp_path / ".wikitool-upload.json").write_text(
json.dumps({
"schema": 1, "max_bytes": 10,
"allowed_extensions": [".md"],
"quota": {"submissions_per_day": 1, "bytes_per_day": 10},
}),
encoding="utf-8",
)
_section(tmp_path, **GOOD)
assert upload.read_config(tmp_path).identity_header == upload.DEFAULT_IDENTITY_HEADER
def test_read_config_refuses_a_file_of_an_earlier_version(tmp_path):
(tmp_path / ".wikitool-upload.json").write_text("{}", encoding="utf-8")
with pytest.raises(legacy_settings.LegacySettingsLeftover, match="config migrate"):
upload.read_config(tmp_path)
# --- the write choke point ----------------------------------------------------
def test_write_primitive_refuses_a_relative_escape(tmp_path):
+32 -12
View File
@@ -19,6 +19,11 @@ the environment, none of them a fixed address:
provision the marker calendar in (Radicale in CI).
- `CHEMENU_LIVE_PROFILE`: a profile file `.wikitool-tasks.d/<name>.json` in the
checkout - a tracker of the user's own, used exactly as configured.
A profile is in the format of `.wikitool-local.json`, restricted to the sections `tasks`,
`superproductivity` and `caldav`. A secret in it is only ever a reference; the suite's own
throwaway tokens travel in `LiveTarget.secrets` and reach the run as
`WIKITOOL_SECRET_<NAME>`, never as a file.
"""
from __future__ import annotations
@@ -32,7 +37,7 @@ from datetime import date, timedelta
from pathlib import Path
from typing import Any, Callable, Optional
from chemenu import tasks
from chemenu import config, config_shapes, settings, tasks
from chemenu.tasks import config as tasks_config
from chemenu.tasks.protocol import TaskReader, find_project, normalize_project_name
from chemenu.tests.sp_headless import LiveAbort
@@ -52,6 +57,7 @@ class LiveTarget:
label: str
version: str
config: dict[str, Any]
secrets: dict[str, str] = field(default_factory=dict)
cleanup: Callable[[], None] = field(default=lambda: None)
@@ -110,13 +116,13 @@ def caldav_target_from_env(environ: "dict[str, str] | os._Environ[str]" = os.env
version=environ.get("CHEMENU_LIVE_CALDAV_VERSION", "unknown"),
config={
"schema": 1,
"provider": "caldav",
"thresholds": THRESHOLDS,
"tasks": {"provider": "caldav", **THRESHOLDS},
"caldav": {
"url": url, "username": user, "app_password": password,
"url": url, "username": user, "app_password": {"ref": "live_caldav_app_password"},
"inbox_list": "Inbox", "someday_list": "Someday",
},
},
secrets={"live_caldav_app_password": password},
)
@@ -140,12 +146,13 @@ def sp_target_from_env(environ: "dict[str, str] | os._Environ[str]" = os.environ
version=sp_headless.installed_version(executable),
config={
"schema": 1,
"provider": "superproductivity",
"thresholds": THRESHOLDS,
"tasks": {"provider": "superproductivity", **THRESHOLDS},
"superproductivity": {
"access": "api", "api_base_url": process.base_url, "api_token": process.token,
"access": "api", "api_base_url": process.base_url,
"api_token": {"ref": "live_sp_api_token"},
},
},
secrets={"live_sp_api_token": process.token},
cleanup=cleanup,
)
@@ -164,13 +171,26 @@ def build_target(kind: str, environ: "dict[str, str] | os._Environ[str]" = os.en
# --- guard and scenario ------------------------------------------------------------------
def export_secrets(target: LiveTarget, setenv: Callable[[str, str], None]) -> None:
"""Hand the target's throwaway secrets to the run, the one way a secret reaches it."""
for name, value in target.secrets.items():
setenv(config.ENV_SECRET_PREFIX + name.upper(), value)
def reader_for(target_config: dict[str, Any]) -> TaskReader:
"""The reader `target_config` describes, built the way the commands build it."""
provider = target_config["provider"]
"""The reader `target_config` describes, built the way the commands build it. A
reference in it resolves from the environment, so `export_secrets` comes first."""
tracker = settings.Section.from_data(config_shapes.TASKS, target_config["tasks"])
provider = tracker["provider"]
shape = next(s for s in config_shapes.SETTINGS_SECTIONS if s.key == provider)
cfg = tasks_config.TasksConfig(
provider=provider,
thresholds=tasks_config.Thresholds(**target_config["thresholds"]),
provider_config=target_config[provider],
thresholds=tasks_config.Thresholds(
stalled_waiting_days=tracker["stalled_waiting_days"],
unpaged_project_weeks=tracker["unpaged_project_weeks"],
someday_stale_months=tracker["someday_stale_months"],
),
provider_config=settings.Section.from_data(shape, target_config[provider]),
)
return tasks.build_reader(cfg)
@@ -201,6 +221,6 @@ def overdue_day(today: date) -> date:
__all__ = [
"ITEM_PREFIX", "KINDS", "LiveAbort", "LiveTarget", "MARKER_PROJECT", "THRESHOLDS",
"build_target", "guard", "normalize_project_name", "overdue_day", "reader_for",
"build_target", "export_secrets", "guard", "normalize_project_name", "overdue_day", "reader_for",
"required_kinds", "run_id_titles",
]