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)
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:
1 parent
e085a8b6c1
commit
db8f90c229
79 files changed
+2784
-1793
No files matched your search
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 ----------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ---------------------------------------
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
Reference in new issue
Block a user