Files
chemenu/tools/chemenu/legacy_settings.py
T
torbenandClaude Code cceb9a7bfe
CI / verify (push) Successful in 8m41s
CI / pwsh (push) Successful in 2m44s
Release / release (push) Successful in 35s
Release / wiki-mirror (push) Skipped
intake: several configured intake channels (mcp, folder, command) all end in quarantine/ (was mcp-upload/), and upload collect pulls the synced-folder and command channels into it (#213); 10.0.0-beta.11
Files changed:
- .gitignore
- CHANGES.md
- VERSION
- docs/explanation/architecture.md
- docs/explanation/pipeline-rationale.md
- docs/how-to/README.md
- docs/how-to/back-up-to-a-synced-folder.md
- docs/how-to/collect-from-a-synced-folder.md
- docs/how-to/ingest-a-source.md
- docs/how-to/set-up-the-mcp-server.md
- docs/reference/commands/README.md
- docs/reference/commands/git.md
- docs/reference/commands/instance-health.md
- docs/reference/commands/raw-material-and-uploads.md
- docs/reference/configuration.md
- instructions/gates.md
- instructions/ingest-queue.md
- instructions/mcp-read-server.md
- instructions/migrations/10.0.0-settings-tiers.md
- instructions/wiki-ingest/SKILL.md
- raw/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/cli_contract.py
- tools/chemenu/collect.py
- tools/chemenu/commands/backup_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/raw/pending.py
- tools/chemenu/commands/upload_cmd.py
- tools/chemenu/config.py
- tools/chemenu/config_shapes.py
- tools/chemenu/legacy_settings.py
- tools/chemenu/mcp/server.py
- tools/chemenu/reference_doc.py
- tools/chemenu/tests/test_backup.py
- tools/chemenu/tests/test_collect.py
- tools/chemenu/tests/test_config_shapes.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_legacy_settings.py
- tools/chemenu/tests/test_mcp_server.py
- tools/chemenu/tests/test_upload.py
- tools/chemenu/tests/test_upload_cmd.py
- tools/chemenu/upload.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-12 22:04:18 +02:00

447 lines
19 KiB
Python

"""The four per-feature settings files of earlier versions, and the move out of them.
Before the tiered settings (`chemenu.settings`) every feature had a file of its
own at the root of the checkout. None of them is read any more; this is the one
module that still names them, to detect a leftover and to carry its content
into `wikitool.json`, `.wikitool-local.json` and the secrets file -
`wikitool config migrate`.
| Old file | Becomes |
|---|---|
| `.wikitool-telemetry.json` | section `telemetry` |
| `.wikitool-remotes.json` | section `remotes` |
| `.wikitool-upload.json` | section `intake`, channel `mcp` with `handler: "mcp"` (`quota.*` is flat: `quota_*`) |
| `.wikitool-tasks.json` | sections `tasks` (`thresholds.*` is flat), `superproductivity`, `caldav` |
What the migration promises, and how it keeps it:
- **Every setting keeps its effective value, defaults included.** The old
content is read with the same coercions and checks the new sections use
(`config_shapes.check`), so a value is accepted exactly when it was, and what
is written is the coerced value. A field the old file did not set is not
written: its default still applies.
- **No secret value reaches the tree.** A secret field's value goes into the
secrets file (`config.secrets_file`), only a reference into `wikitool.json`;
nothing here prints a value.
- **An abort leaves the old files as they were.** Everything is computed and
checked in memory first. Then the targets are written - secrets file,
`wikitool.json`, `.wikitool-local.json` - and read back and checked again.
Only then are the old files removed; before that, any failure puts every
target back to the bytes it had.
"""
from __future__ import annotations
import copy
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
from chemenu import config, config_shapes, settings
from chemenu.config_shapes import INSTANCE, SECRET
from chemenu.errors import ChemenuError, ValidationError
TELEMETRY_FILE = ".wikitool-telemetry.json"
REMOTES_FILE = ".wikitool-remotes.json"
UPLOAD_FILE = ".wikitool-upload.json"
TASKS_FILE = ".wikitool-tasks.json"
# Feature (= the section that replaces the file) -> the file it came from.
FILES = {
"telemetry": TELEMETRY_FILE,
"remotes": REMOTES_FILE,
"intake": UPLOAD_FILE,
"tasks": TASKS_FILE,
}
MIGRATE_COMMAND = "tools/wikitool config migrate"
class LegacySettingsLeftover(ChemenuError):
"""A settings file of an earlier version is still there and nothing reads
it. Not a fault in the caller's input and not something to retry: a human
runs `config migrate`, so the CLI renders it as exit 42 (`needs_clearance`)."""
def __init__(self, names: list[str]):
self.names = names
listed = ", ".join(names)
verb = "is a settings file" if len(names) == 1 else "are settings files"
super().__init__(
f"{listed} {verb} of an earlier version and no longer read - the settings it held "
f"are not in effect. Move them into the tiered settings with: {MIGRATE_COMMAND}"
)
def present(root: "Path | str") -> list[str]:
"""The old files that exist under `root`, in a fixed order."""
return [name for name in FILES.values() if (Path(root) / name).exists()]
def refuse_leftover(root: "Path | str", *features: str) -> None:
"""Raise `LegacySettingsLeftover` when the old file of any of `features`
exists. The tracker file is not looked at while a tracker profile
(`WIKITOOL_TASKS_CONFIG`) replaces its sections - it is not what is read."""
names = []
for feature in features:
if feature == "tasks" and config.tasks_config_override() is not None:
continue
name = FILES[feature]
if (Path(root) / name).exists():
names.append(name)
if names:
raise LegacySettingsLeftover(names)
# ---------------------------------------------------------------------------
# Reading the old files
# ---------------------------------------------------------------------------
@dataclass
class Plan:
"""What the migration would do: the values per section (coerced, JSON-safe),
and what to say about them."""
entries: dict[str, dict[str, Any]] = field(default_factory=dict)
notes: list[str] = field(default_factory=list)
files: list[Path] = field(default_factory=list)
def _json_safe(value: Any) -> Any:
return list(value) if isinstance(value, tuple) else value
def _read_old(path: Path, *, lenient: bool, plan: Plan) -> Any:
"""The old file's parsed JSON. A telemetry file that cannot be read counts
as absent, as it always did; any other raises `ValidationError`."""
import json
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
if lenient:
plan.notes.append(f"{path.name} could not be read ({type(exc).__name__}) and counts as absent, as before")
return None
raise ValidationError(f"{path.name} cannot be read as JSON ({exc}) - fix or remove it, then run again") from None
def _take(plan: Plan, shape: config_shapes.Shape, data: Any, *, file: str, lenient: bool = False) -> None:
"""The coerced values of `data` that `shape` accepts, into `plan`. Strict:
a problem raises `ValidationError` naming the old file. Lenient: a field
with a problem is dropped and said so."""
checked = config_shapes.check(shape, data)
if checked.problems:
found = "; ".join(problem.message for problem in checked.problems)
if not lenient:
raise ValidationError(f"{file}: {found}")
plan.notes.append(f"{file}: not migrated, as it was not read - {found}")
values = {name: _json_safe(checked.values[name]) for name in checked.given}
ignored = [key for key in checked.unknown if key != "schema"]
if ignored:
plan.notes.append(f"{file}: {', '.join(ignored)} was not read before and is not migrated")
if values:
plan.entries.setdefault(shape.key, {}).update(values)
def _flat(data: Any, nested: str, prefix: str, file: str) -> Any:
"""`data` with the object `nested` spread into `<prefix>_<key>` keys."""
if not isinstance(data, dict) or nested not in data:
return data
inner = data[nested]
if not isinstance(inner, dict):
raise ValidationError(f"{file}: {nested} must be an object")
flat = {key: value for key, value in data.items() if key != nested}
for key, value in inner.items():
flat[f"{prefix}{key}"] = value
return flat
def _provider_section(name: str, body: Any, file: str) -> dict[str, Any]:
"""The migrated values of one provider section, after the checks the
adapter makes when it reads it - the access-path rules of Super
Productivity included. Raises `ValidationError`."""
shape = {config_shapes.SUPERPRODUCTIVITY.key: config_shapes.SUPERPRODUCTIVITY,
config_shapes.CALDAV.key: config_shapes.CALDAV}[name]
if not isinstance(body, dict):
raise ValidationError(f"{file}: {name} must be an object")
try:
section = settings.Section.from_data(shape, body)
if name == config_shapes.SUPERPRODUCTIVITY.key:
from chemenu.tasks import superproductivity
superproductivity.SuperProductivityConfig.from_section(section)
except ValidationError as exc:
raise ValidationError(f"{file}: {name}: {exc}") from None
return {key: _json_safe(section.values[key]) for key in section.given}
def read_plan(root: "Path | str") -> Plan:
"""What `config migrate` would move, from every old file under `root`.
Raises `ValidationError` for the first old file that is broken; nothing is
written by then."""
root = Path(root)
plan = Plan()
path = root / TELEMETRY_FILE
if path.exists():
plan.files.append(path)
data = _read_old(path, lenient=True, plan=plan)
if data is not None and not isinstance(data, dict):
plan.notes.append(f"{TELEMETRY_FILE} is not a JSON object and counts as absent, as before")
elif data is not None:
_take(plan, config_shapes.TELEMETRY, data, file=TELEMETRY_FILE, lenient=True)
path = root / REMOTES_FILE
if path.exists():
plan.files.append(path)
_take(plan, config_shapes.REMOTES, _read_old(path, lenient=False, plan=plan), file=REMOTES_FILE)
path = root / UPLOAD_FILE
if path.exists():
plan.files.append(path)
data = _flat(_read_old(path, lenient=False, plan=plan), "quota", "quota_", UPLOAD_FILE)
if isinstance(data, dict):
known = set(config_shapes.mcp_channel_keys())
ignored = [key for key in data if key not in known and key != "schema"]
if ignored:
plan.notes.append(f"{UPLOAD_FILE}: {', '.join(ignored)} was not read before and is not migrated")
data = {key: value for key, value in data.items() if key in known}
data = {"handler": config_shapes.INTAKE_HANDLER_MCP, **data}
_take(plan, config_shapes.INTAKE, {"channels": {"mcp": data}}, file=UPLOAD_FILE)
path = root / TASKS_FILE
if path.exists():
plan.files.append(path)
_read_tasks(path, plan)
return plan
def _read_tasks(path: Path, plan: Plan) -> None:
data = _read_old(path, lenient=False, plan=plan)
data = _flat(data, "thresholds", "", TASKS_FILE)
# `thresholds` was an object whose keys are now the section's own fields;
# `provider` was the only other field. Everything else is a provider section.
_take(plan, config_shapes.TASKS, {k: v for k, v in data.items() if k not in config_shapes.PROVIDERS}
if isinstance(data, dict) else data, file=TASKS_FILE)
provider = plan.entries["tasks"]["provider"]
for name in config_shapes.PROVIDERS:
body = data.get(name)
if name == provider:
if body is None:
raise ValidationError(f"{TASKS_FILE}: provider is {provider!r}, so the file needs a {provider} section")
plan.entries[name] = _provider_section(name, body, TASKS_FILE)
elif body is not None:
try:
plan.entries[name] = _provider_section(name, body, TASKS_FILE)
except ValidationError as exc:
plan.notes.append(f"{exc} - the section of a provider that is not chosen is not migrated")
# ---------------------------------------------------------------------------
# Splitting by tier, merging into what is there
# ---------------------------------------------------------------------------
@dataclass
class Targets:
instance: dict
local: dict
secrets: dict
moved: list[str]
def _secret_name(section: str, name: str) -> str:
return f"{section}_{name}"
def _merge(target: dict, section: Optional[str], name: str, value: Any, label: str) -> None:
body = target
where = name
if section is not None:
body = target.setdefault(section, {})
where = f"{section}.{name}"
if not isinstance(body, dict):
raise ValidationError(f"{label}: {section} is not an object")
if name in body and body[name] != value:
raise ValidationError(
f"{label}: {where} already holds a different value than the old file. Decide which "
"one stands, remove the other, and run again"
)
body[name] = value
def split(plan: Plan, instance: dict, local: dict, secrets: dict) -> Targets:
"""`plan` distributed over the files by tier, on top of what they hold.
Raises `ValidationError` on a key that already holds another value."""
instance, local, secrets = copy.deepcopy(instance), copy.deepcopy(local), dict(secrets)
known = settings.sections()
moved: list[str] = []
for section, values in plan.entries.items():
shape = known[section]
for name, value in values.items():
spec = shape.field(name)
key = f"{section}.{name}"
if spec.tier == SECRET:
ref = _secret_name(section, name)
if secrets.get(ref, value) != value:
raise ValidationError(
f"the secrets file already holds a different value under {ref!r} - decide "
"which one stands, remove the other, and run again"
)
secrets[ref] = value
_merge(instance, section, name, {settings.REF_KEY: ref}, config.INSTANCE_SETTINGS_FILENAME)
moved.append(f"{key} -> secrets file, reference {ref!r} in {config.INSTANCE_SETTINGS_FILENAME}")
elif spec.tier == INSTANCE:
_merge(instance, section, name, value, config.INSTANCE_SETTINGS_FILENAME)
moved.append(f"{key} -> {config.INSTANCE_SETTINGS_FILENAME}")
else:
_merge(local, section, name, value, config.LOCAL_SETTINGS_FILENAME)
moved.append(f"{key} -> {config.LOCAL_SETTINGS_FILENAME}")
return Targets(instance, local, secrets, moved)
# ---------------------------------------------------------------------------
# Writing, reading back, rolling back
# ---------------------------------------------------------------------------
def _write(path: Path, data: dict) -> None:
settings.write_file(path, data)
def _missing_ancestors(path: Path) -> list[Path]:
found = []
parent = path.parent
while not parent.exists():
found.append(parent)
parent = parent.parent
return found
def _restore(originals: dict[Path, Optional[bytes]], created_dirs: list[Path]) -> None:
for path, content in originals.items():
try:
if content is None:
path.unlink(missing_ok=True)
else:
path.write_bytes(content)
except OSError:
pass
for directory in created_dirs:
try:
directory.rmdir()
except OSError:
pass
def _normal(value: Any) -> Any:
return list(value) if isinstance(value, tuple) else value
def _verify(root: Path, plan: Plan, secrets_path: Optional[Path], expected_secrets: dict) -> None:
"""Read the written files back and check that every planned value is in
effect. Raises `ValidationError`."""
loaded = settings.check_files(settings.read_file(settings.instance_path(root)),
settings.read_file(settings.local_path(root)))
known = settings.sections()
for section, values in plan.entries.items():
got = loaded.section(section)
for name, value in values.items():
spec = known[section].field(name)
if spec.tier == SECRET:
ref = got.refs.get(name)
if ref is None or ref.target != _secret_name(section, name):
raise ValidationError(f"read-back: {section}.{name} is not a reference to the secrets file")
elif _normal(got.values.get(name)) != value:
raise ValidationError(f"read-back: {section}.{name} does not hold the migrated value")
if expected_secrets:
assert secrets_path is not None
stored = settings.read_secrets_file(secrets_path) or {}
for ref, value in expected_secrets.items():
if stored.get(ref) != value:
raise ValidationError(f"read-back: the secrets file does not hold {ref!r}")
@dataclass
class Result:
moved: list[str]
notes: list[str]
removed: list[str]
def migrate(root: "Path | str") -> Result:
"""Carry every old file under `root` into the tiered settings. Raises
`ValidationError` - with the old files and the targets as they were - for
anything it cannot do or check; returns an empty `Result` when there is
nothing to move."""
root = Path(root)
plan = read_plan(root)
if not plan.files:
return Result([], [], [])
instance_path, local_path = settings.instance_path(root), settings.local_path(root)
instance = settings.read_file(instance_path)
local = settings.read_file(local_path)
# Everything that can be known without writing is checked first.
instance_id = (instance or {}).get(settings.INSTANCE_ID_KEY)
needs_secrets = any(_tier(section, name) == SECRET
for section, values in plan.entries.items() for name in values)
secrets_path: Optional[Path] = None
existing_secrets: dict = {}
if needs_secrets:
if instance_id is None:
instance_id = settings.new_instance_id()
secrets_path = config.secrets_file(instance_id)
existing_secrets = settings.read_secrets_file(secrets_path) or {}
targets = split(plan, instance or {}, local or {}, existing_secrets)
changed_secrets = {ref: value for ref, value in targets.secrets.items()
if existing_secrets.get(ref) != value}
write_instance = targets.instance != (instance or {})
write_local = targets.local != (local or {})
if write_instance:
targets.instance.setdefault("schema", settings.SCHEMA)
targets.instance.setdefault(settings.INSTANCE_ID_KEY, instance_id or settings.new_instance_id())
if write_local:
targets.local.setdefault("schema", settings.SCHEMA)
try:
settings.check_files(targets.instance if (write_instance or instance is not None) else None,
targets.local if (write_local or local is not None) else None)
except ValidationError as exc:
raise ValidationError(f"the settings would not pass their check after the move:\n{exc}") from None
writes: list[tuple[Path, dict]] = []
if changed_secrets:
assert secrets_path is not None
writes.append((secrets_path, targets.secrets))
if write_instance:
writes.append((instance_path, targets.instance))
if write_local:
writes.append((local_path, targets.local))
originals = {path: (path.read_bytes() if path.exists() else None) for path, _ in writes}
created_dirs = _missing_ancestors(secrets_path) if changed_secrets and secrets_path else []
try:
for path, data in writes:
_write(path, data)
_verify(root, plan, secrets_path, {ref: targets.secrets[ref] for ref in changed_secrets})
except BaseException as exc:
_restore(originals, created_dirs)
if isinstance(exc, ValidationError):
raise ValidationError(f"{exc}. Nothing was changed; the old files are as they were") from None
if isinstance(exc, Exception):
raise ValidationError(
f"the move failed ({type(exc).__name__}). Nothing was changed; the old files are as they were"
) from None
raise
removed = []
for path in plan.files:
path.unlink()
removed.append(path.name)
return Result(targets.moved, plan.notes, removed)
def _tier(section: str, name: str) -> str:
return settings.sections()[section].field(name).tier