chore/adopt-local-tree #4

Merged
smill merged 13 commits from chore/adopt-local-tree into main 2026-09-16 15:48:16 -04:00
5 changed files with 388 additions and 0 deletions
Showing only changes of commit ff09e38b7c - Show all commits

test: meta gates for architecture, CI, docs and packaging

smillmorel 2026-09-16 12:03:07 -04:00

View file

@ -0,0 +1,197 @@
"""Architecture and layering rules - see ``docs/architecture.md``.
The documented dependency stack is ``views -> viewmodels -> services -> core``;
each layer may only import from itself or a lower layer, ``core`` stays free of
Qt/solver/plotting libraries, and the rendering backend is confined to
``views/canvas3d``. These tests never import the application modules; they read
the source tree with ``ast`` so the import discipline can be checked without
executing Qt or the solver. Directories that do not exist yet simply have
nothing to walk, which trivially satisfies a "forbidden import" rule.
"""
from __future__ import annotations
import ast
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SRC = ROOT / "src" / "otko"
CANVAS = SRC / "views" / "canvas3d"
# Qt binding roots, in every spelling the project might use.
QT_ROOTS = {"PySide6", "PyQt5", "PyQt6", "PySide"}
# core/ stays pure - no Qt and no solver/mesh/render libraries.
CORE_FORBIDDEN = QT_ROOTS | {
"openseespy",
"opstool",
"shapely",
"vtk",
"pyvista",
"plotly",
"matplotlib",
}
# Only views/canvas3d/ may touch a 3D or plotting backend.
VTK_ROOTS = {"pyvista", "pyvistaqt", "vtk", "plotly"}
def _iter_python_files(root: Path) -> list[Path]:
"""Return every ``*.py`` file below ``root``, or ``[]`` if it is absent."""
if not root.exists():
return []
return sorted(root.rglob("*.py"))
def _root_imports(path: Path) -> set[str]:
"""Return the top-level module names imported by a Python source file.
Handles ``import X``, ``import X.Y``, ``from X import ...`` and
``from X.Y import ...``. Relative imports are ignored: they cannot reach
a third-party package.
"""
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
roots: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
roots.add(alias.name.split(".", 1)[0])
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
roots.add(node.module.split(".", 1)[0])
return roots
def _is_within(path: Path, directory: Path) -> bool:
"""Return True when ``path`` lies inside ``directory`` (lexical check)."""
try:
path.relative_to(directory)
except ValueError:
return False
return True
def _violations(
root: Path,
forbidden: set[str],
*,
skip: tuple[Path, ...] = (),
exclude_dir: Path | None = None,
) -> list[str]:
"""Return ``"<path>: <module>"`` entries for every forbidden import found."""
found: list[str] = []
for path in _iter_python_files(root):
if path in skip:
continue
if exclude_dir is not None and _is_within(path, exclude_dir):
continue
for module in sorted(_root_imports(path) & forbidden):
found.append(f"{path.relative_to(ROOT)}: {module}")
return found
def test_architecture_core_has_no_qt_or_solver() -> None:
"""core/ imports no Qt, solver, mesh, or plotting library."""
violations = _violations(SRC / "core", CORE_FORBIDDEN)
assert violations == [], f"forbidden imports in core/: {violations}"
def test_architecture_services_no_qt_except_worker() -> None:
"""No Qt in services/ except services/qt_workers.py."""
violations = _violations(
SRC / "services",
QT_ROOTS,
skip=(SRC / "services" / "qt_workers.py",),
)
assert violations == [], f"forbidden Qt imports in services/: {violations}"
def test_architecture_views_no_solver() -> None:
"""views/ imports neither openseespy nor opstool."""
violations = _violations(SRC / "views", {"openseespy", "opstool"})
assert violations == [], f"solver imports in views/: {violations}"
def test_no_vtk_import_outside_canvas() -> None:
"""3D/plot backends stay under views/canvas3d/."""
violations = _violations(SRC, VTK_ROOTS, exclude_dir=CANVAS)
assert violations == [], f"backend imports outside views/canvas3d/: {violations}"
# The documented dependency stack; ``commands`` sits beside viewmodels (a VM
# creates commands) and ``views`` is the top, so it is excluded from this map.
_LAYER_ORDER = {"core": 0, "services": 1, "viewmodels": 2, "views": 3}
def _internal_imports(path: Path) -> set[str]:
"""Return the fully-qualified ``otko.*`` modules imported by a source file."""
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
found: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
found.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
found.add(node.module)
return {module for module in found if module.startswith("otko.")}
def test_architecture_lower_layers_do_not_import_higher() -> None:
"""core/services/viewmodels never import a higher layer."""
violations: list[str] = []
for path in _iter_python_files(SRC):
layer = path.relative_to(SRC).parts[0]
if layer not in _LAYER_ORDER:
continue
for module in _internal_imports(path):
target = module.split(".")[1] if "." in module else ""
if target in _LAYER_ORDER and _LAYER_ORDER[target] > _LAYER_ORDER[layer]:
violations.append(f"{path.relative_to(ROOT)}: {layer} -> {module}")
assert violations == [], f"lower layer imports a higher layer: {violations}"
def test_architecture_viewmodels_do_not_construct_widgets() -> None:
"""viewmodels emit signals and import no Qt widgets."""
violations = _violations(SRC / "viewmodels", {"QtWidgets"})
assert violations == [], f"viewmodels import widgets: {violations}"
for path in _iter_python_files(SRC / "viewmodels"):
assert "PySide6.QtWidgets" not in path.read_text(encoding="utf-8"), path
def test_architecture_report_is_qt_and_solver_free() -> None:
"""report generation imports no Qt or solver (vacuous if absent)."""
report = SRC / "services" / "report"
violations = _violations(report, QT_ROOTS | {"openseespy", "opstool"})
assert violations == [], f"report imports Qt/solver: {violations}"
def test_architecture_project_vm_owns_the_undo_stack() -> None:
"""Only project_vm.py holds a QUndoStack."""
holders = [
path.name
for path in _iter_python_files(SRC / "viewmodels")
if "QUndoStack" in path.read_text(encoding="utf-8")
]
assert holders == ["project_vm.py"], f"QUndoStack holders: {holders}"
def test_architecture_no_environment_configuration() -> None:
"""No env-var configuration anywhere in src/otko."""
offenders = [
str(path.relative_to(ROOT))
for path in _iter_python_files(SRC)
if "os.environ" in path.read_text(encoding="utf-8")
or "os.getenv" in path.read_text(encoding="utf-8")
]
assert offenders == [], f"environment configuration in: {offenders}"
def test_architecture_runner_splits_emit_and_run() -> None:
"""Model emission and analysis live in separate modules."""
emit_path = SRC / "services" / "_emit.py"
run_path = SRC / "services" / "_run.py"
assert emit_path.exists()
assert run_path.exists()
emit_source = emit_path.read_text(encoding="utf-8")
run_source = run_path.read_text(encoding="utf-8")
assert "class OpenSeesEmitter" in emit_source
assert "class OpenSeesAnalysisRunner" in run_source
assert "OpenSeesEmitter" in run_source

View file

@ -0,0 +1,39 @@
"""Meta-gate checks for the CI workflow and pre-commit hook configuration."""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
CI = ROOT / ".github" / "workflows" / "ci.yml"
PRECOMMIT = ROOT / ".pre-commit-config.yaml"
HEADLESS_COMMAND = 'pytest tests/unit tests/services -m "not slow"'
def test_ci_declares_all_jobs() -> None:
"""The workflow exposes the lint/type/test split as separate jobs."""
text = CI.read_text(encoding="utf-8")
for job in ("lint:", "type:", "test-headless:", "test-gui:", "test-integration:"):
assert f"\n {job}\n" in text, f"missing CI job: {job}"
def test_ci_runs_the_headless_command() -> None:
"""The headless job runs the unit + services, non-slow command verbatim."""
assert HEADLESS_COMMAND in CI.read_text(encoding="utf-8")
def test_precommit_declares_expected_hooks() -> None:
"""The pre-commit config wires up the lint/format/type/hygiene hooks."""
text = PRECOMMIT.read_text(encoding="utf-8")
for hook in (
"id: ruff",
"id: ruff-format",
"id: mypy",
"id: trailing-whitespace",
"id: end-of-file-fixer",
"id: check-yaml",
"id: check-added-large-files",
"id: check-merge-conflict",
):
assert hook in text, f"missing pre-commit hook: {hook}"

51
tests/unit/test_docs.py Normal file
View file

@ -0,0 +1,51 @@
"""Documentation gates — assert the user-facing docs keep their key content.
Pure pathlib; no Qt or solver imports so this stays part of the fast unit
suite.
"""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
DOCS_DIR = REPO_ROOT / "docs"
def _text(path: Path) -> str:
"""Return a file's UTF-8 text with normalized line endings."""
return path.read_text(encoding="utf-8").replace("\r\n", "\n")
def test_quick_guide_exists_and_covers_core_topics() -> None:
quick_guide = DOCS_DIR / "QUICK_GUIDE.md"
assert quick_guide.is_file(), "docs/QUICK_GUIDE.md is missing"
lowered = _text(quick_guide).lower()
for topic in ("cantilever", "report", "modal", "units", "undo"):
assert topic in lowered, f"QUICK_GUIDE.md is missing {topic!r}"
def test_contributing_describes_this_repo() -> None:
text = _text(REPO_ROOT / "CONTRIBUTING.md")
for required in (
"views",
"viewmodels",
"services",
"core",
"commands",
"Conventional Commits",
"feat:",
"main",
"pre-commit install",
'pip install -e ".[gui,dev]"',
"python -m otko",
'pytest -m "not slow"',
):
assert required in text, f"CONTRIBUTING.md is missing {required!r}"
def test_readme_links_quick_guide() -> None:
readme = _text(REPO_ROOT / "README.md")
assert "docs/QUICK_GUIDE.md" in readme

View file

@ -0,0 +1,29 @@
"""Meta-gates tying the test markers to the CI commands that consume them."""
from __future__ import annotations
from pathlib import Path
try:
import tomllib
except ModuleNotFoundError: # Python < 3.11
import tomli as tomllib
ROOT = Path(__file__).resolve().parents[2]
PYPROJECT = ROOT / "pyproject.toml"
CI = ROOT / ".github" / "workflows" / "ci.yml"
HEADLESS_COMMAND = 'pytest tests/unit tests/services -m "not slow"'
def test_slow_marker_is_declared() -> None:
"""``[tool.pytest.ini_options]`` registers the ``slow`` marker."""
with PYPROJECT.open("rb") as handle:
config = tomllib.load(handle)
markers = config["tool"]["pytest"]["ini_options"]["markers"]
assert any(marker.startswith("slow:") for marker in markers), markers
def test_ci_runs_the_headless_command() -> None:
"""The headless CI command matches the marker the suite expects."""
assert HEADLESS_COMMAND in CI.read_text(encoding="utf-8")

View file

@ -0,0 +1,72 @@
"""Packaging invariants — version/solver single-sourcing and dependency hygiene.
Pure static checks over the repository files. No imports of :mod:`otko`,
Qt or the solver stack, so this test runs anywhere (including Windows 3.12).
"""
from __future__ import annotations
import re
from pathlib import Path
try: # Python 3.11+
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python 3.10 backport
import tomli as tomllib
REPO_ROOT = Path(__file__).resolve().parents[2]
SRC_ROOT = REPO_ROOT / "src" / "otko"
PYPROJECT = REPO_ROOT / "pyproject.toml"
_VERSION_RE = re.compile(r"^__version__\s*=", re.MULTILINE)
def _pyproject() -> dict:
with PYPROJECT.open("rb") as handle:
return tomllib.load(handle)
def _src_files() -> list[Path]:
return sorted(SRC_ROOT.rglob("*.py"))
def _dep_name(requirement: str) -> str:
"""Return the lowercased distribution name from a PEP 508 requirement."""
match = re.match(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)", requirement)
return match.group(1).lower().replace("_", "-") if match else ""
def test_console_script_entrypoint() -> None:
assert _pyproject()["project"]["scripts"]["otko"] == "otko.__main__:main"
def test_project_version_is_dynamic() -> None:
project = _pyproject()["project"]
assert "version" not in project
assert project["dynamic"] == ["version"]
def test_single_version_assignment() -> None:
hits = [path for path in _src_files() if _VERSION_RE.search(path.read_text(encoding="utf-8"))]
assert len(hits) == 1, f"expected exactly one __version__ assignment, got {hits}"
assert hits[0] == SRC_ROOT / "_const.py"
def test_solver_version_literal_only_in_const() -> None:
hits = [path for path in _src_files() if "3.8.0.0" in path.read_text(encoding="utf-8")]
assert hits == [SRC_ROOT / "_const.py"], f"unexpected 3.8.0.0 occurrences: {hits}"
def test_ruff_line_length_and_ignore() -> None:
ruff = _pyproject()["tool"]["ruff"]
assert ruff["line-length"] == 100
assert "E501" in ruff["lint"]["ignore"]
def test_no_phantom_scipy_pandas_dependencies() -> None:
project = _pyproject()["project"]
groups = [project.get("dependencies", [])]
groups.extend(project.get("optional-dependencies", {}).values())
declared = {_dep_name(req) for group in groups for req in group}
assert "scipy" not in declared
assert "pandas" not in declared