Source code for modelly.lint.config

"""The ``[tool.modelly.lint]`` table of pyproject.toml: which findings to keep.

```toml
[tool.modelly.lint]
select = ["MDL0", "MDL101"]           # only these codes or prefixes (default: all)
ignore = ["MDL105"]                   # drop these codes or prefixes
per-file-ignores = { "tests/*" = ["MDL001"], "setup_*.py" = ["MDL1"] }
```

The file is the first ``pyproject.toml`` found from the working directory upwards;
per-file patterns are globs on the path relative to that file's folder, or on the bare
file name. ``--select`` and ``--ignore`` on the command line replace the file's values.
"""

from __future__ import annotations

import tomllib
from dataclasses import dataclass
from fnmatch import fnmatch
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from modelly import lint

KEYS = frozenset({"select", "ignore", "per-file-ignores"})


[docs] class ConfigError(ValueError): """The ``[tool.modelly.lint]`` table has a key or value the linter does not know."""
[docs] def prefixes(value: str | list[str]) -> tuple[str, ...]: """Normalise ``"MDL0, MDL101"`` or ``["MDL0", "MDL101"]`` to upper-case prefixes.""" if not isinstance(value, str | list): raise ConfigError(f"expected a list of codes, got {value!r}") items = value.split(",") if isinstance(value, str) else value return tuple(str(item).strip().upper() for item in items if str(item).strip())
[docs] def matches(code: str, codes: tuple[str, ...]) -> bool: """Whether ``code`` starts with one of ``codes``.""" return any(code.startswith(prefix) for prefix in codes)
[docs] @dataclass(frozen=True) class Config: """Which findings to keep: the select, ignore and per-file-ignores settings.""" select: tuple[str, ...] = () ignore: tuple[str, ...] = () per_file_ignores: tuple[tuple[str, tuple[str, ...]], ...] = () root: Path = Path()
[docs] def keeps(self, finding: lint.Finding) -> bool: """Whether ``finding`` survives select, ignore and its file's ignores.""" code = finding.rule.code if self.select and not matches(code, self.select): return False if matches(code, self.ignore): return False return not matches(code, self.file_ignores(finding.path))
[docs] def file_ignores(self, path: str) -> tuple[str, ...]: """The codes ignored for ``path`` by the per-file-ignores patterns it matches.""" try: relative = Path(path).resolve().relative_to(self.root.resolve()).as_posix() except ValueError: relative = Path(path).as_posix() name = Path(path).name return tuple( code for pattern, codes in self.per_file_ignores if fnmatch(relative, pattern) or fnmatch(name, pattern) for code in codes )
[docs] def find_pyproject(start: Path) -> Path | None: """The first ``pyproject.toml`` in ``start`` or one of its parents.""" for folder in (start, *start.parents): candidate = folder / "pyproject.toml" if candidate.is_file(): return candidate return None
[docs] def load_config(start: Path | None = None) -> Config: """Read ``[tool.modelly.lint]`` from the nearest pyproject.toml; empty when there is none. Raises: ConfigError: on an unknown key or a value that is not a list of codes. tomllib.TOMLDecodeError: when the file is not valid TOML. """ path = find_pyproject((start or Path.cwd()).resolve()) if path is None: return Config() with path.open("rb") as handle: table = tomllib.load(handle).get("tool", {}).get("modelly", {}).get("lint", {}) unknown = set(table) - KEYS if unknown: raise ConfigError( f"{path}: unknown [tool.modelly.lint] key(s): {sorted(unknown)}" ) per_file = table.get("per-file-ignores", {}) if not isinstance(per_file, dict): raise ConfigError(f"{path}: per-file-ignores must be a table of glob = [codes]") return Config( select=prefixes(table.get("select", [])), ignore=prefixes(table.get("ignore", [])), per_file_ignores=tuple( (glob, prefixes(codes)) for glob, codes in per_file.items() ), root=path.parent, )