generated from john/python-template
Quality Gate / gate (push) Failing after 47s
Co-authored-by: Copilot App <[email protected]>
159 lines
6.0 KiB
Python
159 lines
6.0 KiB
Python
"""Structural rules for the NiceGUI page layer.
|
|
|
|
`.github/instructions/ui.instructions.md:23,31` forbids pages from owning
|
|
persistence or query-building concerns, and forbids components from resolving
|
|
request or application state. HIGH-07 recorded three violations of those rules;
|
|
these tests keep them from coming back.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
UI_DIR = Path(__file__).resolve().parents[1] / "src" / "transcription" / "ui"
|
|
PAGES_DIR = UI_DIR / "pages"
|
|
COMPONENTS_DIR = UI_DIR / "components"
|
|
|
|
FORBIDDEN_PAGE_MODULES = frozenset({"sqlalchemy", "sqlmodel"})
|
|
|
|
# Sensitive modules are allowlisted, not blocklisted, so newly added persistence
|
|
# helpers cannot slip through by using an unlisted name.
|
|
PAGE_IMPORT_ALLOWLIST: dict[tuple[str, int], frozenset[str]] = {
|
|
("db.session", 3): frozenset({"SessionFactoryDep"}),
|
|
("transcription.config", 0): frozenset({"Settings"}),
|
|
}
|
|
|
|
|
|
def _page_paths() -> list[Path]:
|
|
return sorted(path for path in PAGES_DIR.glob("*.py") if path.stem != "__init__")
|
|
|
|
|
|
def _component_paths() -> list[Path]:
|
|
return sorted(COMPONENTS_DIR.rglob("*.py"))
|
|
|
|
|
|
def _imports(tree: ast.Module) -> tuple[set[str], set[str]]:
|
|
"""Return (imported names, imported root modules)."""
|
|
names: set[str] = set()
|
|
modules: set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ImportFrom):
|
|
if node.module and node.level == 0:
|
|
modules.add(node.module.split(".")[0])
|
|
names.update(alias.name for alias in node.names)
|
|
elif isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
modules.add(alias.name.split(".")[0])
|
|
names.add(alias.name)
|
|
return names, modules
|
|
|
|
|
|
def _page_allowlist_violations(tree: ast.Module) -> set[str]:
|
|
violations: set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.ImportFrom) or not node.module:
|
|
continue
|
|
allowed = PAGE_IMPORT_ALLOWLIST.get((node.module, node.level))
|
|
if allowed is None:
|
|
continue
|
|
for alias in node.names:
|
|
imported_name = alias.name
|
|
if imported_name not in allowed:
|
|
violations.add(f"{'.' * node.level}{node.module}.{imported_name}")
|
|
return violations
|
|
|
|
|
|
def test_page_modules_are_discovered():
|
|
"""Guard the guard: the rules below are meaningless if nothing is scanned."""
|
|
discovered = {path.stem for path in _page_paths()}
|
|
assert discovered >= {"documents_page", "jobs_page", "people_page", "sources_page"}
|
|
|
|
|
|
def test_no_page_imports_persistence_or_process_globals():
|
|
"""HIGH-07: pages orchestrate services; they do not own sessions or settings."""
|
|
violations: dict[str, list[str]] = {}
|
|
for path in _page_paths():
|
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
_, modules = _imports(tree)
|
|
allowlist_violations = _page_allowlist_violations(tree)
|
|
found = sorted((modules & FORBIDDEN_PAGE_MODULES) | allowlist_violations)
|
|
if found:
|
|
violations[path.stem] = found
|
|
assert violations == {}
|
|
|
|
|
|
def test_no_component_resolves_request_or_application_state():
|
|
"""`ui.instructions.md:31`: components render, they do not resolve app state."""
|
|
violations: dict[str, list[str]] = {}
|
|
for path in _component_paths():
|
|
names, modules = _imports(ast.parse(path.read_text(encoding="utf-8")))
|
|
forbidden_names = {"get_settings", "get_engine", "session_scope", "transaction_scope"}
|
|
found = sorted((names & forbidden_names) | (modules & (FORBIDDEN_PAGE_MODULES | {"fastapi"})))
|
|
if found:
|
|
violations[str(path.relative_to(COMPONENTS_DIR))] = found
|
|
assert violations == {}
|
|
|
|
|
|
# `build_table` owns the interactive table styling; `print_preview_page` owns the
|
|
# print-only table, which must never paginate or expose a search box.
|
|
TABLE_OWNERS = frozenset({"components/table/common.py", "pages/print_preview_page.py"})
|
|
|
|
|
|
def _calls_ui_table(tree: ast.Module) -> bool:
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Call):
|
|
continue
|
|
func = node.func
|
|
if (
|
|
isinstance(func, ast.Attribute)
|
|
and func.attr == "table"
|
|
and isinstance(func.value, ast.Name)
|
|
and func.value.id == "ui"
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def test_only_the_designated_owners_construct_a_raw_table():
|
|
"""Review section 4: table styling lives in one place, not in every page."""
|
|
offenders = sorted(
|
|
str(path.relative_to(UI_DIR)).replace("\\", "/")
|
|
for path in UI_DIR.rglob("*.py")
|
|
if _calls_ui_table(ast.parse(path.read_text(encoding="utf-8")))
|
|
)
|
|
assert set(offenders) == TABLE_OWNERS
|
|
|
|
|
|
def _notifies_negative(tree: ast.Module) -> bool:
|
|
"""Return True if the module calls ``ui.notify(..., type="negative")``."""
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Call):
|
|
continue
|
|
func = node.func
|
|
if not (
|
|
isinstance(func, ast.Attribute)
|
|
and func.attr == "notify"
|
|
and isinstance(func.value, ast.Name)
|
|
and func.value.id == "ui"
|
|
):
|
|
continue
|
|
for keyword in node.keywords:
|
|
if keyword.arg == "type" and isinstance(keyword.value, ast.Constant) and keyword.value.value == "negative":
|
|
return True
|
|
return False
|
|
|
|
|
|
def test_no_page_hand_rolls_error_notifications():
|
|
"""HIGH-02: `ui.instructions.md:42` routes all error display through error_presenter.
|
|
|
|
Hand-rolled ``ui.notify(str(exc), type="negative")`` discards the correlation
|
|
``error_id``, the canonical category, and the actionable suggestion that
|
|
``show_error`` renders, leaving the user with nothing to report. Eight such sites
|
|
existed in ``home_page`` and ``people_page``; this keeps them from returning.
|
|
"""
|
|
offenders = sorted(
|
|
path.stem for path in _page_paths() if _notifies_negative(ast.parse(path.read_text(encoding="utf-8")))
|
|
)
|
|
assert offenders == [], f"Pages must render errors via error_presenter.show_error, not ui.notify: {offenders}"
|