backend/tools/lint_no_new_env_fields.py
Rama master · réplica de lectura del repositorio original.
"""AST linter — enforce the UI-first env-var convention (V1, since 0.88.0-beta).
Sublarr's contract since 0.88.0-beta:
* ``BootSettings`` (a ``pydantic_settings.BaseSettings``) is the ONLY
class allowed to load values from the environment via
``SUBLARR_<field>``. Its membership is curated by the
``_ALLOWED_BOOT_FIELDS`` frozenset in ``config_settings.py``.
* All other configuration lives on ``UISettings`` (a ``pydantic.BaseModel``,
intentionally NOT ``BaseSettings``) and is set through the UI.
This linter walks ``backend/config_settings.py`` and asserts:
1. ``_ALLOWED_BOOT_FIELDS`` is the source of truth.
2. ``BootSettings`` declares EXACTLY the fields in the allowlist —
no more, no less.
3. ``UISettings`` does not subclass ``BaseSettings`` (subclassing it
would silently re-introduce env loading).
4. No other class in ``config_settings.py`` subclasses ``BaseSettings``
except ``BootSettings`` itself.
Exit code 1 on any violation, 0 otherwise. Designed for CI.
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
CONFIG_FILE = Path(__file__).resolve().parent.parent / "config_settings.py"
def _frozenset_members(node: ast.AST) -> set[str] | None:
"""Return the string members of a ``frozenset({...})`` literal, or None."""
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "frozenset"
and len(node.args) == 1
and isinstance(node.args[0], ast.Set)
):
members: set[str] = set()
for elt in node.args[0].elts:
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
members.add(elt.value)
else:
return None
return members
return None
def _class_field_names(cls: ast.ClassDef) -> set[str]:
"""Return the names of every annotated assignment at class top-level."""
fields: set[str] = set()
for stmt in cls.body:
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
fields.add(stmt.target.id)
return fields
def _base_names(cls: ast.ClassDef) -> set[str]:
"""Return the names of all bases used by ``cls``."""
names: set[str] = set()
for base in cls.bases:
if isinstance(base, ast.Name):
names.add(base.id)
elif isinstance(base, ast.Attribute):
names.add(base.attr)
return names
def lint(path: Path = CONFIG_FILE) -> list[str]:
"""Return a list of violation messages — empty list = clean."""
if not path.exists():
return [f"{path} not found"]
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
allowlist: set[str] | None = None
classes: dict[str, ast.ClassDef] = {}
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == "_ALLOWED_BOOT_FIELDS":
allowlist = _frozenset_members(node.value)
elif (
isinstance(node, ast.AnnAssign)
and isinstance(node.target, ast.Name)
and node.target.id == "_ALLOWED_BOOT_FIELDS"
and node.value is not None
):
allowlist = _frozenset_members(node.value)
elif isinstance(node, ast.ClassDef):
classes[node.name] = node
violations: list[str] = []
if allowlist is None:
violations.append(
"_ALLOWED_BOOT_FIELDS frozenset literal not found at module top-level "
"of config_settings.py — UI-first convention cannot be enforced."
)
return violations
boot = classes.get("BootSettings")
if boot is None:
violations.append("BootSettings class not found in config_settings.py")
else:
boot_fields = _class_field_names(boot)
extra = boot_fields - allowlist
missing = allowlist - boot_fields
if extra:
violations.append(
"BootSettings declares fields NOT in _ALLOWED_BOOT_FIELDS: "
f"{sorted(extra)}. New env-loadable fields are forbidden — "
"put the field on UISettings instead."
)
if missing:
violations.append(
"BootSettings is missing fields listed in _ALLOWED_BOOT_FIELDS: "
f"{sorted(missing)}. Allowlist and class must agree."
)
ui = classes.get("UISettings")
if ui is None:
violations.append("UISettings class not found in config_settings.py")
else:
ui_bases = _base_names(ui)
if "BaseSettings" in ui_bases:
violations.append(
"UISettings must NOT subclass BaseSettings — that would re-enable "
"env loading for UI fields. Use BaseModel."
)
for name, cls in classes.items():
if name in {"BootSettings", "UISettings", "Settings"}:
continue
if "BaseSettings" in _base_names(cls):
violations.append(
f"Class {name!r} subclasses BaseSettings — only BootSettings is "
"allowed to load env vars. Use BaseModel."
)
return violations
def main(argv: list[str]) -> int:
target = Path(argv[1]) if len(argv) > 1 else CONFIG_FILE
violations = lint(target)
if not violations:
print(f"no-new-env-fields linter: clean ({target})")
return 0
print(f"no-new-env-fields linter: {len(violations)} violation(s) in {target}:")
for msg in violations:
print(f" - {msg}")
print()
print("UI-first convention (since v0.88.0-beta): only the fields in")
print("_ALLOWED_BOOT_FIELDS are env-loadable. Everything else is UI-only.")
return 1
if __name__ == "__main__": # pragma: no cover
sys.exit(main(sys.argv))