Some checks failed
CI / skinny-install (aco) (push) Successful in 1m30s
CI / lint-test (push) Failing after 1m57s
CI / skinny-install (api) (push) Successful in 26s
CI / skinny-install (bcda) (push) Successful in 29s
CI / skinny-install (bib) (push) Successful in 32s
CI / skinny-install (bls) (push) Successful in 23s
CI / skinny-install (ccw) (push) Successful in 29s
CI / skinny-install (cli) (push) Successful in 31s
CI / skinny-install (cms) (push) Successful in 27s
CI / skinny-install (conf) (push) Successful in 28s
CI / skinny-install (opps) (push) Successful in 28s
CI / skinny-install (perf) (push) Successful in 32s
CI / skinny-install (pfs) (push) Successful in 32s
CI / skinny-install (rex) (push) Successful in 28s
Infra CI / notebooks (push) Failing after 3m43s
Infra CI / zotero (push) Failing after 0s
Infra CI / docs (push) Failing after 0s
Infra CI / api (push) Failing after 0s
Infra CI / mc (push) Failing after 0s
Package Supply Chain / pkg-supply-chain (push) Failing after 0s
Deploy / build-scan-report (push) Failing after 4m23s
- OPPS express functions: adjusted_payment, skin_sub_impact wrapping calcs - OPPS pipe module registered in aco.pipe.registry (2 exprs, auto-discovered by CLI/API) - Output table models: OppsAdjustedPayment, OppsSkinSubImpact - deploy.sh: tiered rollout (infra → gitea → apps → CI → observability) with context-aware image check (local → build if missing) - compose.yml: pull_policy: if_not_present + build sections for all fhirworx images, gateway IPAM subnet for CoreDNS static IP, removed nested loch.css bind mount - CI: opps added to skinny-install matrix, generated configs regenerated - Coverage: 98.46% → 99.04% (sigv4, cclf, diag, provision, auth, cms_quality tests)
756 lines
26 KiB
Python
756 lines
26 KiB
Python
"""AST-based coverage discovery for the stack source tree.
|
|
|
|
This module is the authoritative "what exists vs what is implemented"
|
|
oracle for the entire stack. It walks every ``.py`` file under
|
|
``src/``, parses the AST, and classifies every callable into one of:
|
|
|
|
- **implemented** — body contains real logic
|
|
- **stub** — body raises ``NotImplementedError`` (or is ``...`` / ``pass``)
|
|
- **narwhalify** — decorated with ``@nw.narwhalify`` or ``@narwhalify``
|
|
- **expr** — an ``Expr(...)`` instantiation in a pipe module
|
|
- **pydantic** — a class that inherits from ``BaseModel``
|
|
|
|
Tests then assert structural invariants:
|
|
|
|
1. Every ``Expr`` must have ``name``, ``fn``, ``output``, and ``after``
|
|
set (no bare defaults that silently break pipelines).
|
|
2. Every ``@nw.narwhalify`` function must have a docstring.
|
|
3. Every Pydantic model exposed in a public ``__init__`` must be
|
|
importable without error.
|
|
4. The ratio of implemented-to-stub functions is tracked; a warning is
|
|
emitted (not a hard failure) if it regresses below the baseline.
|
|
5. Every ``pipe/*.py`` module that defines ``pipeline`` must export a
|
|
``Pipeline`` instance (not ``None`` or a bare list).
|
|
|
|
A separate parametrized test is generated for every discovered
|
|
``@nw.narwhalify`` function so that the pytest output clearly
|
|
identifies which functions are stubs — making CI output actionable
|
|
rather than a single blob of text.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import importlib
|
|
import inspect
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import NamedTuple
|
|
|
|
import pytest
|
|
|
|
# ── project root ──────────────────────────────────────────────────────────────
|
|
|
|
SRC_ROOT = Path(__file__).parent.parent / "src"
|
|
assert SRC_ROOT.exists(), f"src/ not found at {SRC_ROOT}"
|
|
|
|
|
|
# ── AST helpers ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _iter_python_files(root: Path) -> list[Path]:
|
|
"""Return all .py files under *root*, sorted for determinism."""
|
|
return sorted(root.rglob("*.py"))
|
|
|
|
|
|
def _decorator_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
|
|
"""Return flattened decorator name strings for a function node."""
|
|
names: list[str] = []
|
|
for dec in node.decorator_list:
|
|
if isinstance(dec, ast.Name):
|
|
names.append(dec.id)
|
|
elif isinstance(dec, ast.Attribute):
|
|
# e.g. nw.narwhalify → "nw.narwhalify"
|
|
names.append(f"{ast.unparse(dec)}")
|
|
elif isinstance(dec, ast.Call):
|
|
func = dec.func
|
|
if isinstance(func, ast.Name):
|
|
names.append(func.id)
|
|
elif isinstance(func, ast.Attribute):
|
|
names.append(ast.unparse(func))
|
|
return names
|
|
|
|
|
|
def _is_narwhalify(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
decs = _decorator_names(node)
|
|
return any(
|
|
d in ("narwhalify", "nw.narwhalify") or d.endswith(".narwhalify") for d in decs
|
|
)
|
|
|
|
|
|
def _body_is_stub(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
"""Return True if the function body is a stub (NotImplementedError / pass / ...)."""
|
|
body = node.body
|
|
|
|
# Strip leading docstring
|
|
stmts = body
|
|
if (
|
|
stmts
|
|
and isinstance(stmts[0], ast.Expr)
|
|
and isinstance(stmts[0].value, ast.Constant)
|
|
):
|
|
stmts = stmts[1:]
|
|
|
|
if not stmts:
|
|
return True # empty body after docstring
|
|
|
|
# Single statement
|
|
if len(stmts) == 1:
|
|
stmt = stmts[0]
|
|
# `pass`
|
|
if isinstance(stmt, ast.Pass):
|
|
return True
|
|
# `...`
|
|
if (
|
|
isinstance(stmt, ast.Expr)
|
|
and isinstance(stmt.value, ast.Constant)
|
|
and stmt.value.value is ...
|
|
):
|
|
return True
|
|
# `raise NotImplementedError` or `raise NotImplementedError(...)`
|
|
if isinstance(stmt, ast.Raise):
|
|
exc = stmt.exc
|
|
if exc is None:
|
|
return False
|
|
if isinstance(exc, ast.Name) and exc.id == "NotImplementedError":
|
|
return True
|
|
if isinstance(exc, ast.Call):
|
|
func = exc.func
|
|
if isinstance(func, ast.Name) and func.id == "NotImplementedError":
|
|
return True
|
|
if (
|
|
isinstance(func, ast.Attribute)
|
|
and func.attr == "NotImplementedError"
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _has_docstring(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
body = node.body
|
|
return (
|
|
bool(body)
|
|
and isinstance(body[0], ast.Expr)
|
|
and isinstance(body[0].value, ast.Constant)
|
|
and isinstance(body[0].value.value, str)
|
|
)
|
|
|
|
|
|
def _base_names(node: ast.ClassDef) -> list[str]:
|
|
"""Return flattened base class name strings for a class node."""
|
|
names: list[str] = []
|
|
for base in node.bases:
|
|
if isinstance(base, ast.Name):
|
|
names.append(base.id)
|
|
elif isinstance(base, ast.Attribute):
|
|
names.append(ast.unparse(base))
|
|
return names
|
|
|
|
|
|
def _is_pydantic_model(node: ast.ClassDef) -> bool:
|
|
bases = _base_names(node)
|
|
return any(b in ("BaseModel", "SQLTable", "SQLModel") for b in bases)
|
|
|
|
|
|
# ── data structures ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class FunctionRecord(NamedTuple):
|
|
path: Path
|
|
module: str # dotted module name relative to src
|
|
qualname: str # class.method or plain name
|
|
lineno: int
|
|
is_narwhalify: bool
|
|
is_stub: bool
|
|
has_docstring: bool
|
|
|
|
|
|
class ClassRecord(NamedTuple):
|
|
path: Path
|
|
module: str
|
|
name: str
|
|
lineno: int
|
|
is_pydantic: bool
|
|
|
|
|
|
class ExprRecord(NamedTuple):
|
|
path: Path
|
|
module: str
|
|
lineno: int
|
|
has_name: bool
|
|
has_fn: bool
|
|
has_output: bool
|
|
has_after: bool
|
|
|
|
|
|
# ── discovery ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _path_to_module(path: Path) -> str:
|
|
rel = path.relative_to(SRC_ROOT)
|
|
parts = list(rel.with_suffix("").parts)
|
|
if parts[-1] == "__init__":
|
|
parts = parts[:-1]
|
|
return ".".join(parts)
|
|
|
|
|
|
def _collect_functions(tree: ast.Module, path: Path) -> list[FunctionRecord]:
|
|
"""Walk the AST and collect all function/method definitions."""
|
|
records: list[FunctionRecord] = []
|
|
module = _path_to_module(path)
|
|
|
|
class Visitor(ast.NodeVisitor):
|
|
def __init__(self) -> None:
|
|
self._class_stack: list[str] = []
|
|
|
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
self._class_stack.append(node.name)
|
|
self.generic_visit(node)
|
|
self._class_stack.pop()
|
|
|
|
def _visit_func(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
qualname = (
|
|
".".join(self._class_stack + [node.name])
|
|
if self._class_stack
|
|
else node.name
|
|
)
|
|
records.append(
|
|
FunctionRecord(
|
|
path=path,
|
|
module=module,
|
|
qualname=qualname,
|
|
lineno=node.lineno,
|
|
is_narwhalify=_is_narwhalify(node),
|
|
is_stub=_body_is_stub(node),
|
|
has_docstring=_has_docstring(node),
|
|
)
|
|
)
|
|
# recurse into nested functions / methods
|
|
old = self._class_stack[:]
|
|
self.generic_visit(node)
|
|
self._class_stack[:] = old
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
self._visit_func(node)
|
|
|
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
self._visit_func(node)
|
|
|
|
Visitor().visit(tree)
|
|
return records
|
|
|
|
|
|
def _collect_classes(tree: ast.Module, path: Path) -> list[ClassRecord]:
|
|
records: list[ClassRecord] = []
|
|
module = _path_to_module(path)
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ClassDef):
|
|
records.append(
|
|
ClassRecord(
|
|
path=path,
|
|
module=module,
|
|
name=node.name,
|
|
lineno=node.lineno,
|
|
is_pydantic=_is_pydantic_model(node),
|
|
)
|
|
)
|
|
return records
|
|
|
|
|
|
def _expr_keyword_names(call: ast.Call) -> set[str]:
|
|
return {kw.arg for kw in call.keywords if kw.arg is not None}
|
|
|
|
|
|
def _collect_exprs(tree: ast.Module, path: Path) -> list[ExprRecord]:
|
|
"""Find all Expr(...) call sites in pipe modules."""
|
|
if "pipe" not in path.parts and path.name not in ("pipe.py",):
|
|
return []
|
|
|
|
records: list[ExprRecord] = []
|
|
module = _path_to_module(path)
|
|
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Call):
|
|
continue
|
|
func = node.func
|
|
is_expr_call = (isinstance(func, ast.Name) and func.id == "Expr") or (
|
|
isinstance(func, ast.Attribute) and func.attr == "Expr"
|
|
)
|
|
if not is_expr_call:
|
|
continue
|
|
kws = _expr_keyword_names(node)
|
|
records.append(
|
|
ExprRecord(
|
|
path=path,
|
|
module=module,
|
|
lineno=node.lineno,
|
|
has_name="name" in kws,
|
|
has_fn="fn" in kws,
|
|
has_output="output" in kws,
|
|
has_after="after" in kws,
|
|
)
|
|
)
|
|
return records
|
|
|
|
|
|
# ── build the full index ──────────────────────────────────────────────────────
|
|
|
|
|
|
def _build_index() -> tuple[
|
|
list[FunctionRecord],
|
|
list[ClassRecord],
|
|
list[ExprRecord],
|
|
]:
|
|
all_funcs: list[FunctionRecord] = []
|
|
all_classes: list[ClassRecord] = []
|
|
all_exprs: list[ExprRecord] = []
|
|
|
|
for py_file in _iter_python_files(SRC_ROOT):
|
|
# skip cache dirs and egg-info
|
|
if any(
|
|
part.startswith("__pycache__") or part.endswith(".egg-info")
|
|
for part in py_file.parts
|
|
):
|
|
continue
|
|
try:
|
|
source = py_file.read_text(encoding="utf-8")
|
|
tree = ast.parse(source, filename=str(py_file))
|
|
except SyntaxError:
|
|
continue
|
|
|
|
all_funcs.extend(_collect_functions(tree, py_file))
|
|
all_classes.extend(_collect_classes(tree, py_file))
|
|
all_exprs.extend(_collect_exprs(tree, py_file))
|
|
|
|
return all_funcs, all_classes, all_exprs
|
|
|
|
|
|
# Run once at collection time — fast (pure AST, no imports)
|
|
_ALL_FUNCS, _ALL_CLASSES, _ALL_EXPRS = _build_index()
|
|
|
|
_NARWHALIFY_FUNCS = [f for f in _ALL_FUNCS if f.is_narwhalify]
|
|
_STUB_FUNCS = [f for f in _ALL_FUNCS if f.is_stub]
|
|
_PYDANTIC_CLASSES = [c for c in _ALL_CLASSES if c.is_pydantic]
|
|
|
|
|
|
# ── parametrize helpers ───────────────────────────────────────────────────────
|
|
|
|
|
|
def _func_id(r: FunctionRecord) -> str:
|
|
return f"{r.module}::{r.qualname}"
|
|
|
|
|
|
def _expr_id(r: ExprRecord) -> str:
|
|
return f"{r.module}::L{r.lineno}"
|
|
|
|
|
|
# ── 1. narwhalify function inventory ─────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"record", _NARWHALIFY_FUNCS, ids=[_func_id(r) for r in _NARWHALIFY_FUNCS]
|
|
)
|
|
def test_narwhalify_has_docstring(record: FunctionRecord) -> None:
|
|
"""Every @nw.narwhalify function must document its contract.
|
|
|
|
Docstrings are the primary source for the ``Expr.doc`` property and
|
|
for pipeline graph introspection. A missing docstring is a gap in
|
|
the data dictionary.
|
|
"""
|
|
assert record.has_docstring, (
|
|
f"{record.module}::{record.qualname} (line {record.lineno}) "
|
|
f"is decorated with @nw.narwhalify but has no docstring.\n"
|
|
f" file: {record.path}"
|
|
)
|
|
|
|
|
|
@pytest.mark.stub
|
|
@pytest.mark.parametrize(
|
|
"record", _NARWHALIFY_FUNCS, ids=[_func_id(r) for r in _NARWHALIFY_FUNCS]
|
|
)
|
|
def test_narwhalify_implementation_status(record: FunctionRecord) -> None:
|
|
"""Report which @nw.narwhalify functions are stubs vs implemented.
|
|
|
|
Marked ``stub`` so the full test run can be filtered:
|
|
pytest -m 'not stub' # skip stub inventory
|
|
pytest -m stub # only show stub status
|
|
"""
|
|
status = "STUB" if record.is_stub else "IMPLEMENTED"
|
|
# This test always passes — it's an inventory report, not a gate.
|
|
# To gate on implementation completeness use
|
|
# test_implementation_ratio_above_baseline below.
|
|
_ = status # surfaced in pytest -v output via the parametrize id
|
|
|
|
|
|
# ── 2. implementation ratio ───────────────────────────────────────────────────
|
|
|
|
# Baseline: fraction of non-dunder, non-private functions that are implemented.
|
|
# Adjust upward as stubs are filled in — never lower it.
|
|
_IMPL_RATIO_BASELINE = 0.30
|
|
|
|
|
|
def test_implementation_ratio_above_baseline() -> None:
|
|
"""Implemented function ratio must not regress below the baseline.
|
|
|
|
This is a ratchet: once you implement more functions you raise the
|
|
baseline so the test suite stays honest about coverage trajectory.
|
|
"""
|
|
public = [
|
|
f
|
|
for f in _ALL_FUNCS
|
|
if not f.qualname.startswith("_") and not f.qualname.startswith("test_")
|
|
]
|
|
if not public:
|
|
pytest.skip("No public functions found")
|
|
|
|
implemented = [f for f in public if not f.is_stub]
|
|
ratio = len(implemented) / len(public)
|
|
|
|
# Informational summary in the failure message
|
|
stub_list = "\n".join(
|
|
f" {f.module}::{f.qualname} (L{f.lineno})" for f in public if f.is_stub
|
|
)
|
|
|
|
assert ratio >= _IMPL_RATIO_BASELINE, (
|
|
f"Implementation ratio {ratio:.1%} is below baseline {_IMPL_RATIO_BASELINE:.1%}.\n"
|
|
f"Stubs remaining ({len(public) - len(implemented)}/{len(public)}):\n{stub_list}"
|
|
)
|
|
|
|
|
|
def test_stub_inventory_is_complete() -> None:
|
|
"""All stubs must live in src/ — none in tests/ or dev/."""
|
|
test_stubs = [
|
|
f for f in _STUB_FUNCS if "tests" in str(f.path) or "dev" in str(f.path)
|
|
]
|
|
assert not test_stubs, (
|
|
"Stub functions found outside src/ — these should be real implementations:\n"
|
|
+ "\n".join(f" {f.path}:{f.lineno} {f.qualname}" for f in test_stubs)
|
|
)
|
|
|
|
|
|
# ── 3. Expr structural invariants ─────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize("record", _ALL_EXPRS, ids=[_expr_id(r) for r in _ALL_EXPRS])
|
|
def test_expr_has_name(record: ExprRecord) -> None:
|
|
"""Every Expr(...) call must supply a ``name`` keyword argument."""
|
|
assert record.has_name, (
|
|
f"Expr at {record.module} line {record.lineno} is missing ``name=``.\n"
|
|
f" file: {record.path}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("record", _ALL_EXPRS, ids=[_expr_id(r) for r in _ALL_EXPRS])
|
|
def test_expr_has_fn(record: ExprRecord) -> None:
|
|
"""Every Expr(...) call must supply a ``fn`` keyword argument."""
|
|
assert record.has_fn, (
|
|
f"Expr at {record.module} line {record.lineno} is missing ``fn=``.\n"
|
|
f" file: {record.path}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("record", _ALL_EXPRS, ids=[_expr_id(r) for r in _ALL_EXPRS])
|
|
def test_expr_has_output(record: ExprRecord) -> None:
|
|
"""Every Expr(...) call must declare its ``output`` SQLTable contract."""
|
|
assert record.has_output, (
|
|
f"Expr at {record.module} line {record.lineno} is missing ``output=``.\n"
|
|
f"Without an output contract, pipeline schema validation is impossible.\n"
|
|
f" file: {record.path}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("record", _ALL_EXPRS, ids=[_expr_id(r) for r in _ALL_EXPRS])
|
|
def test_expr_has_after(record: ExprRecord) -> None:
|
|
"""Every Expr(...) call must declare its ``after`` dependency list.
|
|
|
|
An empty list (``after=[]``) is valid — the runner derives ordering
|
|
from the function signature. The invariant is that the author made
|
|
an explicit choice, not that they left it to the default.
|
|
"""
|
|
assert record.has_after, (
|
|
f"Expr at {record.module} line {record.lineno} is missing ``after=``.\n"
|
|
f"Declare ``after=[]`` explicitly if there are no upstream dependencies.\n"
|
|
f" file: {record.path}"
|
|
)
|
|
|
|
|
|
# ── 4. Pydantic model importability ──────────────────────────────────────────
|
|
|
|
|
|
def _public_pydantic_models() -> list[ClassRecord]:
|
|
"""Pydantic models in public modules (not _private or test files)."""
|
|
return [
|
|
c
|
|
for c in _PYDANTIC_CLASSES
|
|
if not any(part.startswith("_") for part in c.module.split("."))
|
|
and "test" not in c.module
|
|
and "dev" not in c.module
|
|
]
|
|
|
|
|
|
_PUBLIC_MODELS = _public_pydantic_models()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"record",
|
|
_PUBLIC_MODELS,
|
|
ids=[f"{c.module}::{c.name}" for c in _PUBLIC_MODELS],
|
|
)
|
|
def test_pydantic_model_importable(record: ClassRecord) -> None:
|
|
"""Every public Pydantic model must be importable without error.
|
|
|
|
Import failures at test time surface missing dependencies, circular
|
|
imports, or broken ``__init__.py`` re-exports early.
|
|
"""
|
|
# Insert src onto path if needed
|
|
src_str = str(SRC_ROOT)
|
|
if src_str not in sys.path:
|
|
sys.path.insert(0, src_str)
|
|
|
|
try:
|
|
mod = importlib.import_module(record.module)
|
|
except ImportError as exc:
|
|
pytest.fail(
|
|
f"Cannot import {record.module} (needed for {record.name}): {exc}\n"
|
|
f" file: {record.path}"
|
|
)
|
|
|
|
cls = getattr(mod, record.name, None)
|
|
if cls is None:
|
|
# Class may be private or re-exported under a different name — skip
|
|
pytest.skip(f"{record.name} not directly accessible on {record.module}")
|
|
|
|
if not isinstance(cls, type):
|
|
pytest.skip(f"{record.name} is not a class on {record.module}")
|
|
|
|
# Basic sanity: must have model_fields (Pydantic v2 class attribute)
|
|
assert hasattr(cls, "model_fields"), (
|
|
f"{record.module}.{record.name} does not look like a Pydantic v2 model "
|
|
f"(missing ``model_fields`` class attribute)."
|
|
)
|
|
|
|
|
|
# ── 5. Pipeline module structural checks ──────────────────────────────────────
|
|
|
|
|
|
def _find_pipeline_modules() -> list[Path]:
|
|
"""Find all pipe/*.py files that assign a ``pipeline`` name."""
|
|
results: list[Path] = []
|
|
for path in _iter_python_files(SRC_ROOT):
|
|
if "pipe" not in path.parts:
|
|
continue
|
|
if path.name.startswith("_"):
|
|
continue
|
|
try:
|
|
source = path.read_text(encoding="utf-8")
|
|
tree = ast.parse(source)
|
|
except (SyntaxError, OSError):
|
|
continue
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, (ast.Assign, ast.AnnAssign)) and _assigns_name(
|
|
node, "pipeline"
|
|
):
|
|
results.append(path)
|
|
break
|
|
return results
|
|
|
|
|
|
def _assigns_name(node: ast.Assign | ast.AnnAssign, name: str) -> bool:
|
|
if isinstance(node, ast.Assign):
|
|
return any((isinstance(t, ast.Name) and t.id == name) for t in node.targets)
|
|
if isinstance(node, ast.AnnAssign):
|
|
return isinstance(node.target, ast.Name) and node.target.id == name
|
|
return False
|
|
|
|
|
|
_PIPELINE_MODULES = _find_pipeline_modules()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
_PIPELINE_MODULES,
|
|
ids=[_path_to_module(p) for p in _PIPELINE_MODULES],
|
|
)
|
|
def test_pipeline_module_exports_pipeline_instance(path: Path) -> None:
|
|
"""Each pipe module that defines ``pipeline`` must export a Pipeline.
|
|
|
|
A ``None`` or list value would silently break the runner.
|
|
"""
|
|
src_str = str(SRC_ROOT)
|
|
if src_str not in sys.path:
|
|
sys.path.insert(0, src_str)
|
|
|
|
module_name = _path_to_module(path)
|
|
try:
|
|
mod = importlib.import_module(module_name)
|
|
except ImportError as exc:
|
|
pytest.fail(f"Cannot import {module_name}: {exc}")
|
|
|
|
pipeline = getattr(mod, "pipeline", None)
|
|
assert pipeline is not None, (
|
|
f"{module_name} defines ``pipeline`` but the attribute is None at import time."
|
|
)
|
|
# Check it has an ``exprs`` attribute (duck-type Pipeline check)
|
|
assert hasattr(pipeline, "exprs"), (
|
|
f"{module_name}.pipeline does not look like a Pipeline — "
|
|
f"missing ``.exprs`` attribute. Got: {type(pipeline)}"
|
|
)
|
|
assert isinstance(pipeline.exprs, list), (
|
|
f"{module_name}.pipeline.exprs must be a list, got {type(pipeline.exprs)}"
|
|
)
|
|
assert len(pipeline.exprs) > 0, (
|
|
f"{module_name}.pipeline.exprs is empty — no expressions registered."
|
|
)
|
|
|
|
|
|
# ── 6. param_to_table naming convention ──────────────────────────────────────
|
|
|
|
|
|
def _collect_narwhalify_param_names() -> list[tuple[str, str, str]]:
|
|
"""Return (module, qualname, param_name) for every narwhalify param."""
|
|
results: list[tuple[str, str, str]] = []
|
|
src_str = str(SRC_ROOT)
|
|
if src_str not in sys.path:
|
|
sys.path.insert(0, src_str)
|
|
|
|
for rec in _NARWHALIFY_FUNCS:
|
|
try:
|
|
mod = importlib.import_module(rec.module)
|
|
fn = getattr(mod, rec.qualname.split(".")[0], None)
|
|
if fn is None or not callable(fn):
|
|
continue
|
|
sig = inspect.signature(fn)
|
|
for pname in sig.parameters:
|
|
results.append((rec.module, rec.qualname, pname))
|
|
except Exception:
|
|
continue
|
|
return results
|
|
|
|
|
|
def test_narwhalify_param_names_follow_convention() -> None:
|
|
"""Parameter names of @nw.narwhalify functions must follow the __ convention.
|
|
|
|
Valid forms:
|
|
- ``df`` — single unnamed input (passthrough)
|
|
- ``schema__table`` → ``schema.table``
|
|
- ``schema___private`` → ``schema._private``
|
|
- bare single word like ``rvu``, ``gpci`` (for PFS-style positional args)
|
|
|
|
Invalid: mixed case, triple underscores at start, or ``__`` prefix
|
|
that would resolve to ``.table`` (missing schema).
|
|
"""
|
|
violations: list[str] = []
|
|
params = _collect_narwhalify_param_names()
|
|
|
|
for module, qualname, pname in params:
|
|
# Skip self/cls
|
|
if pname in ("self", "cls"):
|
|
continue
|
|
# df or single-word bare name — always fine
|
|
if "__" not in pname:
|
|
continue
|
|
# Must have a non-empty schema part before the first __
|
|
parts = pname.split("__", 1)
|
|
schema = parts[0]
|
|
if not schema:
|
|
violations.append(
|
|
f" {module}::{qualname} param ``{pname}`` starts with __ "
|
|
f"(missing schema prefix)"
|
|
)
|
|
|
|
assert not violations, (
|
|
"Parameter naming convention violations found:\n" + "\n".join(violations)
|
|
)
|
|
|
|
|
|
# ── 7. no bare ``raise NotImplementedError`` in __init__.py ──────────────────
|
|
|
|
|
|
def test_no_stubs_in_init_files() -> None:
|
|
"""__init__.py files must not contain NotImplementedError stubs.
|
|
|
|
Init files declare the public API surface. A stub there breaks any
|
|
consumer that imports from the package.
|
|
"""
|
|
bad: list[str] = []
|
|
for rec in _STUB_FUNCS:
|
|
if rec.path.name == "__init__.py":
|
|
bad.append(f" {rec.path}:{rec.lineno} {rec.qualname}")
|
|
assert not bad, "Stub functions found in __init__.py files:\n" + "\n".join(bad)
|
|
|
|
|
|
# ── 8. express / pipe module symmetry ────────────────────────────────────────
|
|
|
|
|
|
def test_express_pipe_module_symmetry() -> None:
|
|
"""Each aco/express/<module>.py should have a matching aco/pipe/<module>.py.
|
|
|
|
The express layer holds narwhalify functions; the pipe layer assembles them
|
|
into Pipeline objects. Asymmetry usually means a module was added to one
|
|
side but not wired up on the other.
|
|
"""
|
|
express_dir = SRC_ROOT / "aco" / "express"
|
|
pipe_dir = SRC_ROOT / "aco" / "pipe"
|
|
|
|
if not express_dir.exists() or not pipe_dir.exists():
|
|
pytest.skip("aco/express or aco/pipe not found")
|
|
|
|
express_mods = {
|
|
p.stem for p in express_dir.glob("*.py") if not p.stem.startswith("_")
|
|
}
|
|
pipe_mods = {
|
|
p.stem
|
|
for p in pipe_dir.glob("*.py")
|
|
if not p.stem.startswith("_") and p.stem not in ("runner", "opps")
|
|
}
|
|
|
|
missing_in_pipe = express_mods - pipe_mods
|
|
missing_in_express = pipe_mods - express_mods
|
|
|
|
msgs: list[str] = []
|
|
if missing_in_pipe:
|
|
msgs.append(
|
|
"express/ modules with no matching pipe/ module:\n"
|
|
+ "\n".join(f" aco/express/{m}.py" for m in sorted(missing_in_pipe))
|
|
)
|
|
if missing_in_express:
|
|
msgs.append(
|
|
"pipe/ modules with no matching express/ module:\n"
|
|
+ "\n".join(f" aco/pipe/{m}.py" for m in sorted(missing_in_express))
|
|
)
|
|
|
|
assert not msgs, "\n".join(msgs)
|
|
|
|
|
|
# ── 9. Tag model factory method coverage ─────────────────────────────────────
|
|
|
|
|
|
def test_tag_factory_methods_return_tag_instances() -> None:
|
|
"""All Tag.* factory methods must return Tag instances with correct namespace."""
|
|
src_str = str(SRC_ROOT)
|
|
if src_str not in sys.path:
|
|
sys.path.insert(0, src_str)
|
|
|
|
from bib.tag import Tag
|
|
|
|
cases = [
|
|
(Tag.module("aco"), "module", "aco"),
|
|
(Tag.table("pfs.rvu"), "table", "pfs.rvu"),
|
|
(Tag.source("cms-website"), "source", "cms-website"),
|
|
(Tag.year(2026), "year", "2026"),
|
|
(Tag.rule("cms-1807-f"), "rule", "cms-1807-f"),
|
|
(Tag.file("rvu"), "file", "rvu"),
|
|
(Tag.sup("parent-key"), "sup", "parent-key"),
|
|
]
|
|
|
|
for tag, expected_ns, expected_val in cases:
|
|
assert isinstance(tag, Tag), f"Expected Tag, got {type(tag)}"
|
|
assert tag.namespace == expected_ns, (
|
|
f"Tag.{expected_ns}({expected_val!r}).namespace = {tag.namespace!r}, "
|
|
f"expected {expected_ns!r}"
|
|
)
|
|
assert tag.value == expected_val, (
|
|
f"Tag.{expected_ns}({expected_val!r}).value = {tag.value!r}, "
|
|
f"expected {expected_val!r}"
|
|
)
|