diff --git a/pyproject.toml b/pyproject.toml index 49f5c30..dcf7beb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -157,6 +157,7 @@ color = true line-length = 119 [tool.ruff] +target-version = "py313" # Issue regarding catching multiple exceptions in 3.14 exclude = [ ".bzr", ".direnv", diff --git a/tests/test_settings.py b/tests/test_settings.py index 849edaf..8520550 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,8 +1,13 @@ """Unit tests for wiki_toolkit.settings.""" +import os +import stat +import warnings from typing import TYPE_CHECKING -from wiki_toolkit.settings import resolve_docs_dir +import pytest + +from wiki_toolkit.settings import build_context, resolve_docs_dir if TYPE_CHECKING: from pathlib import Path @@ -96,3 +101,207 @@ def test_resolve_docs_dir_malformed_toml_falls_through_to_default(tmp_path: Path assert result.docs_dir == tmp_path / "docs" assert result.source == "default" + + +def test_build_context_defaults(tmp_path: Path) -> None: + """With nothing else set, every field resolves to its built-in default.""" + context, sources = build_context(cwd=tmp_path) + + assert context.docs_dir == tmp_path / "docs" + assert context.repo_root == tmp_path + assert context.branch_prefix == "wiki-update/" + assert context.batch_byte_cap == 100_000 + assert context.batch_file_cap == 20 + assert sources == { + "docs_dir": "default", + "repo_root": "default", + "branch_prefix": "default", + "batch_byte_cap": "default", + "batch_file_cap": "default", + } + + +def test_build_context_repo_root_walks_up_to_git(tmp_path: Path) -> None: + """repo_root resolves by walking up from cwd to the nearest `.git`.""" + (tmp_path / ".git").mkdir() + nested = tmp_path / "a" / "b" + nested.mkdir(parents=True) + + context, sources = build_context(cwd=nested) + + assert context.repo_root == tmp_path + assert sources["repo_root"] == "default" + + +def test_build_context_flag_wins(tmp_path: Path, monkeypatch) -> None: + """A CLI flag wins over env, dedicated file, and pyproject.toml.""" + monkeypatch.setenv("WIKI_TOOLKIT_DOCS_DIR", str(tmp_path / "env-docs")) + (tmp_path / ".wiki-toolkit.toml").write_text('docs_dir = "dedicated-docs"\n') + + context, sources = build_context(docs_dir_flag=tmp_path / "flag-docs", cwd=tmp_path) + + assert context.docs_dir == tmp_path / "flag-docs" + assert sources["docs_dir"] == "flag" + + +def test_build_context_env_wins_over_dedicated_file(tmp_path: Path, monkeypatch) -> None: + """A `WIKI_TOOLKIT_*` env var wins over the dedicated file and pyproject.toml.""" + (tmp_path / ".wiki-toolkit.toml").write_text('docs_dir = "dedicated-docs"\n') + monkeypatch.setenv("WIKI_TOOLKIT_DOCS_DIR", str(tmp_path / "env-docs")) + + context, sources = build_context(cwd=tmp_path) + + assert context.docs_dir == tmp_path / "env-docs" + assert sources["docs_dir"] == "env" + + +def test_build_context_all_fields_resolvable_via_env(tmp_path: Path, monkeypatch) -> None: + """All five fields are uniformly resolvable via `WIKI_TOOLKIT_*` env vars.""" + monkeypatch.setenv("WIKI_TOOLKIT_DOCS_DIR", str(tmp_path / "env-docs")) + monkeypatch.setenv("WIKI_TOOLKIT_REPO_ROOT", str(tmp_path / "env-repo")) + monkeypatch.setenv("WIKI_TOOLKIT_BRANCH_PREFIX", "env-prefix/") + monkeypatch.setenv("WIKI_TOOLKIT_BATCH_BYTE_CAP", "42") + monkeypatch.setenv("WIKI_TOOLKIT_BATCH_FILE_CAP", "7") + + context, sources = build_context(cwd=tmp_path) + + assert context.docs_dir == tmp_path / "env-docs" + assert context.repo_root == tmp_path / "env-repo" + assert context.branch_prefix == "env-prefix/" + assert context.batch_byte_cap == 42 + assert context.batch_file_cap == 7 + assert all(source == "env" for source in sources.values()) + + +def test_build_context_dedicated_file_wins_over_pyproject(tmp_path: Path) -> None: + """The dedicated `.wiki-toolkit.toml` wins over `pyproject.toml`'s `[tool.wiki_toolkit]` table.""" + (tmp_path / "pyproject.toml").write_text('[tool.wiki_toolkit]\ndocs_dir = "py-docs"\n') + (tmp_path / ".wiki-toolkit.toml").write_text('docs_dir = "dedicated-docs"\n') + + context, sources = build_context(cwd=tmp_path) + + assert context.docs_dir == tmp_path / "dedicated-docs" + assert sources["docs_dir"] == "dedicated_file" + + +def test_build_context_pyproject_wins_over_default(tmp_path: Path) -> None: + """`pyproject.toml`'s `[tool.wiki_toolkit]` table wins over the built-in default.""" + (tmp_path / "pyproject.toml").write_text('[tool.wiki_toolkit]\nbranch_prefix = "py-prefix/"\n') + + context, sources = build_context(cwd=tmp_path) + + assert context.branch_prefix == "py-prefix/" + assert sources["branch_prefix"] == "pyproject" + + +def test_build_context_malformed_dedicated_file_falls_through(tmp_path: Path) -> None: + """A `.wiki-toolkit.toml` that fails to parse falls through to pyproject.toml, not raising.""" + (tmp_path / ".wiki-toolkit.toml").write_text("not [ valid toml") + (tmp_path / "pyproject.toml").write_text('[tool.wiki_toolkit]\ndocs_dir = "py-docs"\n') + + context, sources = build_context(cwd=tmp_path) + + assert context.docs_dir == tmp_path / "py-docs" + assert sources["docs_dir"] == "pyproject" + + +def test_build_context_malformed_pyproject_table_falls_through(tmp_path: Path) -> None: + """A `pyproject.toml` that fails to parse falls through to the built-in default, not raising.""" + (tmp_path / "pyproject.toml").write_text("not [ valid toml") + + context, sources = build_context(cwd=tmp_path) + + assert context.docs_dir == tmp_path / "docs" + assert sources["docs_dir"] == "default" + + +def test_build_context_invalid_field_value_falls_back_to_default(tmp_path: Path) -> None: + """A non-positive batch cap in the dedicated file falls back to the built-in default.""" + (tmp_path / ".wiki-toolkit.toml").write_text("batch_byte_cap = -5\n") + + context, sources = build_context(cwd=tmp_path) + + assert context.batch_byte_cap == 100_000 + assert sources["batch_byte_cap"] == "default" + + +def test_build_context_empty_branch_prefix_falls_back_to_default(tmp_path: Path) -> None: + """An empty branch_prefix in the dedicated file falls back to the built-in default.""" + (tmp_path / ".wiki-toolkit.toml").write_text('branch_prefix = ""\n') + + context, sources = build_context(cwd=tmp_path) + + assert context.branch_prefix == "wiki-update/" + assert sources["branch_prefix"] == "default" + + +def test_build_context_invalid_env_value_falls_through(tmp_path: Path, monkeypatch) -> None: + """A non-integer `WIKI_TOOLKIT_BATCH_BYTE_CAP` falls through to the built-in default.""" + monkeypatch.setenv("WIKI_TOOLKIT_BATCH_BYTE_CAP", "not-a-number") + + context, sources = build_context(cwd=tmp_path) + + assert context.batch_byte_cap == 100_000 + assert sources["batch_byte_cap"] == "default" + + +def test_build_context_invalid_field_does_not_discard_valid_siblings(tmp_path: Path) -> None: + """An invalid batch_byte_cap in the dedicated file doesn't discard a valid docs_dir from the same tier.""" + (tmp_path / ".wiki-toolkit.toml").write_text('docs_dir = "custom-docs"\nbatch_byte_cap = -5\n') + + context, sources = build_context(cwd=tmp_path) + + assert context.docs_dir == tmp_path / "custom-docs" + assert sources["docs_dir"] == "dedicated_file" + assert context.batch_byte_cap == 100_000 + assert sources["batch_byte_cap"] == "default" + + +def test_build_context_invalid_env_value_does_not_discard_valid_siblings(tmp_path: Path, monkeypatch) -> None: + """An invalid WIKI_TOOLKIT_BATCH_BYTE_CAP doesn't discard a valid WIKI_TOOLKIT_DOCS_DIR.""" + monkeypatch.setenv("WIKI_TOOLKIT_DOCS_DIR", str(tmp_path / "env-docs")) + monkeypatch.setenv("WIKI_TOOLKIT_BATCH_BYTE_CAP", "not-a-number") + + context, sources = build_context(cwd=tmp_path) + + assert context.docs_dir == tmp_path / "env-docs" + assert sources["docs_dir"] == "env" + assert context.batch_byte_cap == 100_000 + assert sources["batch_byte_cap"] == "default" + + +def test_build_context_relative_env_docs_dir_resolves_against_cwd(tmp_path: Path, monkeypatch) -> None: + """A relative WIKI_TOOLKIT_DOCS_DIR resolves to an absolute path under cwd, like every other tier.""" + monkeypatch.setenv("WIKI_TOOLKIT_DOCS_DIR", "relative-docs") + + context, sources = build_context(cwd=tmp_path) + + assert context.docs_dir == tmp_path / "relative-docs" + assert context.docs_dir.is_absolute() + assert sources["docs_dir"] == "env" + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="root ignores file permissions") +def test_build_context_unreadable_dedicated_file_falls_through(tmp_path: Path) -> None: + """A `.wiki-toolkit.toml` that can't be read (permission denied) falls through, not raising.""" + dedicated_file = tmp_path / ".wiki-toolkit.toml" + dedicated_file.write_text('docs_dir = "dedicated-docs"\n') + dedicated_file.chmod(0) + (tmp_path / "pyproject.toml").write_text('[tool.wiki_toolkit]\ndocs_dir = "py-docs"\n') + + try: + context, sources = build_context(cwd=tmp_path) + finally: + dedicated_file.chmod(stat.S_IRUSR | stat.S_IWUSR) + + assert context.docs_dir == tmp_path / "py-docs" + assert sources["docs_dir"] == "pyproject" + + +def test_build_context_emits_no_warnings(tmp_path: Path) -> None: + """Resolving a pyproject.toml table doesn't emit pydantic-settings' unused-config-key warning.""" + (tmp_path / "pyproject.toml").write_text('[tool.wiki_toolkit]\ndocs_dir = "py-docs"\n') + + with warnings.catch_warnings(): + warnings.simplefilter("error") + build_context(cwd=tmp_path) diff --git a/wiki_toolkit/_io.py b/wiki_toolkit/_io.py index a71a4e7..4a1c619 100644 --- a/wiki_toolkit/_io.py +++ b/wiki_toolkit/_io.py @@ -1,14 +1,11 @@ """Shared JSONL read/write helpers, internal to wiki_toolkit.""" -from typing import TYPE_CHECKING +from pathlib import Path import orjson from wiki_toolkit.write_gate import stage_best_effort -if TYPE_CHECKING: - from pathlib import Path - def write_jsonl(path: Path, records: list[dict], *, stage_root: Path | None = None) -> None: """Write `records` to `path` as JSONL, one object per line. diff --git a/wiki_toolkit/batches.py b/wiki_toolkit/batches.py index 051894c..871ca50 100644 --- a/wiki_toolkit/batches.py +++ b/wiki_toolkit/batches.py @@ -6,10 +6,7 @@ """ from dataclasses import dataclass, field -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from pathlib import Path +from pathlib import Path BATCH_BYTE_CAP = 100_000 BATCH_FILE_CAP = 20 diff --git a/wiki_toolkit/doctor.py b/wiki_toolkit/doctor.py index 99c3398..7ed15c0 100644 --- a/wiki_toolkit/doctor.py +++ b/wiki_toolkit/doctor.py @@ -5,18 +5,14 @@ import sys from dataclasses import dataclass, field from importlib.metadata import PackageNotFoundError, version -from typing import TYPE_CHECKING +from pathlib import Path import orjson from wiki_toolkit.init import PROVENANCE_FILENAME +from wiki_toolkit.settings import ConfigSource from wiki_toolkit.sources import SOURCE_MANIFEST_FILENAME -if TYPE_CHECKING: - from pathlib import Path - - from wiki_toolkit.settings import ConfigSource - DOCS_DIRS = ("sources", "wiki") DOCS_FILES = ("catalog.jsonl", "log.jsonl", "schema.md", SOURCE_MANIFEST_FILENAME) DOCS_STRUCTURE = (*DOCS_FILES, *DOCS_DIRS) diff --git a/wiki_toolkit/log.py b/wiki_toolkit/log.py index e0995c2..0bd5672 100644 --- a/wiki_toolkit/log.py +++ b/wiki_toolkit/log.py @@ -2,15 +2,13 @@ from dataclasses import asdict, dataclass from datetime import UTC, datetime -from typing import TYPE_CHECKING, Literal +from pathlib import Path +from typing import Literal import orjson from wiki_toolkit.write_gate import stage_best_effort -if TYPE_CHECKING: - from pathlib import Path - LogAction = Literal["ingest", "update", "lint", "create", "archive", "delete"] ALLOWED_LOG_ACTIONS: tuple[LogAction, ...] = ("ingest", "update", "lint", "create", "archive", "delete") diff --git a/wiki_toolkit/settings.py b/wiki_toolkit/settings.py index 854d8ec..83a21a2 100644 --- a/wiki_toolkit/settings.py +++ b/wiki_toolkit/settings.py @@ -1,18 +1,41 @@ -"""Resolves wiki_toolkit configuration (currently just `docs_dir`). +"""Resolves wiki_toolkit configuration. -Precedence: CLI flag > `WIKI_TOOLKIT_DOCS_DIR` env var > nearest `pyproject.toml`'s -`[tool.wiki_toolkit]` table (found by walking upward from cwd, same convention as -ruff/mypy) > built-in default (`docs/` relative to cwd). +`resolve_docs_dir()` resolves just `docs_dir` (precedence: CLI flag > +`WIKI_TOOLKIT_DOCS_DIR` env var > nearest `pyproject.toml`'s `[tool.wiki_toolkit]` +table > built-in default). + +`build_context()` resolves the full `Context` (docs_dir, repo_root, branch_prefix, +batch_byte_cap, batch_file_cap) through a five-tier precedence chain: CLI flag > +`WIKI_TOOLKIT_` env var > nearest `.wiki-toolkit.toml` (dedicated file, +flat top-level keys) > nearest `pyproject.toml`'s `[tool.wiki_toolkit]` table > +built-in default. Each file tier is found by walking upward from cwd, same +convention as ruff/mypy; once found, it's authoritative for that tier (a broken +file or an invalid field value there falls through to the next tier rather than +continuing to search further up). CLI-flag overrides are currently only wired +up for `docs_dir` and `repo_root`; the other three fields start at the env tier. """ +import warnings from dataclasses import dataclass from pathlib import Path from typing import Literal +from pydantic import BaseModel, ValidationError, field_validator from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict -from pydantic_settings.sources import PyprojectTomlConfigSettingsSource +from pydantic_settings.sources import ( + EnvSettingsSource, + PyprojectTomlConfigSettingsSource, + TomlConfigSettingsSource, +) ConfigSource = Literal["flag", "env", "pyproject", "default"] +ContextConfigSource = Literal["flag", "env", "dedicated_file", "pyproject", "default"] + +DEDICATED_FILENAME = ".wiki-toolkit.toml" + +_DEFAULT_BRANCH_PREFIX = "wiki-update/" +_DEFAULT_BATCH_BYTE_CAP = 100_000 +_DEFAULT_BATCH_FILE_CAP = 20 class _EnvSettings(BaseSettings): @@ -23,12 +46,90 @@ class _EnvSettings(BaseSettings): docs_dir: Path | None = None -class _PyprojectSettings(BaseSettings): - """Reads `docs_dir` from a `pyproject.toml`'s `[tool.wiki_toolkit]` table.""" +@dataclass +class ResolvedConfig: + """The resolved `docs_dir` and which source produced it.""" + + docs_dir: Path + source: ConfigSource + - model_config = SettingsConfigDict(pyproject_toml_table_header=("tool", "wiki_toolkit")) +def _find_pyproject_docs_dir(start: Path) -> Path | None: + """Walk upward from `start` for the nearest pyproject.toml's `[tool.wiki_toolkit].docs_dir`.""" + directory = _find_upward(start, "pyproject.toml") + if directory is None: + return None + fields = _pyproject_context_fields(directory) + docs_dir = fields.docs_dir if fields is not None else None + if docs_dir is None: + return None + return docs_dir if docs_dir.is_absolute() else directory / docs_dir + + +def resolve_docs_dir(flag: Path | None = None, cwd: Path | None = None) -> ResolvedConfig: + """Resolve `docs_dir` per precedence: flag > env > pyproject > default.""" + if flag is not None: + return ResolvedConfig(docs_dir=flag, source="flag") + + cwd = cwd or Path.cwd() + + env_docs_dir = _EnvSettings().docs_dir + if env_docs_dir is not None: + return ResolvedConfig(docs_dir=env_docs_dir, source="env") + + pyproject_docs_dir = _find_pyproject_docs_dir(cwd) + if pyproject_docs_dir is not None: + return ResolvedConfig(docs_dir=pyproject_docs_dir, source="pyproject") + + return ResolvedConfig(docs_dir=cwd / "docs", source="default") + + +class Context(BaseModel): + """The full resolved wiki_toolkit configuration.""" + + docs_dir: Path + repo_root: Path + branch_prefix: str + batch_byte_cap: int + batch_file_cap: int + + +_CONTEXT_FIELDS = tuple(Context.model_fields) +_PATH_FIELDS = ("docs_dir", "repo_root") + + +class _ContextFieldsSettings(BaseSettings): + """Base for the optional per-tier settings sources: any field may be absent. + + Each tier reads its raw source (env, a dedicated file, or `pyproject.toml`) + separately and passes the resulting dict to `model_validate`. Restricting + sources to init kwargs here keeps that re-validation from re-triggering + the class's own env/file discovery and re-merging the *original* + (possibly invalid, possibly differently-located) source on top of the + already-filtered dict. + """ docs_dir: Path | None = None + repo_root: Path | None = None + branch_prefix: str | None = None + batch_byte_cap: int | None = None + batch_file_cap: int | None = None + + @field_validator("branch_prefix") + @classmethod + def _branch_prefix_not_empty(cls, value: str | None) -> str | None: + """Reject an empty (but present) `branch_prefix`.""" + if value is not None and not value: + raise ValueError("branch_prefix must not be empty") + return value + + @field_validator("batch_byte_cap", "batch_file_cap") + @classmethod + def _cap_positive(cls, value: int | None) -> int | None: + """Reject a non-positive (but present) batch cap.""" + if value is not None and value <= 0: + raise ValueError("must be positive") + return value @classmethod def settings_customise_sources( @@ -39,54 +140,156 @@ def settings_customise_sources( dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> tuple[PydanticBaseSettingsSource, ...]: - """Restrict this settings class to init kwargs and `pyproject.toml`, no env/dotenv/secrets.""" - return (init_settings, PyprojectTomlConfigSettingsSource(settings_cls)) + """Restrict every tier's validation (`model_validate`) to init kwargs only.""" + return (init_settings,) -@dataclass -class ResolvedConfig: - """The resolved `docs_dir` and which source produced it.""" +class _ContextEnvSettings(_ContextFieldsSettings): + """Field-shape for reading `WIKI_TOOLKIT_*` env vars; env is read separately via `EnvSettingsSource`.""" - docs_dir: Path - source: ConfigSource + model_config = SettingsConfigDict(env_prefix="WIKI_TOOLKIT_") -def _find_pyproject_docs_dir(start: Path) -> Path | None: - """Walk upward from `start` for the nearest pyproject.toml's `[tool.wiki_toolkit].docs_dir`. +class _ContextDedicatedFileSettings(_ContextFieldsSettings): + """Reads context fields from a dedicated `.wiki-toolkit.toml` file's flat top-level keys.""" - Once a pyproject.toml is found, it is authoritative (matches ruff/mypy - convention) — a missing table, missing key, or unparseable file there - means "no pyproject source", not "keep looking further up". - """ + +class _ContextPyprojectSettings(_ContextFieldsSettings): + """Field-shape for reading a `pyproject.toml`'s `[tool.wiki_toolkit]` table, read separately.""" + + model_config = SettingsConfigDict(pyproject_toml_table_header=("tool", "wiki_toolkit")) + + +def _find_upward(start: Path, filename: str) -> Path | None: + """Walk upward from `start` for the nearest directory containing `filename`.""" for directory in (start, *start.parents): - pyproject = directory / "pyproject.toml" - if not pyproject.is_file(): - continue - try: - data = PyprojectTomlConfigSettingsSource(_PyprojectSettings, toml_file=pyproject)() - docs_dir = _PyprojectSettings.model_validate(data).docs_dir - except ValueError: - # ponytail: both tomllib.TOMLDecodeError and pydantic's ValidationError subclass ValueError - return None - if docs_dir is None: - return None - return docs_dir if docs_dir.is_absolute() else directory / docs_dir + if (directory / filename).is_file(): + return directory return None -def resolve_docs_dir(flag: Path | None = None, cwd: Path | None = None) -> ResolvedConfig: - """Resolve `docs_dir` per precedence: flag > env > pyproject > default.""" - if flag is not None: - return ResolvedConfig(docs_dir=flag, source="flag") +def _validate_dropping_invalid(cls: type[_ContextFieldsSettings], data: dict[str, object]) -> _ContextFieldsSettings: + """Validate `data` against `cls`, dropping only the individually-invalid fields. + + One bad field (e.g. a negative `batch_byte_cap`) must not discard its + valid siblings (e.g. a well-formed `docs_dir`) from the same tier. + """ + remaining = dict(data) + while True: + try: + return cls.model_validate(remaining) + except ValidationError as exc: + bad_fields = {str(err["loc"][0]) for err in exc.errors() if err["loc"]} + if not bad_fields & remaining.keys(): + # ponytail: safety net against an infinite loop if a future validator + # raises without loc matching a known field; not reachable today + return cls.model_construct() + for field in bad_fields: + remaining.pop(field, None) + + +def _env_context_fields() -> _ContextFieldsSettings: + """Read context fields from `WIKI_TOOLKIT_*` env vars, dropping any individually-invalid value.""" + data = EnvSettingsSource(_ContextEnvSettings)() + return _validate_dropping_invalid(_ContextEnvSettings, data) + + +def _dedicated_file_context_fields(directory: Path | None) -> _ContextFieldsSettings | None: + """Read context fields from `directory`'s `.wiki-toolkit.toml`, or `None` if missing/unreadable.""" + if directory is None: + return None + toml_file = directory / DEDICATED_FILENAME + try: + data = TomlConfigSettingsSource(_ContextDedicatedFileSettings, toml_file=toml_file)() + except (ValueError, OSError): + # ponytail: tomllib.TOMLDecodeError subclasses ValueError; OSError covers permission/lock errors + return None + return _validate_dropping_invalid(_ContextDedicatedFileSettings, data) + +def _pyproject_context_fields(directory: Path | None) -> _ContextFieldsSettings | None: + """Read context fields from `directory`'s `pyproject.toml` table, or `None` if missing/unreadable.""" + if directory is None: + return None + toml_file = directory / "pyproject.toml" + try: + data = PyprojectTomlConfigSettingsSource(_ContextPyprojectSettings, toml_file=toml_file)() + except (ValueError, OSError): + return None + with warnings.catch_warnings(): + # ponytail: pyproject_toml_table_header is read directly above, not via + # settings_customise_sources, so pydantic-settings warns it looks unused + warnings.filterwarnings("ignore", message=r"Config key `pyproject_toml_table_header`", category=UserWarning) + return _validate_dropping_invalid(_ContextPyprojectSettings, data) + + +def _find_repo_root(cwd: Path) -> Path: + """Walk upward from `cwd` for the nearest `.git`; fall back to `cwd` itself.""" + for directory in (cwd, *cwd.parents): + if (directory / ".git").exists(): + return directory + return cwd + + +def _default_value(field: str, cwd: Path) -> Path | str | int: + """Return the built-in default for `field`.""" + if field == "docs_dir": + return cwd / "docs" + if field == "repo_root": + return _find_repo_root(cwd) + if field == "branch_prefix": + return _DEFAULT_BRANCH_PREFIX + if field == "batch_byte_cap": + return _DEFAULT_BATCH_BYTE_CAP + return _DEFAULT_BATCH_FILE_CAP + + +_Tier = tuple[ContextConfigSource, "_ContextFieldsSettings | None", "Path | None"] + + +def _resolve_field( + field: str, flag_value: Path | None, tiers: tuple[_Tier, ...], cwd: Path +) -> tuple[object, ContextConfigSource]: + """Resolve a single field's value and source, falling through `tiers` in order to `default`.""" + if flag_value is not None: + return flag_value, "flag" + + for source_name, settings, base_dir in tiers: + if settings is None: + continue + value = getattr(settings, field) + if value is None: + continue + if field in _PATH_FIELDS and base_dir is not None and not value.is_absolute(): + value = base_dir / value + return value, source_name + + return _default_value(field, cwd), "default" + + +def build_context( + docs_dir_flag: Path | None = None, + repo_root_flag: Path | None = None, + cwd: Path | None = None, +) -> tuple[Context, dict[str, ContextConfigSource]]: + """Resolve the full `Context` per field, tracking which tier produced each value. + + Precedence per field: CLI flag > env var > dedicated file > pyproject.toml > default. + """ cwd = cwd or Path.cwd() + flags = {"docs_dir": docs_dir_flag, "repo_root": repo_root_flag} - env_docs_dir = _EnvSettings().docs_dir - if env_docs_dir is not None: - return ResolvedConfig(docs_dir=env_docs_dir, source="env") + dedicated_dir = _find_upward(cwd, DEDICATED_FILENAME) + pyproject_dir = _find_upward(cwd, "pyproject.toml") + tiers: tuple[_Tier, ...] = ( + ("env", _env_context_fields(), cwd), + ("dedicated_file", _dedicated_file_context_fields(dedicated_dir), dedicated_dir), + ("pyproject", _pyproject_context_fields(pyproject_dir), pyproject_dir), + ) - pyproject_docs_dir = _find_pyproject_docs_dir(cwd) - if pyproject_docs_dir is not None: - return ResolvedConfig(docs_dir=pyproject_docs_dir, source="pyproject") + values: dict[str, object] = {} + sources: dict[str, ContextConfigSource] = {} + for field in _CONTEXT_FIELDS: + values[field], sources[field] = _resolve_field(field, flags.get(field), tiers, cwd) - return ResolvedConfig(docs_dir=cwd / "docs", source="default") + return Context.model_validate(values), sources diff --git a/wiki_toolkit/sources.py b/wiki_toolkit/sources.py index 498be01..0b9817c 100644 --- a/wiki_toolkit/sources.py +++ b/wiki_toolkit/sources.py @@ -3,10 +3,12 @@ import hashlib import shutil import subprocess +from collections.abc import Iterator from dataclasses import dataclass, field from datetime import UTC, datetime from difflib import SequenceMatcher -from typing import TYPE_CHECKING, Any, Literal, overload +from pathlib import Path +from typing import Any, Literal, overload import orjson import yaml @@ -15,10 +17,6 @@ from wiki_toolkit.frontmatter import Post from wiki_toolkit.write_gate import stage_best_effort -if TYPE_CHECKING: - from collections.abc import Iterator - from pathlib import Path - SOURCE_MANIFEST_FILENAME = "source-manifest.jsonl" diff --git a/wiki_toolkit/wiki.py b/wiki_toolkit/wiki.py index 33f335a..157adfc 100644 --- a/wiki_toolkit/wiki.py +++ b/wiki_toolkit/wiki.py @@ -3,15 +3,13 @@ import re from dataclasses import dataclass, field from decimal import ROUND_HALF_UP, Decimal -from typing import TYPE_CHECKING, Literal +from pathlib import Path +from typing import Literal from wiki_toolkit._io import read_jsonl from wiki_toolkit.frontmatter import Post from wiki_toolkit.sources import SOURCE_MANIFEST_FILENAME, LintViolation, LoadError, SourceManifest, _iter_markdown -if TYPE_CHECKING: - from pathlib import Path - _BULLET_RE = re.compile(r"^\s*[-*]\s+(.*)$") _WIKILINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|[^\]]*)?\]\]") _CONFIDENCE_MARKER_RE = re.compile(r"\^\[(inferred|ambiguous)\]") diff --git a/wiki_toolkit/write_gate.py b/wiki_toolkit/write_gate.py index 1ee21e2..5d3bf94 100644 --- a/wiki_toolkit/write_gate.py +++ b/wiki_toolkit/write_gate.py @@ -5,10 +5,8 @@ import subprocess from dataclasses import dataclass from datetime import UTC, datetime -from typing import TYPE_CHECKING, Literal - -if TYPE_CHECKING: - from pathlib import Path +from pathlib import Path +from typing import Literal Frame = Literal["routine", "needs-review"] ALLOWED_FRAMES: tuple[Frame, ...] = ("routine", "needs-review")