generated from john/python-template
V4.6 Phase 5: UI boundaries and duplication
Fixes the three ui.instructions.md violations recorded as [HIGH-07] and extracts the page-level duplication catalogued in review section 4. Boundary violations - jobs_page no longer imports session_scope or manages a transaction. store.create_document_job and store.create_job_for_document accept an optional session_factory and open their own session scope when the caller supplies neither a session nor a factory. - sources_page no longer calls sqlalchemy.inspect. SourceService .read_latest_execution_attempt now returns a LatestExecutionAttempt read model carrying a plain transport_body_deferred flag, so ORM loader state stays inside the service. Rendered output is unchanged. - Deletes ui/components/document_panzoom.py, its export, and its CSS. The component was exported but used by no page. Pan-zoom is planned for a clean reintroduction in V4.7 alongside the other photo/image work. Extracted duplication - ui/components/media_urls.py: pure upload-URL resolution taking upload_dir and base_url, replacing two identical ~60-line copies in sources_page and people_page. - ui/components/guards.py: parse-then-render-terminal-message, replacing 28 hand-written guard labels across five pages. - ui/components/confirm_delete.py: the blocked-dependency notice and the delete/cancel action row, from four delete pages. - ui/components/upload_panel.py: the auto-uploading file picker, from three pages. Source accept lists now derive from services.source_media .SOURCE_EXTENSIONS instead of being hard-coded. - ui/components/table/registry.py: the two hand-rolled label-registry tables on the settings page now go through build_table, which gained selection and rows_per_page options. - ui/components/formatters.py gains parse_uuid and parse_iso_date, replacing five and two private copies. - ui/runtime.py owns resolve_runtime_settings, replacing three copies and removing get_settings from every page module. [LOW-05] - Upload handlers are annotated with events.UploadEventArguments. - The Document and Person form builders return DocumentFormFields and PersonFormFields dataclasses instead of dict[str, Any]. Verification - tests/test_ui_boundaries.py asserts no page imports a session scope, a session factory, get_settings, sqlalchemy, or sqlmodel, and that no component imports request or application state. - 275 passed, 4 skipped. ruff check clean. Findings: HIGH-07, LOW-05 Co-authored-by: Copilot App <[email protected]>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""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"
|
||||
|
||||
# Names a page must not pull in: they hand the page a session, a transaction, ORM
|
||||
# loader introspection, or process-global configuration.
|
||||
FORBIDDEN_PAGE_IMPORTS = frozenset(
|
||||
{
|
||||
"session_scope",
|
||||
"transaction_scope",
|
||||
"get_session_factory",
|
||||
"resolve_session_factory",
|
||||
"get_settings",
|
||||
"get_engine",
|
||||
"upgrade_schema",
|
||||
"create_all",
|
||||
}
|
||||
)
|
||||
|
||||
FORBIDDEN_PAGE_MODULES = frozenset({"sqlalchemy", "sqlmodel"})
|
||||
|
||||
|
||||
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 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():
|
||||
names, modules = _imports(ast.parse(path.read_text(encoding="utf-8")))
|
||||
found = sorted((names & FORBIDDEN_PAGE_IMPORTS) | (modules & FORBIDDEN_PAGE_MODULES))
|
||||
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")))
|
||||
found = sorted((names & FORBIDDEN_PAGE_IMPORTS) | (modules & (FORBIDDEN_PAGE_MODULES | {"fastapi"})))
|
||||
if found:
|
||||
violations[str(path.relative_to(COMPONENTS_DIR))] = found
|
||||
assert violations == {}
|
||||
Reference in New Issue
Block a user