From 7edf69cbffebccfd99227f09a279590248a1abc0 Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Wed, 8 Jul 2026 23:12:52 -0300 Subject: [PATCH 01/10] build: ship typed package marker --- .local/public-api-surface-improvements.md | 340 ++++++++++++++++++++++ pyproject.toml | 2 +- ser/py.typed | 0 3 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 .local/public-api-surface-improvements.md create mode 100644 ser/py.typed diff --git a/.local/public-api-surface-improvements.md b/.local/public-api-surface-improvements.md new file mode 100644 index 0000000..09cccd5 --- /dev/null +++ b/.local/public-api-surface-improvements.md @@ -0,0 +1,340 @@ +# Public API surface — top-1% hardening plan + +| | | +|---|---| +| **Status** | ACTIVE | +| **Created** | 2026-07-08 | +| **Repo** | `~/dev/github/ser` (Python 3.12.8 pin, hatchling, unpublished — no PyPI release, no git tags) | +| **Branch convention** | one branch per phase off `main`, e.g. `refactor/public-api-hardening-p0` | +| **Tracking** | Task Ledger (§6) + Implementation Journal (§9) in this file | +| **Predecessor** | `.local/api-surface-tightening.md` (shipped as PR #70, merged 2026-07-08) | + +--- + +## 1. Intent + +PR #70 gave the repo a disciplined boundary *mechanism*: `ser/_internal/`, thin +`ser.api`/`ser.config` facades, `boundary_policy.toml` enforced by +`tests/suites/integration/architecture/test_api_import_boundary.py`. This plan finishes +the job so the public surface is **machine-verified, self-sufficient, versioned, and +governed** — the four properties that separate top-1% Python API surfaces (trio, +urllib3, attrs, structlog) from merely well-designed ones: + +1. **Machine-verified**: the exact public surface is a checked-in snapshot; any change + is an explicit, reviewable diff. Type completeness is scored and gated. Import cost + is a contract test, not a convention. +2. **Self-sufficient**: every type appearing in a `ser.api` signature is importable + from `ser.api`. No consumer ever needs a non-facade import path. +3. **Versioned**: `ser.__version__` exists; `py.typed` ships so downstream type + checkers actually see the carefully designed types. +4. **Governed**: a written stability policy per tier, tested doc examples, and a + changelog discipline that starts before first publish. + +## 2. Goals + +- G1 — Ship `py.typed`; pyright `--verifytypes` score for `ser` ≥ 95% (ratchet from baseline). +- G2 — `ser.api` exports every type in its own signatures; `__all__` complete. +- G3 — Checked-in public-API snapshot + contract test that fails on any surface drift. +- G4 — Import-cost contract test: `import ser` / `import ser.api` never imports `torch`. +- G5 — Public tree contains only tier-1 modules and genuine facades; implementation + lives under `ser/_internal/`; `boundary_policy.toml` shrinks to facades only. +- G6 — Written stability policy, tested README examples, bootstrapped changelog. + +## 3. Non-goals / out of scope + +- NG1 — No runtime behavior, dependency (runtime), or algorithm changes. Moves only. +- NG2 — No version bump. Package is unpublished; stays `1.0.0`. No deprecation shims — + hard moves are the established convention here (see predecessor doc). +- NG3 — No publishing, no triggering of publish workflows + (`.github/workflows/python-publish*.yml`), no edits to them. +- NG4 — No mkdocs/documentation site (deferred until after first publish). +- NG5 — No towncrier; a plain `CHANGELOG.md` is enough pre-publish. +- NG6 — No CLI surface changes (`ser/__main__.py`, `ser/_internal/cli/*`). +- NG7 — No changes to the platform-conditional torch/torchaudio dependency matrix + (versions, markers, or lock partitions) — see DD-13 for the full locked matrix and + the files that encode it. + +## 4. Current state (verified 2026-07-08) + +- Tier-1 facades: `ser/__init__.py` (3 domain NamedTuples only), `ser/api.py` + (14 functions + 4 re-exported types + `RuntimePipeline` Protocol), `ser/config.py` + (~25 re-exports from `ser._internal.config`), `ser/domain.py`, `ser/profiles.py`, + `ser/utils/` (lazy-import facade). +- `ser/_internal/`: 76 files (api/, cli/, config/, runtime plumbing, models support). +- Residual public implementation (~126 files): `ser/data` 39, `ser/models` 26, + `ser/runtime` 24, `ser/transcript` 19, `ser/utils` 10, `ser/diagnostics` 4, + `ser/features` 2, `ser/heads` 2, plus `ser/repr`. +- `boundary_policy.toml`: 17 public→`_internal` exceptions; several are implementation + under public paths (`ser/runtime/*_inference.py`, `ser/models/training_entrypoints.py`, + `ser/transcript/*`), not facades. +- **No `py.typed`** — the typed surface is invisible to downstream checkers. +- **No `ser.__version__`**. +- `ser.api` signature types `InferenceRequest`/`InferenceExecution`/`SubtitleFormat`/ + `DiagnosticReport` are TYPE_CHECKING-only there and not in `__all__`. +- `show_dataset_consents`/`configure_dataset_consents` return an anonymous + `tuple[tuple[str, ...], tuple[str, ...]]`. +- `train()` has two escape hatches: `use_profile_pipeline: bool` + `pipeline_builder`. + +## 5. Design decisions + +| ID | Decision | Rationale | +|----|----------|-----------| +| DD-01 | Hard moves, no deprecation shims, version stays 1.0.0. | Package never published; no external consumers. Matches predecessor doc convention. | +| DD-02 | Tier model: **tier-1** = `ser`, `ser.api`, `ser.config` (trimmed), `ser.domain`, `ser.profiles`, `ser.utils` (curated `__all__` only). **Facades** = the by-export `__init__.py`/named facade modules listed in `boundary_policy.toml`. Everything else internal. | Continues the tier-1 outcome PR #70 declared; removes the ambiguous "looks public" middle tier. | +| DD-03 | `ser.api` re-exports its full signature vocabulary at runtime (not TYPE_CHECKING-only). | A facade that requires side-door imports for its own return types isn't a facade. | +| DD-04 | Type distribution = `py.typed` + pyright `--verifytypes` ratchet gate (baseline first, target ≥ 95%). | "Has annotations" ≠ "ships a verified typed API". The ratchet prevents regression without blocking on legacy gaps day one. | +| DD-05 | Public-API snapshot generated with **griffe** (new dev-only dependency), stored at `tests/suites/integration/architecture/public_api_snapshot.json`, asserted by a contract test. Fallback if griffe is rejected: hand-rolled `inspect`-based dumper in `scripts/`. | Industry-standard tool; the snapshot diff becomes the review artifact for any API change. Dev-dep addition is allowed; runtime deps are not (NG1). | +| DD-06 | Import-cost test asserts **module absence** (`torch` not in `sys.modules` after `import ser, ser.api` in a subprocess), no wall-clock assertions. | Wall-clock is flaky on the slow WSL2 dev box and in CI. Module presence is deterministic. | +| DD-07 | `DatasetConsents(NamedTuple)` with fields `policy_ids`, `license_ids` in `ser.domain`; consent functions return it. | NamedTuple is a tuple subtype — existing unpacking keeps working; new code gets names. | +| DD-08 | `train()` drops `use_profile_pipeline`; `pipeline_builder=None` → profile pipeline, provided → custom. | One extension point. Hard removal is safe per DD-01. | +| DD-09 | `ser.__version__` via `importlib.metadata.version("ser")` with a `PackageNotFoundError` fallback to `"0.0.0.dev0"`; `pyproject.toml` version stays the single static source. | No hatch dynamic-version machinery needed; one source of truth. | +| DD-10 | Mirror the boundary at lint speed with ruff `flake8-tidy-imports` `TID251` banning `ser._internal` imports, allowlisted via `per-file-ignores` generated from `boundary_policy.toml` entries. | Violations fail in seconds at `make lint`, not only in the architecture test lane. The contract test remains authoritative. | +| DD-11 | Module moves use `git mv`; imports updated mechanically; the boundary contract test may only be strengthened, never weakened. | Preserves history; preserves the guarantee. | +| DD-12 | README examples are executed by a contract test (extract fenced `python` blocks, run in subprocess with a stub/sample where needed). No sybil/doctest framework added. | Smallest thing that makes advertised examples provably work. | +| DD-13 | The platform-conditional torch/torchaudio dependency matrix is **authoritative and frozen** for this effort. Where it lives (stable anchors, not line numbers): the marker-conditional `torch`/`torchaudio` specifiers in `[project] dependencies` of `pyproject.toml` (find: `rg -n 'torch' pyproject.toml`); the `resolution-markers` block at the top of `uv.lock` (environment partitions); and the `[[package]]` entries for `torch` and `torchaudio` in `uv.lock` (find: `rg -n '^name = "torch(audio)?"' uv.lock`). Locked outcomes: Darwin x86_64 + py<3.13 → torch==2.2.2 (rule `>=2.2,<=2.2.2`); Darwin x86_64 + py3.13 → no torch marker matches, no direct locked torch; Linux/Windows/Darwin arm64 + py3.12 → torch==2.10.0 (rule `>=2.2,<3.0`); same platforms + py3.13 → torch==2.10.0 (rule `>=2.6,<3.0`). torchaudio mirrors the split (2.2.2 on old Intel py3.12; 2.10.x has no full runtime wheels there). CI lanes depend on it: Linux CI 3.12/3.13 → 2.10.0; Darwin Intel validation (macos-15-intel, py3.12) → 2.2.2; macOS MPS arm64 → 2.10.0; Linux self-hosted GPU → 2.10.0 + Linux x86_64 CUDA deps from torch wheel metadata. Any `uv lock` re-resolution (e.g. P1-01's griffe addition) must leave every torch/torchaudio locked version and marker partition byte-identical; verify with `git diff uv.lock` scoped to those sections before committing. | This matrix encodes hard platform compatibility (old Intel Mac wheel cutoff, MPS, CUDA) that a routine lock refresh could silently destroy; four CI lanes and the GPU runner assume it. | + +## 6. Task Ledger + +Statuses: `TODO` → `IN-PROGRESS` → `DONE` (or `BLOCKED` / `DROPPED` with journal entry). +One task `IN-PROGRESS` at a time. Update this table **and** the journal on every transition. + +### Phase 0 — Ship the types you already wrote (additive, no moves) + +| ID | Task | Status | Depends on | +|----|------|--------|------------| +| P0-01 | `py.typed` marker shipped in the wheel | DONE | — | +| P0-02 | `ser.__version__` | TODO | — | +| P0-03 | `ser.api` self-sufficiency (re-export signature vocabulary) | TODO | — | +| P0-04 | `DatasetConsents` NamedTuple | TODO | — | +| P0-05 | `train()` single extension point | TODO | — | + +**P0-01** — Create empty `ser/py.typed`. Add it to the hatchling build include in +`pyproject.toml` (next to the existing `ser/profile_defs.yaml` include). +*Acceptance*: `uv build` succeeds and `unzip -l dist/ser-1.0.0-py3-none-any.whl | rg py.typed` +shows the file; `make lint type` exit 0. + +**P0-02** — In `ser/__init__.py`, expose `__version__` per DD-09; add to `__all__`. +*Acceptance*: `uv run --frozen --extra dev python -c "import ser; print(ser.__version__)"` +prints `1.0.0`; import-cost invariant still holds (see P1-03 command, runnable ad hoc). + +**P0-03** — In `ser/api.py`: move `InferenceRequest`, `InferenceExecution`, +`SubtitleFormat`, `DiagnosticReport`, `DiagnosticFinding`, `DiagnosticSeverity` out of +the TYPE_CHECKING guard into runtime re-exports; add them plus `AppConfig` and +`ProfileName` to `__all__`. **Contingency**: importing `ser.runtime.contracts` executes +`ser/runtime/__init__.py` (which imports `pipeline`); if that pulls heavy deps, relocate +the contract dataclasses to a leaf module (candidate: `ser/_internal/runtime/` leaf +re-exported by `ser.runtime.contracts`) rather than accepting the import cost. +*Acceptance*: `uv run --frozen --extra dev python -c "import sys; from ser.api import InferenceRequest, InferenceExecution, SubtitleFormat, DiagnosticReport, AppConfig, ProfileName; assert 'torch' not in sys.modules"` +exits 0; `make check` exits 0. + +**P0-04** — Add `DatasetConsents(NamedTuple)` (`policy_ids: tuple[str, ...]`, +`license_ids: tuple[str, ...]`) to `ser/domain.py`; return it from +`show_dataset_consents`/`configure_dataset_consents` in `ser.api` and the internal +owner; export from `ser.api` and `ser.domain`. +*Acceptance*: existing consent tests pass unmodified (tuple compatibility proven); +new assertion on field access added to the relevant test module; `make check` exits 0. + +**P0-05** — Remove `use_profile_pipeline` from `ser.api.train` and +`ser._internal.api.runtime.train`; `pipeline_builder` becomes the sole override. Update +all callers (CLI, tests). +*Acceptance*: `rg -n "use_profile_pipeline" ser tests` returns nothing; +`make check test-cov` exit 0. + +### Phase 1 — Machine-enforce the contract + +| ID | Task | Status | Depends on | +|----|------|--------|------------| +| P1-01 | Public-API snapshot + drift contract test | TODO | P0-03, P0-04, P0-05 | +| P1-02 | pyright `--verifytypes` ratchet gate | TODO | P0-01 | +| P1-03 | Import-cost contract test | TODO | P0-03 | +| P1-04 | ruff TID251 boundary lint | TODO | — | +| P1-05 | CI wiring for the new gates | TODO | P1-01..04 | + +**P1-01** — Add `griffe` to the dev extra (`uv lock` refresh; `make lock-check` must +pass). Script `scripts/dump_public_api.py` dumps names + signatures + annotations for +tier-1 modules (DD-02) to `tests/suites/integration/architecture/public_api_snapshot.json` +(sorted keys, stable formatting). Contract test regenerates in-memory and diffs against +the checked-in snapshot with a failure message saying how to intentionally update. +*Acceptance*: test passes on clean tree; demonstrably fails when a symbol is added to +`ser/api.py` `__all__` (show the failing run in the journal, then revert); `make check +lock-check` exit 0; per DD-13, `git diff uv.lock` shows **no** change to any torch or +torchaudio version, marker, or partition line — paste the (empty) scoped diff into the +journal entry. + +**P1-02** — Make target `type-completeness`: `uv run --frozen --extra dev pyright +--verifytypes ser --ignoreexternal --outputjson` parsed against a threshold stored in +`pyproject.toml` or the Makefile. Record the baseline score first; set the gate at +baseline; file the ratchet-to-95% as follow-up work in the journal if baseline < 95%. +*Acceptance*: `make type-completeness` exits 0 at baseline and the baseline number is +recorded in the journal. + +**P1-03** — Contract test (architecture suite): subprocess runs +`import ser, ser.api, ser.config, ser.domain, ser.profiles, ser.utils` then asserts +`torch not in sys.modules` (DD-06). +*Acceptance*: test passes; `make check` exits 0. + +**P1-04** — Configure ruff `TID251` to ban `ser._internal` imports tree-wide, with +`per-file-ignores` exactly mirroring `boundary_policy.toml` paths. Add a comment in +`pyproject.toml` pointing at the policy file as the source of truth. +*Acceptance*: `make lint` exits 0 on clean tree; a synthetic violation (temporary +`from ser._internal.config.schema import AppConfig` in a non-allowlisted module) fails +`make lint` — demonstrate, journal it, revert. + +**P1-05** — Add snapshot test lane (it runs with the existing architecture suite — +verify it is collected in CI), and `type-completeness` to the CI quality workflow. +Never touch publish workflows (NG3). +*Acceptance*: workflow YAML change passes `make workflow-lint ci-contracts`; the +updated workflow is dispatched once via `gh workflow run` (validation workflows only) +and observed green via `verifier-ci`. + +### Phase 2 — Finish the tier consolidation (moves only) + +| ID | Task | Status | Depends on | +|----|------|--------|------------| +| P2-01 | Inventory & classification appendix | TODO | P1-01 | +| P2-02 | `ser/models` → internal (keep facades) | TODO | P2-01 | +| P2-03 | `ser/runtime` → internal (keep contracts/pipeline/registry facades) | TODO | P2-01 | +| P2-04 | `ser/data` → internal (keep `application.py` + curated `__init__`) | TODO | P2-01 | +| P2-05 | `ser/transcript` → internal (keep policy-listed facades) | TODO | P2-01 | +| P2-06 | `ser/features`, `ser/heads`, `ser/repr`, `ser/diagnostics` residue | TODO | P2-01 | +| P2-07 | Shrink `boundary_policy.toml` to genuine facades; strengthen contract test | TODO | P2-02..06 | +| P2-08 | `ser/utils` trim to curated `__all__` | TODO | P2-01 | + +**P2-01** — Generate the full classification: for every `.py` under public non-tier-1 +paths, record `keep-as-facade` (with reason) or `move-internal` (with destination). +Append as Appendix A of this file. This is the review artifact for the phase; get it +into the journal before any move. +*Acceptance*: Appendix A exists, covers all ~126 files, no `unclassified` rows. + +**P2-02..P2-06** — Per subpackage, in dependency order within each task: `git mv` +move-internal modules to `ser/_internal//`, update imports (internal code +imports internal paths directly; facades re-export), keep public `__init__.py` exports +byte-compatible for symbols classified keep. One commit per subpackage. +*Acceptance per task*: `make check test-cov import-lint` exit 0; P1-01 snapshot test +passes **unchanged** (tier-1 surface must not move); coverage gate (fail_under=78) holds. + +**P2-07** — Remove policy entries whose paths moved internal; the remaining entries are +genuine facades only. Strengthen the contract test if PR #70 didn't already: it must +fail on new `_internal` imports without a policy entry and on tier-1 `__all__` growth. +*Acceptance*: policy file ≤ 10 entries, each with a facade reason; contract test +demonstrably fails on a synthetic unlisted `_internal` import (journal, revert). + +**P2-08** — `ser/utils/__init__.py` keeps only the curated lazy `__all__`; helper +modules not re-exported move internal. +*Acceptance*: same gate set as P2-02..06. + +### Phase 3 — Governance + +| ID | Task | Status | Depends on | +|----|------|--------|------------| +| P3-01 | `docs/api-stability.md` | TODO | P2-07 | +| P3-02 | README Python API section refresh | TODO | P2-07 | +| P3-03 | README examples executed by a contract test | TODO | P3-02 | +| P3-04 | `CHANGELOG.md` bootstrap | TODO | — | + +**P3-01** — Document: tier-1 list (DD-02), the SemVer promise that activates at first +publish, what `_internal` means, how the snapshot test governs API change, pointer to +`boundary_policy.toml`. +*Acceptance*: file exists, linked from README; `make lint` (docs lint if any) passes. + +**P3-02** — README "Python API" section shows `ser.api` as sole supported entry point, +a minimal `infer` example, `__version__`, and links the stability doc. +*Acceptance*: `rg` shows no README references to moved/removed symbols. + +**P3-03** — Contract test per DD-12 executes README `python` blocks. +*Acceptance*: test passes; deliberately breaking an example fails it (journal, revert). + +**P3-04** — `CHANGELOG.md` in keep-a-changelog format with an `[Unreleased]` section +summarizing this effort (no branding words — see §7). +*Acceptance*: file exists; `make lint` passes. + +## 7. Operating instructions for the coding agent + +1. **Read this file top to bottom before any work.** Re-read §5 and §8 after context + compaction. +2. **Environment**: Python pinned 3.12.8 via `.python-version`. Every dev-tool + invocation must be `uv run --frozen --extra dev ...` — bare `uv run` rebuilds the + venv without dev tools. Make targets already do this. +3. **Gates** (the definition of "green"): `make lint`, `make type`, `make test-cov` + (coverage fail_under=78), `make import-lint`, `make lock-check`. Full local sweeps + are fine; `make quality-gate-full`, training, and accurate-profile inference belong + in CI only (slow WSL2 box). +4. **Git discipline**: branch per phase off `main` (`refactor/public-api-hardening-pN`), + conventional commits, one logical commit per task (P2 allows one per subpackage). + **Never** use the words claude, anthropic, codex, or openai in branch names, commit + messages, PR titles/descriptions, or labels. Open a PR per phase; do not merge + without the user. +5. **Workflow per task**: journal a *start* entry → set ledger `IN-PROGRESS` → implement + → run the task's acceptance commands + the gate set → journal a *done* entry citing + the actual command results → set ledger `DONE` → commit (code + this file together). +6. **Verification honesty**: only claim what a command in this session demonstrated. + Where a task says "demonstrate failure, then revert", the failing output must appear + in the journal entry. +7. **Delegation**: run gate sweeps via a `verifier-lite` subagent with the exact + commands; use `verifier-ci` for remote CI status. Never dispatch publish workflows. +8. **ffmpeg** is a runtime prerequisite for inference only, not tests; do not attempt + to install it (needs sudo). + +## 8. Stop conditions — halt, journal, and report to the user when… + +- S1 — An acceptance or gate command fails for a reason **unrelated** to the current + task (pre-existing breakage, environment issue). +- S2 — A task appears to require weakening the boundary contract test, the coverage + gate, or any existing check. +- S3 — A change would touch publish workflows, trigger publishing, or bump the version. +- S4 — A move requires a runtime behavior change to keep tests green (violates NG1). +- S5 — Two consecutive failed attempts at the same acceptance criterion. +- S6 — The P2-01 classification finds a module that cannot be cleanly classified + (genuinely ambiguous ownership). +- S7 — A dependency addition beyond `griffe` (dev-only) seems needed. +- S8 — The `/goal` turn budget in the companion goal file is reached with tasks open. +- S9 — Any operation would alter the torch/torchaudio locked matrix (DD-13): a lock + refresh changes a torch/torchaudio version, marker, or partition, or a task seems to + require touching the torch/torchaudio specifiers in `pyproject.toml` or the + `torch`/`torchaudio` `[[package]]` entries or `resolution-markers` in `uv.lock`. + +## 9. Implementation Journal + +Protocol: newest entry **first**. One entry when a task starts (intent + approach), +one when it finalizes (evidence: commands run and their results, deviations from spec, +follow-ups). Entries also required for BLOCKED/DROPPED transitions and stop-condition +hits. Keep entries terse but evidence-bearing. + +Template: + +``` +### YYYY-MM-DD HH:MM — +- What: … +- Evidence: `` → +- Deviations / follow-ups: … +``` + +### 2026-07-08 22:07 — P0-01 done +- What: Added empty `ser/py.typed` and included it in the hatchling wheel manifest. +- Evidence: `rtk uv build` → built `dist/ser-1.0.0-py3-none-any.whl`; + `rtk unzip -l dist/ser-1.0.0-py3-none-any.whl | rtk rg py.typed` → `0 ... + ser/py.typed`; `rtk make lint` → passed; `rtk make type` → passed + (`Success: no issues found in 390 source files`; `0 errors, 0 warnings, 0 + informations`) via verifier-lite `019f449e-1b52-7763-957e-2242f67db12b`. +- Deviations / follow-ups: Cold pyright on this WSL2 box took ~20 minutes; no task + changes needed. + +### 2026-07-08 22:07 — P0-01 started +- What: Ship the PEP 561 marker and include it in the wheel build manifest. +- Evidence: `git status --short --branch` → clean `refactor/public-api-hardening-p0` + worktree before edits. +- Deviations / follow-ups: Working in clean sibling worktree + `../ser-public-api-hardening-p0` to avoid unrelated dirty files in the original + checkout. + +### 2026-07-08 — Plan created +- What: Document authored from a live audit of the surface (facade files, boundary + policy, packaging config, subpackage inventory) plus the PR #70 predecessor spec. + No implementation work has started; all tasks TODO. +- Evidence: audit commands in the authoring session (file reads of `ser/api.py`, + `ser/config.py`, `boundary_policy.toml`, `pyproject.toml`; `find`/`rg` inventories). +- Follow-ups: none. + +## Appendix A — Phase 2 classification (populated by P2-01) + +*(empty until P2-01)* diff --git a/pyproject.toml b/pyproject.toml index 68197f8..9fbf035 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,7 @@ exclude = [ [tool.hatch.build.targets.wheel] packages = ["ser"] -include = ["ser/profile_defs.yaml"] +include = ["ser/profile_defs.yaml", "ser/py.typed"] exclude = [ "ser/.gitignore", "ser/configure", diff --git a/ser/py.typed b/ser/py.typed new file mode 100644 index 0000000..e69de29 From 94bb5cc4531835f1ff1065fad91ba0e4be9c1440 Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Wed, 8 Jul 2026 23:28:54 -0300 Subject: [PATCH 02/10] feat: expose package version --- .local/public-api-surface-improvements.md | 21 ++++++++++++++++++- ser/__init__.py | 9 +++++++- .../architecture/test_api_import_boundary.py | 8 +++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.local/public-api-surface-improvements.md b/.local/public-api-surface-improvements.md index 09cccd5..2a98750 100644 --- a/.local/public-api-surface-improvements.md +++ b/.local/public-api-surface-improvements.md @@ -103,7 +103,7 @@ One task `IN-PROGRESS` at a time. Update this table **and** the journal on every | ID | Task | Status | Depends on | |----|------|--------|------------| | P0-01 | `py.typed` marker shipped in the wheel | DONE | — | -| P0-02 | `ser.__version__` | TODO | — | +| P0-02 | `ser.__version__` | DONE | — | | P0-03 | `ser.api` self-sufficiency (re-export signature vocabulary) | TODO | — | | P0-04 | `DatasetConsents` NamedTuple | TODO | — | | P0-05 | `train()` single extension point | TODO | — | @@ -309,6 +309,25 @@ Template: - Deviations / follow-ups: … ``` +### 2026-07-08 23:13 — P0-02 done +- What: Added `ser.__version__` via `importlib.metadata.version("ser")` with + `PackageNotFoundError` fallback, exported it from root package, and updated the + tier-1 export contract. +- Evidence: verifier-lite `019f44a7-7f9c-7221-a2c9-45f943e29bf2` ran + `rtk uv run --frozen --extra dev python -c "import ser; print(ser.__version__)"` → + `1.0.0`; import-cost check → exit 0 with `torch` absent; boundary test → + `13 passed`; `rtk make lint` → passed. Parent reran `rtk make type` with a long + window → mypy `Success: no issues found in 390 source files`; pyright `0 errors, 0 + warnings, 0 informations`. +- Deviations / follow-ups: verifier-lite's first `make type` timeout was too short for + local pyright; parent rerun completed successfully. + +### 2026-07-08 23:13 — P0-02 started +- What: Expose `ser.__version__` from package metadata without adding heavy imports. +- Evidence: `ser/__init__.py` currently only re-exports domain NamedTuples; tier-1 + export snapshot for `ser` contains no `__version__`. +- Deviations / follow-ups: none. + ### 2026-07-08 22:07 — P0-01 done - What: Added empty `ser/py.typed` and included it in the hatchling wheel manifest. - Evidence: `rtk uv build` → built `dist/ser-1.0.0-py3-none-any.whl`; diff --git a/ser/__init__.py b/ser/__init__.py index c10886b..c767a2c 100644 --- a/ser/__init__.py +++ b/ser/__init__.py @@ -1,5 +1,12 @@ """Speech Emotion Recognition package.""" +from importlib.metadata import PackageNotFoundError, version + from .domain import EmotionSegment, TimelineEntry, TranscriptWord -__all__ = ["TranscriptWord", "EmotionSegment", "TimelineEntry"] +try: + __version__: str = version("ser") +except PackageNotFoundError: + __version__ = "0.0.0.dev0" + +__all__ = ["TranscriptWord", "EmotionSegment", "TimelineEntry", "__version__"] diff --git a/tests/suites/integration/architecture/test_api_import_boundary.py b/tests/suites/integration/architecture/test_api_import_boundary.py index 6a2d02f..76a3fa1 100644 --- a/tests/suites/integration/architecture/test_api_import_boundary.py +++ b/tests/suites/integration/architecture/test_api_import_boundary.py @@ -172,6 +172,7 @@ def test_legacy_api_implementation_modules_are_not_publicly_importable( "EmotionSegment", "TimelineEntry", "TranscriptWord", + "__version__", ), "ser.api": ( "ComplianceMode", @@ -253,3 +254,10 @@ def test_tier_one_public_exports_do_not_grow_without_contract_review( "Shrinking or renaming requires updating this snapshot; growth requires " "explicit API review." ) + + +def test_root_package_exposes_metadata_version() -> None: + """Root package should expose the installed package version.""" + import ser + + assert ser.__version__ == "1.0.0" From 2a8158a9ae8c06ce1d10d199f9ce0310d6590cb1 Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Wed, 8 Jul 2026 23:48:42 -0300 Subject: [PATCH 03/10] feat(api): re-export signature types --- .local/public-api-surface-improvements.md | 22 ++++++++++++++++++- ser/api.py | 16 +++++++++----- tests/suites/integration/api/test_api.py | 16 ++++++++++++++ .../architecture/test_api_import_boundary.py | 8 +++++++ 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/.local/public-api-surface-improvements.md b/.local/public-api-surface-improvements.md index 2a98750..89e8df7 100644 --- a/.local/public-api-surface-improvements.md +++ b/.local/public-api-surface-improvements.md @@ -104,7 +104,7 @@ One task `IN-PROGRESS` at a time. Update this table **and** the journal on every |----|------|--------|------------| | P0-01 | `py.typed` marker shipped in the wheel | DONE | — | | P0-02 | `ser.__version__` | DONE | — | -| P0-03 | `ser.api` self-sufficiency (re-export signature vocabulary) | TODO | — | +| P0-03 | `ser.api` self-sufficiency (re-export signature vocabulary) | DONE | — | | P0-04 | `DatasetConsents` NamedTuple | TODO | — | | P0-05 | `train()` single extension point | TODO | — | @@ -309,6 +309,26 @@ Template: - Deviations / follow-ups: … ``` +### 2026-07-08 23:31 — P0-03 done +- What: Runtime-exported `InferenceRequest`, `InferenceExecution`, `SubtitleFormat`, + `DiagnosticReport`, `DiagnosticFinding`, `DiagnosticSeverity`, `AppConfig`, and + `ProfileName` from `ser.api`; updated API and tier-1 export snapshots. +- Evidence: verifier-lite `019f44b6-e188-7bb0-97f6-1ced57ed2667` import-cost command + importing the P0-03 symbols from `ser.api` → exit 0 with `torch` absent; targeted API + + boundary tests → `52 passed`; `rtk make lint` → passed. Parent reran `rtk make + type` with a long window → mypy `Success: no issues found in 390 source files`; + pyright `0 errors, 0 warnings, 0 informations`. +- Deviations / follow-ups: verifier-lite again used too-short `make type` wait; parent + long-window rerun completed successfully. + +### 2026-07-08 23:31 — P0-03 started +- What: Make `ser.api` runtime-export every type used by its own public signatures. +- Evidence: `ser.api` currently imports `InferenceRequest`, `InferenceExecution`, + `SubtitleFormat`, and `DiagnosticReport` only under `TYPE_CHECKING`; `AppConfig` and + `ProfileName` are imported but absent from `__all__`. +- Deviations / follow-ups: `from ser.runtime.contracts ...` and diagnostics domain + imports kept `torch` absent in a subprocess, so no contract relocation needed. + ### 2026-07-08 23:13 — P0-02 done - What: Added `ser.__version__` via `importlib.metadata.version("ser")` with `PackageNotFoundError` fallback, exported it from root package, and updated the diff --git a/ser/api.py b/ser/api.py index f135696..af51e8d 100644 --- a/ser/api.py +++ b/ser/api.py @@ -4,17 +4,15 @@ from collections.abc import Callable from pathlib import Path -from typing import TYPE_CHECKING, Protocol +from typing import Protocol import ser._internal.api.data as _data_api import ser._internal.api.diagnostics as _diagnostics_api import ser._internal.api.runtime as _runtime_api from ser.config import AppConfig, reload_settings +from ser.diagnostics.domain import DiagnosticFinding, DiagnosticReport, DiagnosticSeverity from ser.profiles import ProfileName - -if TYPE_CHECKING: - from ser.diagnostics.domain import DiagnosticReport - from ser.runtime.contracts import InferenceExecution, InferenceRequest, SubtitleFormat +from ser.runtime.contracts import InferenceExecution, InferenceRequest, SubtitleFormat ComplianceMode = _data_api.ComplianceMode DatasetPrepareResult = _data_api.DatasetPrepareResult @@ -193,12 +191,20 @@ def run_startup_preflight( __all__ = [ + "AppConfig", "ComplianceMode", "DatasetPrepareResult", "DatasetRegistryHealthIssueRecord", "DatasetRegistryRecord", + "DiagnosticFinding", + "DiagnosticReport", + "DiagnosticSeverity", + "InferenceExecution", + "InferenceRequest", + "ProfileName", "RuntimePipeline", "RuntimePipelineBuilder", + "SubtitleFormat", "configure_dataset_consents", "infer", "list_dataset_registry_health_issues", diff --git a/tests/suites/integration/api/test_api.py b/tests/suites/integration/api/test_api.py index 49d59e6..b35a0b5 100644 --- a/tests/suites/integration/api/test_api.py +++ b/tests/suites/integration/api/test_api.py @@ -1094,10 +1094,17 @@ def test_api_public_surface_includes_user_oriented_entrypoints() -> None: """Stable API contract should expose user-oriented library entrypoints only.""" exported = set(api.__all__) required = { + "AppConfig", "ComplianceMode", "DatasetPrepareResult", "DatasetRegistryHealthIssueRecord", "DatasetRegistryRecord", + "DiagnosticFinding", + "DiagnosticReport", + "DiagnosticSeverity", + "InferenceExecution", + "InferenceRequest", + "ProfileName", "configure_dataset_consents", "infer", "train", @@ -1109,6 +1116,7 @@ def test_api_public_surface_includes_user_oriented_entrypoints() -> None: "list_registered_datasets", "list_dataset_registry_health_issues", "show_dataset_consents", + "SubtitleFormat", } assert required.issubset(exported) @@ -1116,12 +1124,20 @@ def test_api_public_surface_includes_user_oriented_entrypoints() -> None: def test_api_public_surface_snapshot_matches_expected_contract() -> None: """Public API export snapshot should only change via explicit contract updates.""" assert api.__all__ == [ + "AppConfig", "ComplianceMode", "DatasetPrepareResult", "DatasetRegistryHealthIssueRecord", "DatasetRegistryRecord", + "DiagnosticFinding", + "DiagnosticReport", + "DiagnosticSeverity", + "InferenceExecution", + "InferenceRequest", + "ProfileName", "RuntimePipeline", "RuntimePipelineBuilder", + "SubtitleFormat", "configure_dataset_consents", "infer", "list_dataset_registry_health_issues", diff --git a/tests/suites/integration/architecture/test_api_import_boundary.py b/tests/suites/integration/architecture/test_api_import_boundary.py index 76a3fa1..81b6a8a 100644 --- a/tests/suites/integration/architecture/test_api_import_boundary.py +++ b/tests/suites/integration/architecture/test_api_import_boundary.py @@ -175,12 +175,20 @@ def test_legacy_api_implementation_modules_are_not_publicly_importable( "__version__", ), "ser.api": ( + "AppConfig", "ComplianceMode", "DatasetPrepareResult", "DatasetRegistryHealthIssueRecord", "DatasetRegistryRecord", + "DiagnosticFinding", + "DiagnosticReport", + "DiagnosticSeverity", + "InferenceExecution", + "InferenceRequest", + "ProfileName", "RuntimePipeline", "RuntimePipelineBuilder", + "SubtitleFormat", "configure_dataset_consents", "infer", "list_dataset_registry_health_issues", From cbcc3b5afe1f9780e3f43329e7c51628a9d6f737 Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 9 Jul 2026 00:29:29 -0300 Subject: [PATCH 04/10] feat(api): name dataset consent tuple --- .local/public-api-surface-improvements.md | 20 ++++++++++++++++++- ser/_internal/api/data.py | 17 ++++++++-------- ser/api.py | 6 ++++-- ser/domain.py | 9 ++++++++- tests/suites/integration/api/test_api.py | 7 ++++++- .../architecture/test_api_import_boundary.py | 2 ++ 6 files changed, 48 insertions(+), 13 deletions(-) diff --git a/.local/public-api-surface-improvements.md b/.local/public-api-surface-improvements.md index 89e8df7..5cd25fa 100644 --- a/.local/public-api-surface-improvements.md +++ b/.local/public-api-surface-improvements.md @@ -105,7 +105,7 @@ One task `IN-PROGRESS` at a time. Update this table **and** the journal on every | P0-01 | `py.typed` marker shipped in the wheel | DONE | — | | P0-02 | `ser.__version__` | DONE | — | | P0-03 | `ser.api` self-sufficiency (re-export signature vocabulary) | DONE | — | -| P0-04 | `DatasetConsents` NamedTuple | TODO | — | +| P0-04 | `DatasetConsents` NamedTuple | DONE | — | | P0-05 | `train()` single extension point | TODO | — | **P0-01** — Create empty `ser/py.typed`. Add it to the hatchling build include in @@ -309,6 +309,24 @@ Template: - Deviations / follow-ups: … ``` +### 2026-07-09 00:28 — P0-04 done +- What: Added `DatasetConsents(NamedTuple)`, returned it from consent APIs, exported it + from `ser.domain` and `ser.api`, and added field-access regression assertions. +- Evidence: import/tuple-compatibility check for `ser.api.DatasetConsents` → exit 0 + with `torch` absent; targeted API/boundary/dataset-consent tests → `56 passed`; first + targeted run failed only on `ser.api.__all__` ordering and passed after reorder; + `rtk make check` → lint passed, mypy `Success: no issues found in 390 source files`, + pyright `0 errors, 0 warnings, 0 informations`, pytest `1025 passed`. +- Deviations / follow-ups: verifier-lite unavailable due account usage limit; parent + ran checks directly per stop-free fallback. + +### 2026-07-08 23:47 — P0-04 started +- What: Add `DatasetConsents` tuple-compatible named return type and export it through + `ser.domain` and `ser.api`. +- Evidence: consent APIs currently return anonymous `tuple[tuple[str, ...], + tuple[str, ...]]`; existing API test only proves unpacking, not field access. +- Deviations / follow-ups: none. + ### 2026-07-08 23:31 — P0-03 done - What: Runtime-exported `InferenceRequest`, `InferenceExecution`, `SubtitleFormat`, `DiagnosticReport`, `DiagnosticFinding`, `DiagnosticSeverity`, `AppConfig`, and diff --git a/ser/_internal/api/data.py b/ser/_internal/api/data.py index 46efba1..8a12e24 100644 --- a/ser/_internal/api/data.py +++ b/ser/_internal/api/data.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Literal from ser.config import AppConfig +from ser.domain import DatasetConsents from ser.utils.logger import get_logger logger = get_logger(__name__) @@ -117,14 +118,14 @@ def list_dataset_registry_health_issues( def show_dataset_consents( *, settings: AppConfig, -) -> tuple[tuple[str, ...], tuple[str, ...]]: +) -> DatasetConsents: """Returns persisted dataset consent IDs as `(policy_ids, license_ids)`.""" from ser.data.dataset_consents import load_persisted_dataset_consents consents = load_persisted_dataset_consents(settings=settings) - return ( - tuple(sorted(consents.policy_consents)), - tuple(sorted(consents.license_consents)), + return DatasetConsents( + policy_ids=tuple(sorted(consents.policy_consents)), + license_ids=tuple(sorted(consents.license_consents)), ) @@ -134,7 +135,7 @@ def configure_dataset_consents( accept_license_ids: tuple[str, ...] = (), settings: AppConfig, source: str = "ser.api.configure_dataset_consents", -) -> tuple[tuple[str, ...], tuple[str, ...]]: +) -> DatasetConsents: """Persists dataset consents and returns updated `(policy_ids, license_ids)`.""" from ser.data.dataset_consents import ( load_persisted_dataset_consents, @@ -148,9 +149,9 @@ def configure_dataset_consents( source=source, ) updated = load_persisted_dataset_consents(settings=settings) - return ( - tuple(sorted(updated.policy_consents)), - tuple(sorted(updated.license_consents)), + return DatasetConsents( + policy_ids=tuple(sorted(updated.policy_consents)), + license_ids=tuple(sorted(updated.license_consents)), ) diff --git a/ser/api.py b/ser/api.py index af51e8d..b523242 100644 --- a/ser/api.py +++ b/ser/api.py @@ -11,6 +11,7 @@ import ser._internal.api.runtime as _runtime_api from ser.config import AppConfig, reload_settings from ser.diagnostics.domain import DiagnosticFinding, DiagnosticReport, DiagnosticSeverity +from ser.domain import DatasetConsents from ser.profiles import ProfileName from ser.runtime.contracts import InferenceExecution, InferenceRequest, SubtitleFormat @@ -66,7 +67,7 @@ def list_dataset_registry_health_issues( def show_dataset_consents( *, settings: AppConfig | None = None, -) -> tuple[tuple[str, ...], tuple[str, ...]]: +) -> DatasetConsents: """Returns persisted dataset consents using the active settings snapshot.""" return _data_api.show_dataset_consents(settings=_resolve_boundary_settings(settings)) @@ -77,7 +78,7 @@ def configure_dataset_consents( accept_license_ids: tuple[str, ...] = (), settings: AppConfig | None = None, source: str = "ser.api.configure_dataset_consents", -) -> tuple[tuple[str, ...], tuple[str, ...]]: +) -> DatasetConsents: """Persists dataset consents using the active settings snapshot.""" return _data_api.configure_dataset_consents( accept_policy_ids=accept_policy_ids, @@ -193,6 +194,7 @@ def run_startup_preflight( __all__ = [ "AppConfig", "ComplianceMode", + "DatasetConsents", "DatasetPrepareResult", "DatasetRegistryHealthIssueRecord", "DatasetRegistryRecord", diff --git a/ser/domain.py b/ser/domain.py index b27939a..a498dbf 100644 --- a/ser/domain.py +++ b/ser/domain.py @@ -2,7 +2,14 @@ from typing import NamedTuple -__all__ = ["EmotionSegment", "TimelineEntry", "TranscriptWord"] +__all__ = ["DatasetConsents", "EmotionSegment", "TimelineEntry", "TranscriptWord"] + + +class DatasetConsents(NamedTuple): + """Persisted dataset policy and license consent identifiers.""" + + policy_ids: tuple[str, ...] + license_ids: tuple[str, ...] class TranscriptWord(NamedTuple): diff --git a/tests/suites/integration/api/test_api.py b/tests/suites/integration/api/test_api.py index b35a0b5..5193db7 100644 --- a/tests/suites/integration/api/test_api.py +++ b/tests/suites/integration/api/test_api.py @@ -480,9 +480,12 @@ def _run_dataset_prepare_workflow(**kwargs: object) -> object: assert result.manifest_paths == (manifest_path,) assert result.missing_policy_consents == () assert result.missing_license_consents == () - policies, licenses = api.show_dataset_consents(settings=settings) + consents = api.show_dataset_consents(settings=settings) + policies, licenses = consents assert "academic_only" in policies assert "msp-academic-license" in licenses + assert consents.policy_ids == policies + assert consents.license_ids == licenses kwargs = captured["kwargs"] assert isinstance(kwargs, dict) assert kwargs["dataset_id"] == "msp-podcast" @@ -1096,6 +1099,7 @@ def test_api_public_surface_includes_user_oriented_entrypoints() -> None: required = { "AppConfig", "ComplianceMode", + "DatasetConsents", "DatasetPrepareResult", "DatasetRegistryHealthIssueRecord", "DatasetRegistryRecord", @@ -1126,6 +1130,7 @@ def test_api_public_surface_snapshot_matches_expected_contract() -> None: assert api.__all__ == [ "AppConfig", "ComplianceMode", + "DatasetConsents", "DatasetPrepareResult", "DatasetRegistryHealthIssueRecord", "DatasetRegistryRecord", diff --git a/tests/suites/integration/architecture/test_api_import_boundary.py b/tests/suites/integration/architecture/test_api_import_boundary.py index 81b6a8a..92604be 100644 --- a/tests/suites/integration/architecture/test_api_import_boundary.py +++ b/tests/suites/integration/architecture/test_api_import_boundary.py @@ -177,6 +177,7 @@ def test_legacy_api_implementation_modules_are_not_publicly_importable( "ser.api": ( "AppConfig", "ComplianceMode", + "DatasetConsents", "DatasetPrepareResult", "DatasetRegistryHealthIssueRecord", "DatasetRegistryRecord", @@ -231,6 +232,7 @@ def test_legacy_api_implementation_modules_are_not_publicly_importable( "settings_override", ), "ser.domain": ( + "DatasetConsents", "EmotionSegment", "TimelineEntry", "TranscriptWord", From 2e4ee57115641c4dd56641d0e41643392aaa95c3 Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 9 Jul 2026 03:09:41 -0300 Subject: [PATCH 05/10] refactor(api): make pipeline_builder the sole training override --- .local/public-api-surface-improvements.md | 27 +++++++++++++- ser/__main__.py | 11 ++---- ser/_internal/api/runtime.py | 20 +++-------- ser/_internal/runtime/commands.py | 2 -- ser/_internal/runtime/restricted_backends.py | 24 ++++++------- ser/api.py | 2 -- tests/suites/integration/api/test_api.py | 38 +++++++------------- tests/suites/integration/cli/test_cli.py | 2 +- 8 files changed, 60 insertions(+), 66 deletions(-) diff --git a/.local/public-api-surface-improvements.md b/.local/public-api-surface-improvements.md index 5cd25fa..9812d52 100644 --- a/.local/public-api-surface-improvements.md +++ b/.local/public-api-surface-improvements.md @@ -106,7 +106,7 @@ One task `IN-PROGRESS` at a time. Update this table **and** the journal on every | P0-02 | `ser.__version__` | DONE | — | | P0-03 | `ser.api` self-sufficiency (re-export signature vocabulary) | DONE | — | | P0-04 | `DatasetConsents` NamedTuple | DONE | — | -| P0-05 | `train()` single extension point | TODO | — | +| P0-05 | `train()` single extension point | DONE | — | **P0-01** — Create empty `ser/py.typed`. Add it to the hatchling build include in `pyproject.toml` (next to the existing `ser/profile_defs.yaml` include). @@ -309,6 +309,23 @@ Template: - Deviations / follow-ups: … ``` +### 2026-07-09 03:10 — P0-05 done +- What: Removed `use_profile_pipeline` from `ser.api.train`, the internal train/ + training-command chain, the CLI, and the restricted-backend gate plumbing; + `pipeline_builder` is the sole training override. Gate-side booleans renamed to + `profile_resolution_enabled`/`profile_routing_enabled` with identical value flow + (CLI still feeds `profile_pipeline_enabled(settings)`), so no behavior change. +- Evidence: `rg -n "use_profile_pipeline" ser tests` → no matches (exit 1); + `uv run --frozen --extra dev python -c "import ser; print(ser.__version__)"` → + `1.0.0`; import-cost one-liner importing all P0-03/P0-04 symbols from `ser.api` + → exit 0 with `torch` absent; `uv build` → wheel built, `unzip -l` shows + `ser/py.typed`; verifier-lite gate sweep → `make lint` (all checks passed), + `make type` (mypy 0 issues / pyright 0 errors), `make test-cov` + (`1025 passed in 181.76s`, coverage gate held), `make import-lint` + (16 passed), `make lock-check` (lock verified) — all exit 0. +- Deviations / follow-ups: none; NG6 respected (CLI flags untouched, only internal + call plumbing updated). + ### 2026-07-09 00:28 — P0-04 done - What: Added `DatasetConsents(NamedTuple)`, returned it from consent APIs, exported it from `ser.domain` and `ser.api`, and added field-access regression assertions. @@ -320,6 +337,14 @@ Template: - Deviations / follow-ups: verifier-lite unavailable due account usage limit; parent ran checks directly per stop-free fallback. +### 2026-07-09 00:34 — P0-05 started +- What: Remove the `train()` profile-pipeline escape hatch and make + `pipeline_builder` the only training override. +- Evidence: `rtk rg -n "use_profile_pipeline" ser tests` showed public API, CLI, + restricted-backend gate, command-wrapper, and tests still carrying the old flag. +- Deviations / follow-ups: no runtime behavior change intended; restricted-backend + profile-routing flag is renamed, not semantically changed. + ### 2026-07-08 23:47 — P0-04 started - What: Add `DatasetConsents` tuple-compatible named return type and export it through `ser.domain` and `ser.api`. diff --git a/ser/__main__.py b/ser/__main__.py index 862be2b..7b9a254 100644 --- a/ser/__main__.py +++ b/ser/__main__.py @@ -252,7 +252,7 @@ def _run_restricted_backend_gate( """Runs restricted-backend CLI gating and returns an optional exit code.""" restricted_logs, restricted_exit_code = run_restricted_backend_cli_gate( settings=cast(AppConfig, active_settings), - use_profile_pipeline=profile_pipeline_enabled(cast(AppConfig, active_settings)), + profile_resolution_enabled=profile_pipeline_enabled(cast(AppConfig, active_settings)), train_requested=bool(args.train), file_path=_command_file_path(args.file), accept_restricted_backends=bool(args.accept_restricted_backends), @@ -320,13 +320,12 @@ def _run_calibration_or_exit(args: argparse.Namespace) -> None: sys.exit(0) -def _run_training_or_exit(*, active_settings: object, use_profile_pipeline: bool) -> None: +def _run_training_or_exit(*, active_settings: object) -> None: """Runs training flow and exits with the appropriate status code.""" logger.info("Starting model training...") start_time = time.perf_counter() disposition = run_training_command( settings=cast(AppConfig, active_settings), - use_profile_pipeline=use_profile_pipeline, pipeline_builder=build_runtime_pipeline, ) if disposition is not None: @@ -423,12 +422,8 @@ def main() -> None: if args.calibrate_transcription_runtime: _run_calibration_or_exit(args) - use_profile_pipeline = profile_pipeline_enabled(active_settings) if args.train: - _run_training_or_exit( - active_settings=active_settings, - use_profile_pipeline=use_profile_pipeline, - ) + _run_training_or_exit(active_settings=active_settings) _run_inference_or_exit(args, active_settings=active_settings) diff --git a/ser/_internal/api/runtime.py b/ser/_internal/api/runtime.py index 7b5e424..aa2fce1 100644 --- a/ser/_internal/api/runtime.py +++ b/ser/_internal/api/runtime.py @@ -187,11 +187,11 @@ def profile_pipeline_enabled(settings: AppConfig) -> bool: def profile_resolution_requested( *, - use_profile_pipeline: bool, + profile_routing_enabled: bool, file_path: str | None, ) -> bool: """Returns whether profile resolution should run for this invocation.""" - return bool(use_profile_pipeline or file_path) + return bool(profile_routing_enabled or file_path) def resolve_cli_workflow_profile(settings: AppConfig) -> ProfileName: @@ -261,15 +261,9 @@ def load_profile( def run_training_workflow( *, settings: AppConfig, - use_profile_pipeline: bool, pipeline_builder: _RuntimePipelineBuilder | None = None, ) -> None: - """Runs CLI-equivalent training workflow through the runtime pipeline. - - The ``use_profile_pipeline`` parameter is retained for compatibility with older - callers, but training now uses the pipeline for every profile so orchestration - remains single-owned. - """ + """Runs CLI-equivalent training workflow through the runtime pipeline.""" builder = pipeline_builder if pipeline_builder is not None else _build_runtime_pipeline builder(settings).run_training() @@ -278,14 +272,12 @@ def train( *, profile: ProfileName | None = None, settings: AppConfig, - use_profile_pipeline: bool = True, pipeline_builder: _RuntimePipelineBuilder | None = None, ) -> None: """Runs training for the selected profile through the runtime pipeline.""" scoped_settings = _settings_for_profile(settings, profile=profile) run_training_workflow( settings=scoped_settings, - use_profile_pipeline=use_profile_pipeline, pipeline_builder=pipeline_builder, ) @@ -350,7 +342,7 @@ def infer( def run_restricted_backend_cli_gate( *, settings: AppConfig, - use_profile_pipeline: bool, + profile_resolution_enabled: bool, train_requested: bool, file_path: str | None, accept_restricted_backends: bool, @@ -360,7 +352,7 @@ def run_restricted_backend_cli_gate( """Evaluates restricted-backend CLI gate and returns logs plus optional exit code.""" return _run_restricted_backend_cli_gate( settings=settings, - use_profile_pipeline=use_profile_pipeline, + profile_resolution_enabled=profile_resolution_enabled, train_requested=train_requested, file_path=file_path, accept_restricted_backends=accept_restricted_backends, @@ -374,13 +366,11 @@ def run_restricted_backend_cli_gate( def run_training_command( *, settings: AppConfig, - use_profile_pipeline: bool, pipeline_builder: _RuntimePipelineBuilder | None = None, ) -> WorkflowErrorDisposition | None: """Runs training command and returns one exit disposition on failure.""" return _run_training_command( settings=settings, - use_profile_pipeline=use_profile_pipeline, pipeline_builder=pipeline_builder, run_training_workflow=run_training_workflow, classify_training_error=classify_training_exception, diff --git a/ser/_internal/runtime/commands.py b/ser/_internal/runtime/commands.py index 1dc9adb..66b9aa8 100644 --- a/ser/_internal/runtime/commands.py +++ b/ser/_internal/runtime/commands.py @@ -120,7 +120,6 @@ def classify_inference_exception(err: Exception) -> WorkflowErrorDisposition: def run_training_command( *, settings: AppConfig, - use_profile_pipeline: bool, pipeline_builder: object | None, run_training_workflow: _TrainingWorkflow, classify_training_error: _TrainingErrorClassifier, @@ -129,7 +128,6 @@ def run_training_command( try: run_training_workflow( settings=settings, - use_profile_pipeline=use_profile_pipeline, pipeline_builder=pipeline_builder, ) except Exception as err: diff --git a/ser/_internal/runtime/restricted_backends.py b/ser/_internal/runtime/restricted_backends.py index 0735608..5524217 100644 --- a/ser/_internal/runtime/restricted_backends.py +++ b/ser/_internal/runtime/restricted_backends.py @@ -41,20 +41,20 @@ class RestrictedBackendOptInState: def _profile_resolution_requested( *, - use_profile_pipeline: bool, + profile_routing_enabled: bool, file_path: str | None, ) -> bool: """Returns whether profile resolution should run for this invocation.""" - return bool(use_profile_pipeline or file_path) + return bool(profile_routing_enabled or file_path) def required_restricted_backends_for_current_profile( settings: AppConfig, *, - use_profile_pipeline: bool, + profile_resolution_enabled: bool, ) -> tuple[str, ...]: """Returns restricted backend ids required by the active runtime profile.""" - if not use_profile_pipeline: + if not profile_resolution_enabled: return () profile_name = resolve_profile_name(settings) backend_id = get_profile_catalog()[profile_name].backend_id @@ -67,13 +67,13 @@ def required_restricted_backends_for_current_profile( def persist_required_restricted_backends( settings: AppConfig, *, - use_profile_pipeline: bool, + profile_resolution_enabled: bool, consent_source: str, ) -> tuple[str, ...]: """Persists consent for restricted backends required by the active profile.""" required_backends = required_restricted_backends_for_current_profile( settings, - use_profile_pipeline=use_profile_pipeline, + profile_resolution_enabled=profile_resolution_enabled, ) persisted: list[str] = [] for backend_id in required_backends: @@ -102,7 +102,7 @@ def persist_all_restricted_backend_consents( def prepare_restricted_backend_opt_in_state( *, settings: AppConfig, - use_profile_pipeline: bool, + profile_routing_enabled: bool, train_requested: bool, file_path: str | None, accept_restricted_backends: bool, @@ -112,12 +112,12 @@ def prepare_restricted_backend_opt_in_state( ) -> RestrictedBackendOptInState: """Prepares restricted-backend state transitions for one CLI invocation.""" profile_resolution_enabled = _profile_resolution_requested( - use_profile_pipeline=use_profile_pipeline, + profile_routing_enabled=profile_routing_enabled, file_path=file_path, ) required_backend_ids = required_restricted_backends_for_current_profile( settings, - use_profile_pipeline=profile_resolution_enabled, + profile_resolution_enabled=profile_resolution_enabled, ) persisted_all_count = 0 persisted_profile_backend_ids: tuple[str, ...] = () @@ -129,7 +129,7 @@ def prepare_restricted_backend_opt_in_state( if accept_restricted_backends: persisted_profile_backend_ids = persist_required_restricted_backends( settings, - use_profile_pipeline=profile_resolution_enabled, + profile_resolution_enabled=profile_resolution_enabled, consent_source=profile_consent_source, ) should_exit_zero = (accept_restricted_backends or accept_all_restricted_backends) and ( @@ -170,7 +170,7 @@ def enforce_restricted_backends_for_cli( def run_restricted_backend_cli_gate( *, settings: AppConfig, - use_profile_pipeline: bool, + profile_resolution_enabled: bool, train_requested: bool, file_path: str | None, accept_restricted_backends: bool, @@ -182,7 +182,7 @@ def run_restricted_backend_cli_gate( """Evaluates restricted-backend CLI gate and returns logs plus optional exit code.""" opt_in_state = prepare_opt_in_state( settings=settings, - use_profile_pipeline=use_profile_pipeline, + profile_routing_enabled=profile_resolution_enabled, train_requested=train_requested, file_path=file_path, accept_restricted_backends=accept_restricted_backends, diff --git a/ser/api.py b/ser/api.py index b523242..3ee46d8 100644 --- a/ser/api.py +++ b/ser/api.py @@ -141,14 +141,12 @@ def train( *, profile: ProfileName | None = None, settings: AppConfig | None = None, - use_profile_pipeline: bool = True, pipeline_builder: RuntimePipelineBuilder | None = None, ) -> None: """Runs training using the active settings snapshot via the runtime pipeline.""" return _runtime_api.train( profile=profile, settings=_resolve_boundary_settings(settings), - use_profile_pipeline=use_profile_pipeline, pipeline_builder=pipeline_builder, ) diff --git a/tests/suites/integration/api/test_api.py b/tests/suites/integration/api/test_api.py index 5193db7..2522b0a 100644 --- a/tests/suites/integration/api/test_api.py +++ b/tests/suites/integration/api/test_api.py @@ -190,7 +190,6 @@ def test_run_training_command_maps_training_exceptions_to_disposition( disposition = api_runtime_module.run_training_command( settings=config_module.reload_settings(), - use_profile_pipeline=True, ) assert disposition is not None @@ -224,14 +223,12 @@ def _run_training_workflow(**kwargs: object) -> None: disposition = api_runtime_module.run_training_command( settings=settings, - use_profile_pipeline=False, pipeline_builder=_pipeline_builder, ) assert disposition is None kwargs = cast(dict[str, object], captured["kwargs"]) assert kwargs["settings"] is settings - assert kwargs["use_profile_pipeline"] is False assert kwargs["pipeline_builder"] is _pipeline_builder @@ -332,7 +329,7 @@ def test_run_restricted_backend_cli_gate_short_circuits_without_command_path() - logs, exit_code = api_runtime_module.run_restricted_backend_cli_gate( settings=settings, - use_profile_pipeline=False, + profile_resolution_enabled=False, train_requested=False, file_path=None, accept_restricted_backends=False, @@ -367,7 +364,7 @@ def test_run_restricted_backend_cli_gate_short_circuits_opt_in_only_invocation( logs, exit_code = api_runtime_module.run_restricted_backend_cli_gate( settings=settings, - use_profile_pipeline=True, + profile_resolution_enabled=True, train_requested=False, file_path=None, accept_restricted_backends=True, @@ -406,7 +403,7 @@ def test_run_restricted_backend_cli_gate_maps_policy_errors_to_exit_2( logs, exit_code = api_runtime_module.run_restricted_backend_cli_gate( settings=settings, - use_profile_pipeline=True, + profile_resolution_enabled=True, train_requested=True, file_path=None, accept_restricted_backends=False, @@ -661,10 +658,10 @@ def test_list_dataset_registry_health_issues_exposes_issue_records( assert issue.message == "Mismatch." -def test_train_uses_pipeline_builder_when_enabled( +def test_train_uses_custom_pipeline_builder( tmp_path: Path, ) -> None: - """Stable training API should use injected pipeline builder when enabled.""" + """Stable training API should use the injected pipeline builder.""" settings = _settings(tmp_path) calls: dict[str, bool] = {"training": False} @@ -678,17 +675,16 @@ def run_inference(self, request: InferenceRequest) -> InferenceExecution: api.train( settings=settings, - use_profile_pipeline=True, pipeline_builder=lambda _settings: _FakePipeline(), ) assert calls["training"] is True -def test_train_uses_pipeline_builder_when_disabled( +def test_run_training_workflow_uses_default_pipeline_builder( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Stable training API should keep routing through the pipeline when disabled.""" + """Training workflow should use the default pipeline builder when none is injected.""" settings = config_module.reload_settings() captured: dict[str, object] = {"training": False} @@ -700,21 +696,14 @@ def run_inference(self, request: InferenceRequest) -> InferenceExecution: del request raise AssertionError("unreachable") - def _pipeline_builder(received_settings: AppConfig) -> _FakePipeline: + def _build_runtime_pipeline(received_settings: AppConfig) -> _FakePipeline: captured["settings"] = received_settings return _FakePipeline() - monkeypatch.setattr( - "ser.models.emotion_model.train_model", - lambda: (_ for _ in ()).throw( - AssertionError("Legacy training branch should remain unreachable.") - ), - ) + monkeypatch.setattr(api_runtime_module, "_build_runtime_pipeline", _build_runtime_pipeline) - api.train( + api_runtime_module.run_training_workflow( settings=settings, - use_profile_pipeline=False, - pipeline_builder=_pipeline_builder, ) assert captured["settings"] is settings @@ -741,7 +730,6 @@ def _pipeline_builder(received_settings: AppConfig) -> _FakePipeline: api.train( settings=scoped_settings, - use_profile_pipeline=False, pipeline_builder=_pipeline_builder, ) @@ -831,7 +819,7 @@ def test_required_restricted_backends_for_profile_returns_research_backend() -> required = restricted_backends_module.required_restricted_backends_for_current_profile( scoped, - use_profile_pipeline=True, + profile_resolution_enabled=True, ) assert required == ("emotion2vec",) @@ -906,14 +894,14 @@ def test_runtime_profile_helpers_cover_pipeline_and_resolution_paths() -> None: assert api_runtime_module.profile_pipeline_enabled(pipeline_settings) is True assert ( api_runtime_module.profile_resolution_requested( - use_profile_pipeline=False, + profile_routing_enabled=False, file_path=None, ) is False ) assert ( api_runtime_module.profile_resolution_requested( - use_profile_pipeline=False, + profile_routing_enabled=False, file_path="sample.wav", ) is True diff --git a/tests/suites/integration/cli/test_cli.py b/tests/suites/integration/cli/test_cli.py index 3cf392b..1f27e97 100644 --- a/tests/suites/integration/cli/test_cli.py +++ b/tests/suites/integration/cli/test_cli.py @@ -1034,7 +1034,7 @@ def test_cli_accept_restricted_backends_delegates_to_api_runtime_helpers( assert exc_info.value.code == 0 kwargs = cast(dict[str, object], captured["kwargs"]) - assert kwargs["use_profile_pipeline"] is True + assert kwargs["profile_resolution_enabled"] is True assert kwargs["train_requested"] is False assert kwargs["file_path"] is None assert kwargs["accept_restricted_backends"] is True From b27671c24416db947f988b857ee53c5c74addbc0 Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 9 Jul 2026 20:30:44 -0300 Subject: [PATCH 06/10] test(api): snapshot public surface --- .local/public-api-surface-improvements.md | 27 +- pyproject.toml | 1 + scripts/dump_public_api.py | 225 ++++ .../architecture/public_api_snapshot.json | 1066 +++++++++++++++++ .../architecture/test_public_api_snapshot.py | 48 + uv.lock | 14 + 6 files changed, 1380 insertions(+), 1 deletion(-) create mode 100644 scripts/dump_public_api.py create mode 100644 tests/suites/integration/architecture/public_api_snapshot.json create mode 100644 tests/suites/integration/architecture/test_public_api_snapshot.py diff --git a/.local/public-api-surface-improvements.md b/.local/public-api-surface-improvements.md index 9812d52..d149d49 100644 --- a/.local/public-api-surface-improvements.md +++ b/.local/public-api-surface-improvements.md @@ -144,7 +144,7 @@ all callers (CLI, tests). | ID | Task | Status | Depends on | |----|------|--------|------------| -| P1-01 | Public-API snapshot + drift contract test | TODO | P0-03, P0-04, P0-05 | +| P1-01 | Public-API snapshot + drift contract test | DONE | P0-03, P0-04, P0-05 | | P1-02 | pyright `--verifytypes` ratchet gate | TODO | P0-01 | | P1-03 | Import-cost contract test | TODO | P0-03 | | P1-04 | ruff TID251 boundary lint | TODO | — | @@ -309,6 +309,31 @@ Template: - Deviations / follow-ups: … ``` +### 2026-07-09 20:29 — P1-01 done +- What: Added `griffe` to the dev extra, `scripts/dump_public_api.py`, the tier-1 + JSON snapshot, and a contract test that diffs current griffe output against the + checked-in snapshot. +- Evidence: `uv run --frozen --extra dev python scripts/dump_public_api.py --write` + → generated `tests/suites/integration/architecture/public_api_snapshot.json`; + `uv run --frozen --extra dev pytest -q tests/suites/integration/architecture/test_public_api_snapshot.py` + → `1 passed`; synthetic `"SyntheticExport"` in `ser.api.__all__` made that test + fail with `ValueError: ser.api.__all__ exports missing member 'SyntheticExport'`, + then the synthetic edit was reverted; `make lock-check && make check` → lock + fresh, lint/type/test all pass, `1026 passed in 119.60s`; `uv run --frozen --extra + dev black --check scripts/dump_public_api.py` → unchanged; scoped + `git diff` searches for `torch`, `torchaudio`, and `resolution-markers` in + `uv.lock`/`pyproject.toml` → no matches. +- Deviations / follow-ups: Baseline snapshot includes `ser.profiles` public + definitions because that tier-1 module has no `__all__`; explicit exports can be + added in a later public-surface cleanup if desired. + +### 2026-07-09 20:17 — P1-01 started +- What: Add the griffe-backed tier-1 public API snapshot script, checked-in JSON + snapshot, and architecture drift contract test. +- Evidence: Current branch rebuilt from `main` plus P0 commits; `git status` clean + before this task. +- Deviations / follow-ups: none. + ### 2026-07-09 03:10 — P0-05 done - What: Removed `use_profile_pipeline` from `ser.api.train`, the internal train/ training-command chain, the CLI, and the restricted-backend gate plumbing; diff --git a/pyproject.toml b/pyproject.toml index 9fbf035..344374c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dev = [ "pre-commit>=4.0.0,<5.0.0", "pytest>=9.1.1,<10.0.0", "coverage[toml]>=7.6.0,<8.0.0", + "griffe>=1.14.0,<2.0.0", "hypothesis>=6.130.0,<7.0.0", "ruff>=0.9.0,<1.0.0", "black>=26.3.1,<27.0.0", diff --git a/scripts/dump_public_api.py b/scripts/dump_public_api.py new file mode 100644 index 0000000..83155f7 --- /dev/null +++ b/scripts/dump_public_api.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Dump the reviewed tier-1 public API surface as stable JSON.""" + +from __future__ import annotations + +import argparse +import ast +import json +from pathlib import Path +from typing import Any + +import griffe + +SCHEMA_VERSION = 1 +SNAPSHOT_PATH = Path("tests/suites/integration/architecture/public_api_snapshot.json") +TIER_ONE_MODULES = ( + "ser", + "ser.api", + "ser.config", + "ser.domain", + "ser.profiles", + "ser.utils", +) + +type JsonValue = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"] +type JsonObject = dict[str, JsonValue] + + +def _text(value: object) -> str | None: + """Returns a stable string representation for Griffe expression values.""" + if value is None: + return None + return str(value) + + +def _kind(member: Any) -> str: + """Returns Griffe's stable kind value.""" + return str(member.kind.value) + + +def _member_name(member: Any) -> str: + """Returns a member name without resolving aliases.""" + return str(member.name) + + +def _parse_dunder_all(module: Any, module_name: str) -> tuple[str, ...] | None: + """Reads a literal module `__all__` declaration when present.""" + all_member = module.members.get("__all__") + if all_member is None: + return None + raw_value = _text(all_member.value) + if raw_value is None: + raise ValueError(f"{module_name}.__all__ must be a literal sequence of strings.") + parsed = ast.literal_eval(raw_value) + if not isinstance(parsed, list | tuple) or not all(isinstance(item, str) for item in parsed): + raise ValueError(f"{module_name}.__all__ must be a literal sequence of strings.") + return tuple(parsed) + + +def _exported_names(module: Any, module_name: str) -> tuple[str, ...]: + """Returns reviewed export names for one tier-1 module.""" + explicit_exports = _parse_dunder_all(module, module_name) + if explicit_exports is not None: + return tuple(sorted(explicit_exports)) + + return tuple( + sorted( + name + for name, member in module.members.items() + if not name.startswith("_") and not bool(getattr(member, "is_alias", False)) + ) + ) + + +def _parameter_snapshot(parameter: Any) -> JsonObject: + """Returns a stable snapshot for one callable parameter.""" + snapshot: JsonObject = { + "annotation": _text(parameter.annotation), + "default": _text(parameter.default), + "kind": str(parameter.kind.value), + "name": str(parameter.name), + } + return snapshot + + +def _signature(member: Any) -> JsonObject: + """Returns a stable callable signature snapshot.""" + parameters = [_parameter_snapshot(parameter) for parameter in member.parameters] + return { + "parameters": parameters, + "returns": _text(member.returns), + "signature": _format_signature(parameters, _text(member.returns)), + } + + +def _format_signature(parameters: list[JsonObject], returns: str | None) -> str: + """Formats parameters into a compact, review-friendly signature string.""" + parts: list[str] = [] + inserted_keyword_separator = False + for parameter in parameters: + kind = parameter["kind"] + if kind == "keyword-only" and not inserted_keyword_separator: + parts.append("*") + inserted_keyword_separator = True + + name = str(parameter["name"]) + if kind == "variadic positional": + name = f"*{name}" + inserted_keyword_separator = True + elif kind == "variadic keyword": + name = f"**{name}" + + annotation = parameter["annotation"] + if isinstance(annotation, str): + name = f"{name}: {annotation}" + + default = parameter["default"] + if isinstance(default, str): + name = f"{name} = {default}" + parts.append(name) + + return_annotation = returns if returns is not None else "None" + return f"({', '.join(parts)}) -> {return_annotation}" + + +def _attribute_snapshot(member: Any) -> JsonObject: + """Returns a stable attribute or type-alias snapshot.""" + return { + "annotation": _text(getattr(member, "annotation", None)), + "kind": _kind(member), + "value": _text(getattr(member, "value", None)), + } + + +def _class_snapshot(member: Any) -> JsonObject: + """Returns a stable class snapshot, including reviewed public members.""" + snapshot: JsonObject = { + "bases": [_text(base) for base in member.bases], + "kind": _kind(member), + "members": { + name: _member_snapshot(child) + for name, child in sorted(member.members.items()) + if not name.startswith("_") + }, + } + return snapshot + + +def _alias_snapshot(member: Any) -> JsonObject: + """Returns a stable alias snapshot without forcing target resolution.""" + target_path = getattr(member, "target_path", None) + return { + "kind": _kind(member), + "target_path": str(target_path) if target_path is not None else None, + } + + +def _member_snapshot(member: Any) -> JsonObject: + """Returns the stable public-contract snapshot for one exported member.""" + if bool(getattr(member, "is_alias", False)): + return _alias_snapshot(member) + kind = _kind(member) + if kind in {"function", "method"}: + snapshot = _signature(member) + snapshot["kind"] = kind + return snapshot + if kind == "class": + return _class_snapshot(member) + if kind in {"attribute", "type alias"}: + return _attribute_snapshot(member) + return {"kind": kind} + + +def dump_public_api(repo_root: Path) -> JsonObject: + """Builds the stable tier-1 public API snapshot.""" + modules: dict[str, JsonValue] = {} + search_paths = [str(repo_root)] + for module_name in TIER_ONE_MODULES: + module = griffe.load(module_name, search_paths=search_paths) + exports: dict[str, JsonValue] = {} + for export_name in _exported_names(module, module_name): + member = module.members.get(export_name) + if member is None: + raise ValueError(f"{module_name}.__all__ exports missing member {export_name!r}.") + exports[export_name] = _member_snapshot(member) + modules[module_name] = { + "exports": exports, + "source": "explicit __all__" if "__all__" in module.members else "public definitions", + } + + return { + "modules": modules, + "schema_version": SCHEMA_VERSION, + "tier_one_modules": list(TIER_ONE_MODULES), + } + + +def _json_text(snapshot: JsonObject) -> str: + """Serializes snapshot data with deterministic formatting.""" + return json.dumps(snapshot, indent=2, sort_keys=True) + "\n" + + +def main() -> int: + """CLI entry point.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--write", + action="store_true", + help=f"write {SNAPSHOT_PATH} instead of printing to stdout", + ) + args = parser.parse_args() + + repo_root = Path.cwd() + snapshot_text = _json_text(dump_public_api(repo_root)) + if args.write: + output_path = repo_root / SNAPSHOT_PATH + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(snapshot_text, encoding="utf-8") + else: + print(snapshot_text, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/suites/integration/architecture/public_api_snapshot.json b/tests/suites/integration/architecture/public_api_snapshot.json new file mode 100644 index 0000000..0a81f90 --- /dev/null +++ b/tests/suites/integration/architecture/public_api_snapshot.json @@ -0,0 +1,1066 @@ +{ + "modules": { + "ser": { + "exports": { + "EmotionSegment": { + "kind": "class", + "target_path": "ser.domain.EmotionSegment" + }, + "TimelineEntry": { + "kind": "class", + "target_path": "ser.domain.TimelineEntry" + }, + "TranscriptWord": { + "kind": "class", + "target_path": "ser.domain.TranscriptWord" + }, + "__version__": { + "annotation": "str", + "kind": "attribute", + "value": "version('ser')" + } + }, + "source": "explicit __all__" + }, + "ser.api": { + "exports": { + "AppConfig": { + "kind": "class", + "target_path": "ser.config.AppConfig" + }, + "ComplianceMode": { + "annotation": null, + "kind": "attribute", + "value": "_data_api.ComplianceMode" + }, + "DatasetConsents": { + "kind": "class", + "target_path": "ser.domain.DatasetConsents" + }, + "DatasetPrepareResult": { + "annotation": null, + "kind": "attribute", + "value": "_data_api.DatasetPrepareResult" + }, + "DatasetRegistryHealthIssueRecord": { + "annotation": null, + "kind": "attribute", + "value": "_data_api.DatasetRegistryHealthIssueRecord" + }, + "DatasetRegistryRecord": { + "annotation": null, + "kind": "attribute", + "value": "_data_api.DatasetRegistryRecord" + }, + "DiagnosticFinding": { + "kind": "class", + "target_path": "ser.diagnostics.domain.DiagnosticFinding" + }, + "DiagnosticReport": { + "kind": "class", + "target_path": "ser.diagnostics.domain.DiagnosticReport" + }, + "DiagnosticSeverity": { + "kind": "type alias", + "target_path": "ser.diagnostics.domain.DiagnosticSeverity" + }, + "InferenceExecution": { + "kind": "class", + "target_path": "ser.runtime.contracts.InferenceExecution" + }, + "InferenceRequest": { + "kind": "class", + "target_path": "ser.runtime.contracts.InferenceRequest" + }, + "ProfileName": { + "kind": "type alias", + "target_path": "ser.profiles.ProfileName" + }, + "RuntimePipeline": { + "bases": [ + "Protocol" + ], + "kind": "class", + "members": { + "run_inference": { + "kind": "function", + "parameters": [ + { + "annotation": null, + "default": null, + "kind": "positional or keyword", + "name": "self" + }, + { + "annotation": "InferenceRequest", + "default": null, + "kind": "positional or keyword", + "name": "request" + } + ], + "returns": "InferenceExecution", + "signature": "(self, request: InferenceRequest) -> InferenceExecution" + }, + "run_training": { + "kind": "function", + "parameters": [ + { + "annotation": null, + "default": null, + "kind": "positional or keyword", + "name": "self" + } + ], + "returns": "None", + "signature": "(self) -> None" + } + } + }, + "RuntimePipelineBuilder": { + "annotation": null, + "kind": "type alias", + "value": "Callable[[AppConfig], RuntimePipeline]" + }, + "SubtitleFormat": { + "kind": "type alias", + "target_path": "ser.runtime.contracts.SubtitleFormat" + }, + "configure_dataset_consents": { + "kind": "function", + "parameters": [ + { + "annotation": "tuple[str, ...]", + "default": "()", + "kind": "keyword-only", + "name": "accept_policy_ids" + }, + { + "annotation": "tuple[str, ...]", + "default": "()", + "kind": "keyword-only", + "name": "accept_license_ids" + }, + { + "annotation": "AppConfig | None", + "default": "None", + "kind": "keyword-only", + "name": "settings" + }, + { + "annotation": "str", + "default": "'ser.api.configure_dataset_consents'", + "kind": "keyword-only", + "name": "source" + } + ], + "returns": "DatasetConsents", + "signature": "(*, accept_policy_ids: tuple[str, ...] = (), accept_license_ids: tuple[str, ...] = (), settings: AppConfig | None = None, source: str = 'ser.api.configure_dataset_consents') -> DatasetConsents" + }, + "infer": { + "kind": "function", + "parameters": [ + { + "annotation": "str | Path", + "default": null, + "kind": "positional or keyword", + "name": "file_path" + }, + { + "annotation": "ProfileName | None", + "default": "None", + "kind": "keyword-only", + "name": "profile" + }, + { + "annotation": "str | None", + "default": "None", + "kind": "keyword-only", + "name": "language" + }, + { + "annotation": "bool", + "default": "False", + "kind": "keyword-only", + "name": "save_transcript" + }, + { + "annotation": "bool", + "default": "True", + "kind": "keyword-only", + "name": "include_transcript" + }, + { + "annotation": "str | None", + "default": "None", + "kind": "keyword-only", + "name": "subtitle_output_path" + }, + { + "annotation": "SubtitleFormat | None", + "default": "None", + "kind": "keyword-only", + "name": "subtitle_format" + }, + { + "annotation": "AppConfig | None", + "default": "None", + "kind": "keyword-only", + "name": "settings" + }, + { + "annotation": "RuntimePipelineBuilder | None", + "default": "None", + "kind": "keyword-only", + "name": "pipeline_builder" + } + ], + "returns": "InferenceExecution", + "signature": "(file_path: str | Path, *, profile: ProfileName | None = None, language: str | None = None, save_transcript: bool = False, include_transcript: bool = True, subtitle_output_path: str | None = None, subtitle_format: SubtitleFormat | None = None, settings: AppConfig | None = None, pipeline_builder: RuntimePipelineBuilder | None = None) -> InferenceExecution" + }, + "list_dataset_registry_health_issues": { + "kind": "function", + "parameters": [ + { + "annotation": "AppConfig | None", + "default": "None", + "kind": "keyword-only", + "name": "settings" + } + ], + "returns": "tuple[DatasetRegistryHealthIssueRecord, ...]", + "signature": "(*, settings: AppConfig | None = None) -> tuple[DatasetRegistryHealthIssueRecord, ...]" + }, + "list_datasets": { + "kind": "function", + "parameters": [], + "returns": "tuple[str, ...]", + "signature": "() -> tuple[str, ...]" + }, + "list_profiles": { + "kind": "function", + "parameters": [], + "returns": "tuple[ProfileName, ...]", + "signature": "() -> tuple[ProfileName, ...]" + }, + "list_registered_datasets": { + "kind": "function", + "parameters": [ + { + "annotation": "AppConfig | None", + "default": "None", + "kind": "keyword-only", + "name": "settings" + } + ], + "returns": "tuple[DatasetRegistryRecord, ...]", + "signature": "(*, settings: AppConfig | None = None) -> tuple[DatasetRegistryRecord, ...]" + }, + "load_profile": { + "kind": "function", + "parameters": [ + { + "annotation": "ProfileName", + "default": null, + "kind": "positional or keyword", + "name": "profile" + }, + { + "annotation": "AppConfig | None", + "default": "None", + "kind": "keyword-only", + "name": "settings" + } + ], + "returns": "None", + "signature": "(profile: ProfileName, *, settings: AppConfig | None = None) -> None" + }, + "prepare_dataset": { + "kind": "function", + "parameters": [ + { + "annotation": "str", + "default": null, + "kind": "keyword-only", + "name": "dataset_id" + }, + { + "annotation": "Path | None", + "default": "None", + "kind": "keyword-only", + "name": "dataset_root" + }, + { + "annotation": "Path | None", + "default": "None", + "kind": "keyword-only", + "name": "manifest_path" + }, + { + "annotation": "Path | None", + "default": "None", + "kind": "keyword-only", + "name": "labels_csv_path" + }, + { + "annotation": "Path | None", + "default": "None", + "kind": "keyword-only", + "name": "audio_base_dir" + }, + { + "annotation": "str | None", + "default": "None", + "kind": "keyword-only", + "name": "source_repo_id" + }, + { + "annotation": "str | None", + "default": "None", + "kind": "keyword-only", + "name": "source_revision" + }, + { + "annotation": "str | None", + "default": "None", + "kind": "keyword-only", + "name": "default_language" + }, + { + "annotation": "bool", + "default": "False", + "kind": "keyword-only", + "name": "skip_download" + }, + { + "annotation": "bool", + "default": "False", + "kind": "keyword-only", + "name": "accept_license" + }, + { + "annotation": "ComplianceMode", + "default": "'advisory'", + "kind": "keyword-only", + "name": "compliance_mode" + }, + { + "annotation": "AppConfig | None", + "default": "None", + "kind": "keyword-only", + "name": "settings" + } + ], + "returns": "DatasetPrepareResult", + "signature": "(*, dataset_id: str, dataset_root: Path | None = None, manifest_path: Path | None = None, labels_csv_path: Path | None = None, audio_base_dir: Path | None = None, source_repo_id: str | None = None, source_revision: str | None = None, default_language: str | None = None, skip_download: bool = False, accept_license: bool = False, compliance_mode: ComplianceMode = 'advisory', settings: AppConfig | None = None) -> DatasetPrepareResult" + }, + "run_startup_preflight": { + "kind": "function", + "parameters": [ + { + "annotation": "bool", + "default": null, + "kind": "keyword-only", + "name": "include_transcription_checks" + }, + { + "annotation": "AppConfig | None", + "default": "None", + "kind": "keyword-only", + "name": "settings" + } + ], + "returns": "DiagnosticReport", + "signature": "(*, include_transcription_checks: bool, settings: AppConfig | None = None) -> DiagnosticReport" + }, + "show_dataset_consents": { + "kind": "function", + "parameters": [ + { + "annotation": "AppConfig | None", + "default": "None", + "kind": "keyword-only", + "name": "settings" + } + ], + "returns": "DatasetConsents", + "signature": "(*, settings: AppConfig | None = None) -> DatasetConsents" + }, + "train": { + "kind": "function", + "parameters": [ + { + "annotation": "ProfileName | None", + "default": "None", + "kind": "keyword-only", + "name": "profile" + }, + { + "annotation": "AppConfig | None", + "default": "None", + "kind": "keyword-only", + "name": "settings" + }, + { + "annotation": "RuntimePipelineBuilder | None", + "default": "None", + "kind": "keyword-only", + "name": "pipeline_builder" + } + ], + "returns": "None", + "signature": "(*, profile: ProfileName | None = None, settings: AppConfig | None = None, pipeline_builder: RuntimePipelineBuilder | None = None) -> None" + } + }, + "source": "explicit __all__" + }, + "ser.config": { + "exports": { + "APP_NAME": { + "kind": "attribute", + "target_path": "ser._internal.config.schema.APP_NAME" + }, + "AccurateResearchRuntimeConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.AccurateResearchRuntimeConfig" + }, + "AccurateRuntimeConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.AccurateRuntimeConfig" + }, + "AppConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.AppConfig" + }, + "ArtifactProfileName": { + "kind": "type alias", + "target_path": "ser._internal.config.schema.ArtifactProfileName" + }, + "AudioReadConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.AudioReadConfig" + }, + "DataLoaderConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.DataLoaderConfig" + }, + "DatasetConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.DatasetConfig" + }, + "FastRuntimeConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.FastRuntimeConfig" + }, + "FeatureFlags": { + "kind": "class", + "target_path": "ser._internal.config.schema.FeatureFlags" + }, + "FeatureRuntimePolicyConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.FeatureRuntimePolicyConfig" + }, + "MediumRuntimeConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.MediumRuntimeConfig" + }, + "MediumTrainingConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.MediumTrainingConfig" + }, + "ModelsConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.ModelsConfig" + }, + "NeuralNetConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.NeuralNetConfig" + }, + "ProfileRuntimeConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.ProfileRuntimeConfig" + }, + "QualityGateConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.QualityGateConfig" + }, + "RuntimeFlags": { + "kind": "class", + "target_path": "ser._internal.config.schema.RuntimeFlags" + }, + "SchemaConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.SchemaConfig" + }, + "TimelineConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.TimelineConfig" + }, + "TorchRuntimeConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.TorchRuntimeConfig" + }, + "TrainingConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.TrainingConfig" + }, + "TranscriptionConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.TranscriptionConfig" + }, + "WhisperModelConfig": { + "kind": "class", + "target_path": "ser._internal.config.schema.WhisperModelConfig" + }, + "get_settings": { + "kind": "function", + "target_path": "ser._internal.config.bootstrap.get_settings" + }, + "reload_settings": { + "kind": "function", + "target_path": "ser._internal.config.bootstrap.reload_settings" + }, + "settings_override": { + "kind": "function", + "target_path": "ser._internal.config.bootstrap.settings_override" + } + }, + "source": "explicit __all__" + }, + "ser.domain": { + "exports": { + "DatasetConsents": { + "bases": [ + "NamedTuple" + ], + "kind": "class", + "members": { + "license_ids": { + "annotation": "tuple[str, ...]", + "kind": "attribute", + "value": null + }, + "policy_ids": { + "annotation": "tuple[str, ...]", + "kind": "attribute", + "value": null + } + } + }, + "EmotionSegment": { + "bases": [ + "NamedTuple" + ], + "kind": "class", + "members": { + "emotion": { + "annotation": "str", + "kind": "attribute", + "value": null + }, + "end_seconds": { + "annotation": "float", + "kind": "attribute", + "value": null + }, + "start_seconds": { + "annotation": "float", + "kind": "attribute", + "value": null + } + } + }, + "TimelineEntry": { + "bases": [ + "NamedTuple" + ], + "kind": "class", + "members": { + "emotion": { + "annotation": "str", + "kind": "attribute", + "value": null + }, + "speech": { + "annotation": "str", + "kind": "attribute", + "value": null + }, + "timestamp_seconds": { + "annotation": "float", + "kind": "attribute", + "value": null + } + } + }, + "TranscriptWord": { + "bases": [ + "NamedTuple" + ], + "kind": "class", + "members": { + "end_seconds": { + "annotation": "float", + "kind": "attribute", + "value": null + }, + "start_seconds": { + "annotation": "float", + "kind": "attribute", + "value": null + }, + "word": { + "annotation": "str", + "kind": "attribute", + "value": null + } + } + } + }, + "source": "explicit __all__" + }, + "ser.profiles": { + "exports": { + "ProfileCatalogEntry": { + "bases": [], + "kind": "class", + "members": { + "backend_id": { + "annotation": "str", + "kind": "attribute", + "value": null + }, + "description": { + "annotation": "str", + "kind": "attribute", + "value": null + }, + "enable_flag": { + "annotation": "ProfileEnableFlag | None", + "kind": "attribute", + "value": null + }, + "enabled_by_default": { + "annotation": "bool", + "kind": "attribute", + "value": null + }, + "feature_runtime_defaults": { + "annotation": "ProfileFeatureRuntimeDefaults | None", + "kind": "attribute", + "value": null + }, + "model": { + "annotation": "ProfileModelDefinition", + "kind": "attribute", + "value": null + }, + "name": { + "annotation": "ProfileName", + "kind": "attribute", + "value": null + }, + "required_modules": { + "annotation": "tuple[str, ...]", + "kind": "attribute", + "value": null + }, + "runtime_defaults": { + "annotation": "ProfileRuntimeDefaults", + "kind": "attribute", + "value": null + }, + "runtime_env": { + "annotation": "ProfileRuntimeEnvDefinition", + "kind": "attribute", + "value": null + }, + "transcription_defaults": { + "annotation": "ProfileTranscriptionDefaults", + "kind": "attribute", + "value": null + }, + "transcription_env": { + "annotation": "ProfileTranscriptionEnvDefinition", + "kind": "attribute", + "value": null + } + } + }, + "ProfileEnableFlag": { + "annotation": null, + "kind": "type alias", + "value": "Literal['SER_ENABLE_MEDIUM_PROFILE', 'SER_ENABLE_ACCURATE_PROFILE', 'SER_ENABLE_ACCURATE_RESEARCH_PROFILE']" + }, + "ProfileFeatureRuntimeDefaults": { + "bases": [], + "kind": "class", + "members": { + "torch_device": { + "annotation": "str | None", + "kind": "attribute", + "value": null + }, + "torch_dtype": { + "annotation": "str | None", + "kind": "attribute", + "value": null + } + } + }, + "ProfileModelDefinition": { + "bases": [], + "kind": "class", + "members": { + "default_model_id": { + "annotation": "str | None", + "kind": "attribute", + "value": null + }, + "env_var": { + "annotation": "str | None", + "kind": "attribute", + "value": null + } + } + }, + "ProfileName": { + "annotation": null, + "kind": "type alias", + "value": "Literal['fast', 'medium', 'accurate', 'accurate-research']" + }, + "ProfileRuntimeDefaults": { + "bases": [], + "kind": "class", + "members": { + "max_timeout_retries": { + "annotation": "int", + "kind": "attribute", + "value": null + }, + "max_transient_retries": { + "annotation": "int", + "kind": "attribute", + "value": null + }, + "pool_window_size_seconds": { + "annotation": "float", + "kind": "attribute", + "value": null + }, + "pool_window_stride_seconds": { + "annotation": "float", + "kind": "attribute", + "value": null + }, + "post_hysteresis_enter_confidence": { + "annotation": "float", + "kind": "attribute", + "value": null + }, + "post_hysteresis_exit_confidence": { + "annotation": "float", + "kind": "attribute", + "value": null + }, + "post_min_segment_duration_seconds": { + "annotation": "float", + "kind": "attribute", + "value": null + }, + "post_smoothing_window_frames": { + "annotation": "int", + "kind": "attribute", + "value": null + }, + "process_isolation": { + "annotation": "bool", + "kind": "attribute", + "value": null + }, + "retry_backoff_seconds": { + "annotation": "float", + "kind": "attribute", + "value": null + }, + "timeout_seconds": { + "annotation": "float", + "kind": "attribute", + "value": null + } + } + }, + "ProfileRuntimeEnvDefinition": { + "bases": [], + "kind": "class", + "members": { + "max_timeout_retries": { + "annotation": "str | None", + "kind": "attribute", + "value": null + }, + "max_transient_retries": { + "annotation": "str | None", + "kind": "attribute", + "value": null + }, + "pool_window_size_seconds": { + "annotation": "str | None", + "kind": "attribute", + "value": null + }, + "pool_window_stride_seconds": { + "annotation": "str | None", + "kind": "attribute", + "value": null + }, + "post_hysteresis_enter_confidence": { + "annotation": "str | None", + "kind": "attribute", + "value": null + }, + "post_hysteresis_exit_confidence": { + "annotation": "str | None", + "kind": "attribute", + "value": null + }, + "post_min_segment_duration_seconds": { + "annotation": "str | None", + "kind": "attribute", + "value": null + }, + "post_smoothing_window_frames": { + "annotation": "str | None", + "kind": "attribute", + "value": null + }, + "process_isolation": { + "annotation": "str | None", + "kind": "attribute", + "value": null + }, + "retry_backoff_seconds": { + "annotation": "str | None", + "kind": "attribute", + "value": null + }, + "timeout_seconds": { + "annotation": "str | None", + "kind": "attribute", + "value": null + } + } + }, + "ProfileTranscriptionDefaults": { + "bases": [], + "kind": "class", + "members": { + "backend_id": { + "annotation": "TranscriptionBackendId", + "kind": "attribute", + "value": null + }, + "model_name": { + "annotation": "str", + "kind": "attribute", + "value": null + }, + "use_demucs": { + "annotation": "bool", + "kind": "attribute", + "value": null + }, + "use_vad": { + "annotation": "bool", + "kind": "attribute", + "value": null + } + } + }, + "ProfileTranscriptionEnvDefinition": { + "bases": [], + "kind": "class", + "members": { + "backend_id": { + "annotation": "str | None", + "kind": "attribute", + "value": null + }, + "model_name": { + "annotation": "str | None", + "kind": "attribute", + "value": null + }, + "use_demucs": { + "annotation": "str | None", + "kind": "attribute", + "value": null + }, + "use_vad": { + "annotation": "str | None", + "kind": "attribute", + "value": null + } + } + }, + "RuntimeProfile": { + "bases": [], + "kind": "class", + "members": { + "description": { + "annotation": "str", + "kind": "attribute", + "value": null + }, + "name": { + "annotation": "ProfileName", + "kind": "attribute", + "value": null + } + } + }, + "TranscriptionBackendId": { + "annotation": null, + "kind": "type alias", + "value": "Literal['stable_whisper', 'faster_whisper']" + }, + "available_profiles": { + "kind": "function", + "parameters": [], + "returns": "Mapping[str, RuntimeProfile]", + "signature": "() -> Mapping[str, RuntimeProfile]" + }, + "get_profile_catalog": { + "kind": "function", + "parameters": [], + "returns": "Mapping[ProfileName, ProfileCatalogEntry]", + "signature": "() -> Mapping[ProfileName, ProfileCatalogEntry]" + }, + "resolve_profile": { + "kind": "function", + "parameters": [ + { + "annotation": "AppConfig", + "default": null, + "kind": "positional or keyword", + "name": "settings" + } + ], + "returns": "RuntimeProfile", + "signature": "(settings: AppConfig) -> RuntimeProfile" + }, + "resolve_profile_name": { + "kind": "function", + "parameters": [ + { + "annotation": "AppConfig", + "default": null, + "kind": "positional or keyword", + "name": "settings" + } + ], + "returns": "ProfileName", + "signature": "(settings: AppConfig) -> ProfileName" + } + }, + "source": "public definitions" + }, + "ser.utils": { + "exports": { + "build_timeline": { + "kind": "function", + "parameters": [ + { + "annotation": "list[TranscriptWord]", + "default": null, + "kind": "positional or keyword", + "name": "text_with_timestamps" + }, + { + "annotation": "list[EmotionSegment]", + "default": null, + "kind": "positional or keyword", + "name": "emotion_with_timestamps" + } + ], + "returns": "list[TimelineEntry]", + "signature": "(text_with_timestamps: list[TranscriptWord], emotion_with_timestamps: list[EmotionSegment]) -> list[TimelineEntry]" + }, + "display_elapsed_time": { + "kind": "function", + "target_path": "ser.utils.common_utils.display_elapsed_time" + }, + "get_logger": { + "kind": "function", + "target_path": "ser.utils.logger.get_logger" + }, + "print_timeline": { + "kind": "function", + "parameters": [ + { + "annotation": "list[TimelineEntry]", + "default": null, + "kind": "positional or keyword", + "name": "timeline" + } + ], + "returns": "None", + "signature": "(timeline: list[TimelineEntry]) -> None" + }, + "read_audio_file": { + "kind": "function", + "parameters": [ + { + "annotation": "str", + "default": null, + "kind": "positional or keyword", + "name": "file_path" + }, + { + "annotation": "float | None", + "default": "None", + "kind": "keyword-only", + "name": "start_seconds" + }, + { + "annotation": "float | None", + "default": "None", + "kind": "keyword-only", + "name": "duration_seconds" + } + ], + "returns": "tuple[np.ndarray, int]", + "signature": "(file_path: str, *, start_seconds: float | None = None, duration_seconds: float | None = None) -> tuple[np.ndarray, int]" + }, + "save_timeline_to_csv": { + "kind": "function", + "parameters": [ + { + "annotation": "list[TimelineEntry]", + "default": null, + "kind": "positional or keyword", + "name": "timeline" + }, + { + "annotation": "str", + "default": null, + "kind": "positional or keyword", + "name": "file_name" + } + ], + "returns": "str", + "signature": "(timeline: list[TimelineEntry], file_name: str) -> str" + } + }, + "source": "explicit __all__" + } + }, + "schema_version": 1, + "tier_one_modules": [ + "ser", + "ser.api", + "ser.config", + "ser.domain", + "ser.profiles", + "ser.utils" + ] +} diff --git a/tests/suites/integration/architecture/test_public_api_snapshot.py b/tests/suites/integration/architecture/test_public_api_snapshot.py new file mode 100644 index 0000000..cb38ebc --- /dev/null +++ b/tests/suites/integration/architecture/test_public_api_snapshot.py @@ -0,0 +1,48 @@ +"""Contract test for the reviewed tier-1 public API snapshot.""" + +from __future__ import annotations + +import difflib +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.topology_contract + + +def test_public_api_snapshot_matches_current_surface(repo_root: Path) -> None: + """Tier-1 public API drift should require an explicit snapshot update.""" + snapshot_path = repo_root / "tests/suites/integration/architecture/public_api_snapshot.json" + dump_command = [sys.executable, "scripts/dump_public_api.py"] + completed = subprocess.run( + dump_command, + cwd=repo_root, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, ( + "Public API snapshot dump failed. Fix the exported surface or run " + "`uv run --frozen --extra dev python scripts/dump_public_api.py --write` " + f"after an intentional API change.\n{completed.stderr}" + ) + + expected = snapshot_path.read_text(encoding="utf-8") + actual = completed.stdout + if actual != expected: + diff = "\n".join( + difflib.unified_diff( + expected.splitlines(), + actual.splitlines(), + fromfile=str(snapshot_path), + tofile="current public API", + lineterm="", + ) + ) + raise AssertionError( + "Tier-1 public API snapshot drifted. If this is intentional, run " + "`uv run --frozen --extra dev python scripts/dump_public_api.py --write` " + "and review the JSON diff.\n" + f"{diff[:12000]}" + ) diff --git a/uv.lock b/uv.lock index 9b29079..da85688 100644 --- a/uv.lock +++ b/uv.lock @@ -606,6 +606,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, ] +[[package]] +name = "griffe" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, +] + [[package]] name = "hf-xet" version = "1.2.0" @@ -2024,6 +2036,7 @@ dependencies = [ dev = [ { name = "black" }, { name = "coverage" }, + { name = "griffe" }, { name = "hypothesis" }, { name = "isort" }, { name = "mypy" }, @@ -2052,6 +2065,7 @@ requires-dist = [ { name = "faster-whisper", specifier = ">=1.1.1,<2.0.0" }, { name = "ffmpeg-python", specifier = ">=0.2.0,<0.3.0" }, { name = "funasr", marker = "extra == 'full'", specifier = ">=1.3.1,<2.0.0" }, + { name = "griffe", marker = "extra == 'dev'", specifier = ">=1.14.0,<2.0.0" }, { name = "huggingface-hub", specifier = ">=0.30.0,<1.0.0" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.130.0,<7.0.0" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=8.0.1,<9.0.0" }, From 8509d2142d2445475bc8270855327bdc671a2203 Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 9 Jul 2026 20:32:23 -0300 Subject: [PATCH 07/10] test(types): gate public type completeness --- .local/public-api-surface-improvements.md | 20 ++++- Makefile | 6 +- pyproject.toml | 3 + scripts/check_type_completeness.py | 93 +++++++++++++++++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 scripts/check_type_completeness.py diff --git a/.local/public-api-surface-improvements.md b/.local/public-api-surface-improvements.md index d149d49..34046ea 100644 --- a/.local/public-api-surface-improvements.md +++ b/.local/public-api-surface-improvements.md @@ -145,7 +145,7 @@ all callers (CLI, tests). | ID | Task | Status | Depends on | |----|------|--------|------------| | P1-01 | Public-API snapshot + drift contract test | DONE | P0-03, P0-04, P0-05 | -| P1-02 | pyright `--verifytypes` ratchet gate | TODO | P0-01 | +| P1-02 | pyright `--verifytypes` ratchet gate | DONE | P0-01 | | P1-03 | Import-cost contract test | TODO | P0-03 | | P1-04 | ruff TID251 boundary lint | TODO | — | | P1-05 | CI wiring for the new gates | TODO | P1-01..04 | @@ -309,6 +309,24 @@ Template: - Deviations / follow-ups: … ``` +### 2026-07-09 20:32 — P1-02 done +- What: Added `make type-completeness`, backed by + `scripts/check_type_completeness.py`, with the baseline stored at + `[tool.ser.type_completeness].threshold`. +- Evidence: `make type-completeness` → `pyright verifytypes completeness: + 0.9788235294 (threshold 0.9788235294)`; `uv run --frozen --extra dev black + --check scripts/check_type_completeness.py` → unchanged. +- Deviations / follow-ups: Baseline already exceeds 95%, so no ratchet-to-95% + follow-up needed. + +### 2026-07-09 20:30 — P1-02 started +- What: Add a `make type-completeness` gate around pyright `--verifytypes ser` and + record the current completeness baseline as the threshold. +- Evidence: `uv run --frozen --extra dev pyright --verifytypes ser --ignoreexternal + --outputjson` → completeness score `0.9788235294117648` with zero diagnostics + before adding the gate. +- Deviations / follow-ups: none. + ### 2026-07-09 20:29 — P1-01 done - What: Added `griffe` to the dev extra, `scripts/dump_public_api.py`, the tier-1 JSON snapshot, and a contract test that diffs current griffe output against the diff --git a/Makefile b/Makefile index 202de10..406da84 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help setup setup-runtime fmt lint type test test-cov check ci train predict optin-all-restricted quality-gate-full prepush prepush-check prepush-hook import-lint lock-check workflow-lint ci-contracts clean +.PHONY: help setup setup-runtime fmt lint type type-completeness test test-cov check ci train predict optin-all-restricted quality-gate-full prepush prepush-check prepush-hook import-lint lock-check workflow-lint ci-contracts clean .DEFAULT_GOAL := help @@ -11,6 +11,7 @@ help: @echo " fmt - format code" @echo " lint - run linters" @echo " type - run type checks" + @echo " type-completeness - enforce pyright verifytypes public API completeness" @echo " test - run tests" @echo " test-cov - run tests with branch coverage gating" @echo " check - lint + type + test" @@ -48,6 +49,9 @@ type: uv run --frozen --extra dev mypy ser tests uv run --frozen --extra dev pyright --pythonversion 3.12 ser tests +type-completeness: + uv run --frozen --extra dev python scripts/check_type_completeness.py + test: uv run --frozen --extra dev pytest -q diff --git a/pyproject.toml b/pyproject.toml index 344374c..bfeb69b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -183,6 +183,9 @@ exclude = ["**/__pycache__", "dist", "build", ".venv", ".*"] reportMissingImports = "warning" reportMissingTypeStubs = "none" +[tool.ser.type_completeness] +threshold = 0.9788235294117648 + [tool.coverage.run] branch = true concurrency = ["multiprocessing"] diff --git a/scripts/check_type_completeness.py b/scripts/check_type_completeness.py new file mode 100644 index 0000000..c4bbedc --- /dev/null +++ b/scripts/check_type_completeness.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Run pyright verifytypes and enforce the configured completeness ratchet.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tomllib +from pathlib import Path +from typing import Any + +PYRIGHT_VERIFYTYPES_COMMAND = ( + "pyright", + "--verifytypes", + "ser", + "--ignoreexternal", + "--outputjson", +) + + +def _load_threshold(repo_root: Path) -> float: + """Loads the configured type-completeness threshold from pyproject.""" + pyproject = tomllib.loads((repo_root / "pyproject.toml").read_text(encoding="utf-8")) + threshold = pyproject["tool"]["ser"]["type_completeness"]["threshold"] + if not isinstance(threshold, int | float): + raise TypeError("[tool.ser.type_completeness].threshold must be a number.") + return float(threshold) + + +def _run_pyright(repo_root: Path) -> dict[str, Any]: + """Runs pyright verifytypes and returns its JSON payload.""" + completed = subprocess.run( + PYRIGHT_VERIFYTYPES_COMMAND, + cwd=repo_root, + capture_output=True, + text=True, + ) + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError as err: + sys.stderr.write(completed.stdout) + sys.stderr.write(completed.stderr) + raise RuntimeError("pyright --verifytypes did not emit valid JSON.") from err + if not isinstance(payload, dict): + raise RuntimeError("pyright --verifytypes JSON payload must be an object.") + return payload + + +def _read_score(payload: dict[str, Any]) -> float: + """Reads the verifytypes completeness score with shape validation.""" + completeness = payload.get("typeCompleteness") + if not isinstance(completeness, dict): + raise RuntimeError("pyright JSON missing typeCompleteness object.") + score = completeness.get("completenessScore") + if not isinstance(score, int | float): + raise RuntimeError("pyright JSON missing numeric completenessScore.") + return float(score) + + +def _read_error_count(payload: dict[str, Any]) -> int: + """Reads the pyright summary error count.""" + summary = payload.get("summary") + if not isinstance(summary, dict): + raise RuntimeError("pyright JSON missing summary object.") + error_count = summary.get("errorCount") + if not isinstance(error_count, int): + raise RuntimeError("pyright JSON missing integer summary.errorCount.") + return error_count + + +def main() -> int: + """CLI entry point.""" + repo_root = Path.cwd() + threshold = _load_threshold(repo_root) + payload = _run_pyright(repo_root) + error_count = _read_error_count(payload) + score = _read_score(payload) + print(f"pyright verifytypes completeness: {score:.10f} (threshold {threshold:.10f})") + if error_count: + print(f"pyright verifytypes reported {error_count} errors.", file=sys.stderr) + return 1 + if score < threshold: + print( + f"pyright verifytypes completeness {score:.10f} is below threshold {threshold:.10f}.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 104a3988a2f5ace516c292c1c2b6964ed016b9c7 Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 9 Jul 2026 20:36:33 -0300 Subject: [PATCH 08/10] test(api): guard public import cost --- .local/public-api-surface-improvements.md | 17 ++++++++- .../architecture/test_public_import_cost.py | 36 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/suites/integration/architecture/test_public_import_cost.py diff --git a/.local/public-api-surface-improvements.md b/.local/public-api-surface-improvements.md index 34046ea..23825ee 100644 --- a/.local/public-api-surface-improvements.md +++ b/.local/public-api-surface-improvements.md @@ -146,7 +146,7 @@ all callers (CLI, tests). |----|------|--------|------------| | P1-01 | Public-API snapshot + drift contract test | DONE | P0-03, P0-04, P0-05 | | P1-02 | pyright `--verifytypes` ratchet gate | DONE | P0-01 | -| P1-03 | Import-cost contract test | TODO | P0-03 | +| P1-03 | Import-cost contract test | DONE | P0-03 | | P1-04 | ruff TID251 boundary lint | TODO | — | | P1-05 | CI wiring for the new gates | TODO | P1-01..04 | @@ -309,6 +309,21 @@ Template: - Deviations / follow-ups: … ``` +### 2026-07-09 20:36 — P1-03 done +- What: Added `tests/suites/integration/architecture/test_public_import_cost.py` + to assert tier-1 public imports leave `torch` absent from `sys.modules` in a + subprocess. +- Evidence: `uv run --frozen --extra dev pytest -q tests/suites/integration/architecture/test_public_import_cost.py` + → `1 passed`; `make check` → lint/type/test all pass, `1027 passed in 70.27s`. +- Deviations / follow-ups: none. + +### 2026-07-09 20:32 — P1-03 started +- What: Add an architecture subprocess test proving tier-1 public imports do not + import `torch`. +- Evidence: P0 import-cost one-liner already passed before this task; adding a + collected contract test makes that invariant permanent. +- Deviations / follow-ups: none. + ### 2026-07-09 20:32 — P1-02 done - What: Added `make type-completeness`, backed by `scripts/check_type_completeness.py`, with the baseline stored at diff --git a/tests/suites/integration/architecture/test_public_import_cost.py b/tests/suites/integration/architecture/test_public_import_cost.py new file mode 100644 index 0000000..3dcb566 --- /dev/null +++ b/tests/suites/integration/architecture/test_public_import_cost.py @@ -0,0 +1,36 @@ +"""Import-cost contracts for tier-1 public modules.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.topology_contract + + +def test_tier_one_public_imports_do_not_import_torch(repo_root: Path) -> None: + """Tier-1 public module imports should not eagerly import torch.""" + script = """ +import sys + +import ser +import ser.api +import ser.config +import ser.domain +import ser.profiles +import ser.utils + +if "torch" in sys.modules: + raise SystemExit("torch imported during tier-1 public imports") +""" + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=repo_root, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stderr or completed.stdout From 4941fb51be49171bfb9985405a45f10d1d61f83d Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 9 Jul 2026 20:40:19 -0300 Subject: [PATCH 09/10] test(imports): lint public internal boundaries --- .local/public-api-surface-improvements.md | 24 ++++++++- Makefile | 3 +- pyproject.toml | 26 +++++++--- scripts/run_import_lint.sh | 5 +- .../architecture/test_import_lint_policy.py | 51 ++++++++++--------- 5 files changed, 73 insertions(+), 36 deletions(-) diff --git a/.local/public-api-surface-improvements.md b/.local/public-api-surface-improvements.md index 23825ee..3db58a1 100644 --- a/.local/public-api-surface-improvements.md +++ b/.local/public-api-surface-improvements.md @@ -147,7 +147,7 @@ all callers (CLI, tests). | P1-01 | Public-API snapshot + drift contract test | DONE | P0-03, P0-04, P0-05 | | P1-02 | pyright `--verifytypes` ratchet gate | DONE | P0-01 | | P1-03 | Import-cost contract test | DONE | P0-03 | -| P1-04 | ruff TID251 boundary lint | TODO | — | +| P1-04 | ruff TID251 boundary lint | DONE | — | | P1-05 | CI wiring for the new gates | TODO | P1-01..04 | **P1-01** — Add `griffe` to the dev extra (`uv lock` refresh; `make lock-check` must @@ -309,6 +309,28 @@ Template: - Deviations / follow-ups: … ``` +### 2026-07-09 20:40 — P1-04 done +- What: Broadened Ruff TID251 to ban `ser._internal` for public source files, + changed the TID251 lane to scan public `ser` files only, and made per-file + ignores mirror `boundary_policy.toml` exactly. +- Evidence: `bash scripts/run_import_lint.sh` → Ruff TID251 passed and boundary + contract tests `16 passed`; synthetic `from ser._internal.config.schema import + AppConfig` in non-allowlisted `ser/domain.py` made `make lint` fail with + `TID251 ser._internal is banned`, then the synthetic edit was reverted; + `uv run --frozen --extra dev black tests/suites/integration/architecture/test_import_lint_policy.py` + → reformatted policy test; `make lint` → Ruff/black/isort all pass. +- Deviations / follow-ups: TID251 scans public source files only; tests and internal + implementation modules remain free to import internal helpers directly. + +### 2026-07-09 20:37 — P1-04 started +- What: Broaden Ruff TID251 from API-owner imports to all public imports of + `ser._internal`, with exceptions generated from `boundary_policy.toml`. +- Evidence: Existing `make lint` passes before broadening, but TID251 only bans + `ser._internal.api*`; synthetic whole-internal boundary coverage is not present yet. +- Deviations / follow-ups: The TID251 lane will target public `ser` files only so + internal implementation modules and internal-focused tests can keep importing + `_internal` directly. + ### 2026-07-09 20:36 — P1-03 done - What: Added `tests/suites/integration/architecture/test_public_import_cost.py` to assert tier-1 public imports leave `torch` absent from `sys.modules` in a diff --git a/Makefile b/Makefile index 406da84..3868afd 100644 --- a/Makefile +++ b/Makefile @@ -41,7 +41,8 @@ fmt: uv run --frozen --extra dev black ser tests lint: - uv run --frozen --extra dev ruff check ser tests + uv run --frozen --extra dev ruff check --ignore TID251 ser tests + bash ./scripts/run_import_lint.sh uv run --frozen --extra dev black --check ser tests uv run --frozen --extra dev isort --check-only ser tests diff --git a/pyproject.toml b/pyproject.toml index bfeb69b..adc3c18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,17 +142,27 @@ select = ["E", "F", "B", "TID251"] ignore = ["E501"] [tool.ruff.lint.flake8-tidy-imports.banned-api] -"ser._internal.api".msg = "Internal API modules are private; import from `ser.api`." -"ser._internal.api.data".msg = "Internal API modules are private; import from `ser.api`." -"ser._internal.api.diagnostics".msg = "Internal API modules are private; import from `ser.api`." -"ser._internal.api.runtime".msg = "Internal API modules are private; import from `ser.api`." +"ser._internal".msg = "Internal modules are private; public facades importing them must be declared in boundary_policy.toml." [tool.ruff.lint.per-file-ignores] +# TID251 exceptions mirror boundary_policy.toml; keep the policy file authoritative. +"ser/__main__.py" = ["TID251"] "ser/api.py" = ["TID251"] -"ser/_internal/cli/data.py" = ["TID251"] -"ser/_internal/cli/diagnostics.py" = ["TID251"] -"ser/_internal/cli/runtime.py" = ["TID251"] -"tests/suites/integration/api/test_api.py" = ["TID251"] +"ser/config.py" = ["TID251"] +"ser/data/application.py" = ["TID251"] +"ser/diagnostics/service.py" = ["TID251"] +"ser/models/emotion_model.py" = ["TID251"] +"ser/models/profile_runtime.py" = ["TID251"] +"ser/models/training_entrypoints.py" = ["TID251"] +"ser/repr/emotion2vec.py" = ["TID251"] +"ser/runtime/accurate_inference.py" = ["TID251"] +"ser/runtime/fast_inference.py" = ["TID251"] +"ser/runtime/medium_inference.py" = ["TID251"] +"ser/runtime/pipeline.py" = ["TID251"] +"ser/runtime/profile_quality_gate.py" = ["TID251"] +"ser/transcript/backends/stable_whisper.py" = ["TID251"] +"ser/transcript/profiling.py" = ["TID251"] +"ser/transcript/transcript_extractor.py" = ["TID251"] [tool.ruff.format] quote-style = "double" diff --git a/scripts/run_import_lint.sh b/scripts/run_import_lint.sh index 1b5e14e..cb5c512 100755 --- a/scripts/run_import_lint.sh +++ b/scripts/run_import_lint.sh @@ -2,9 +2,8 @@ set -euo pipefail # Import-layer contract lane for public API boundary enforcement. -readonly IMPORT_LINT_PATHS=( - ser - tests +mapfile -t IMPORT_LINT_PATHS < <( + find ser -path 'ser/_internal' -prune -o -name '*.py' -print | sort ) uv run --frozen --extra dev ruff check --select TID251 "${IMPORT_LINT_PATHS[@]}" diff --git a/tests/suites/integration/architecture/test_import_lint_policy.py b/tests/suites/integration/architecture/test_import_lint_policy.py index f7af9b4..35f091c 100644 --- a/tests/suites/integration/architecture/test_import_lint_policy.py +++ b/tests/suites/integration/architecture/test_import_lint_policy.py @@ -19,46 +19,51 @@ def _load_ruff_lint_config(repo_root: Path) -> dict[str, Any]: return cast(dict[str, Any], ruff_config["lint"]) -def test_import_lint_policy_covers_internal_api_modules(repo_root: Path) -> None: - """Ruff banned-api policy should cover all internal API implementation modules.""" +def _load_boundary_policy_paths(repo_root: Path) -> set[str]: + """Loads public facade paths from the authoritative boundary policy.""" + policy_data = tomllib.loads((repo_root / "boundary_policy.toml").read_text(encoding="utf-8")) + entries = policy_data["public_internal_import"] + if not isinstance(entries, list): + raise AssertionError("boundary_policy.toml must define public_internal_import entries.") + paths: set[str] = set() + for entry in entries: + if not isinstance(entry, dict): + raise AssertionError("boundary_policy.toml entries must be tables.") + path = entry.get("path") + if not isinstance(path, str): + raise AssertionError("boundary_policy.toml entries must define string paths.") + paths.add(path) + return paths + + +def test_import_lint_policy_covers_all_internal_modules(repo_root: Path) -> None: + """Ruff banned-api policy should cover every public import of `ser._internal`.""" lint_config = _load_ruff_lint_config(repo_root) tidy_imports_config = cast(dict[str, Any], lint_config["flake8-tidy-imports"]) banned_api = cast(dict[str, dict[str, str]], tidy_imports_config["banned-api"]) - required_modules = { - "ser._internal.api", - "ser._internal.api.data", - "ser._internal.api.diagnostics", - "ser._internal.api.runtime", - } - assert required_modules.issubset(set(banned_api)) - for module_name in required_modules: - assert "ser.api" in banned_api[module_name]["msg"] + assert set(banned_api) == {"ser._internal"} + assert "boundary_policy.toml" in banned_api["ser._internal"]["msg"] def test_import_lint_policy_limits_tid251_exceptions_to_boundary_files(repo_root: Path) -> None: - """Only facade + API contract test files should bypass TID251 for internal imports.""" + """Only boundary-policy facade files should bypass TID251.""" lint_config = _load_ruff_lint_config(repo_root) per_file_ignores = cast(dict[str, list[str]], lint_config["per-file-ignores"]) - allowed_tid251_files = { - "ser/api.py", - "ser/_internal/cli/data.py", - "ser/_internal/cli/diagnostics.py", - "ser/_internal/cli/runtime.py", - "tests/suites/integration/api/test_api.py", + tid251_ignored_files = { + file_path + for file_path, ignored_rules in per_file_ignores.items() + if "TID251" in ignored_rules } - for file_path, ignored_rules in per_file_ignores.items(): - if "TID251" in ignored_rules: - assert file_path in allowed_tid251_files - for allowed_file in allowed_tid251_files: - assert "TID251" in per_file_ignores[allowed_file] + assert tid251_ignored_files == _load_boundary_policy_paths(repo_root) def test_import_lint_lane_runs_boundary_contract_tests(repo_root: Path) -> None: """The import-lint lane should enforce both lint rules and boundary contracts.""" script = (repo_root / "scripts" / "run_import_lint.sh").read_text(encoding="utf-8") + assert "find ser -path 'ser/_internal' -prune -o -name '*.py' -print | sort" in script assert "ruff check --select TID251" in script assert "tests/suites/integration/architecture/test_api_import_boundary.py" in script assert "tests/suites/integration/architecture/test_import_lint_policy.py" in script From 24555b56105fcdc28a8329f15a914933686c575a Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 9 Jul 2026 20:42:08 -0300 Subject: [PATCH 10/10] ci: wire public API contract gates --- .github/workflows/ci.yml | 4 +++ .local/public-api-surface-improvements.md | 26 ++++++++++++++++++- .pre-commit-config.yaml | 9 ++++++- Makefile | 2 +- .../test_ci_workflow_contracts.py | 5 ++++ .../architecture/test_import_lint_policy.py | 10 +++++++ 6 files changed, 53 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fb5405..984a666 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + workflow_dispatch: schedule: - cron: "0 6 * * 1" @@ -76,6 +77,9 @@ jobs: - name: Run linters and type checks (pre-push stage) run: make prepush-check + - name: Verify public API type completeness + run: make type-completeness + - name: Run CI/CD policy contract tests run: make ci-contracts diff --git a/.local/public-api-surface-improvements.md b/.local/public-api-surface-improvements.md index 3db58a1..adae2c9 100644 --- a/.local/public-api-surface-improvements.md +++ b/.local/public-api-surface-improvements.md @@ -148,7 +148,7 @@ all callers (CLI, tests). | P1-02 | pyright `--verifytypes` ratchet gate | DONE | P0-01 | | P1-03 | Import-cost contract test | DONE | P0-03 | | P1-04 | ruff TID251 boundary lint | DONE | — | -| P1-05 | CI wiring for the new gates | TODO | P1-01..04 | +| P1-05 | CI wiring for the new gates | DONE | P1-01..04 | **P1-01** — Add `griffe` to the dev extra (`uv lock` refresh; `make lock-check` must pass). Script `scripts/dump_public_api.py` dumps names + signatures + annotations for @@ -309,6 +309,30 @@ Template: - Deviations / follow-ups: … ``` +### 2026-07-09 21:00 — P1-05 done +- What: Wired `make type-completeness` into CI code-quality, added + `workflow_dispatch` to CI so the validation workflow can be dispatched for this + phase, and extended CI contract tests to require the new gate and snapshot test + presence. Publish workflows were untouched. +- Evidence: `make workflow-lint ci-contracts` → actionlint passed and CI contracts + `22 passed`; `make prepush-check` → Ruff, Black, isort, mypy, import-lint, and + pyright hooks passed; `gh workflow run ci.yml --ref refactor/public-api-hardening-p1` + → dispatched run `https://github.com/jsugg/ser/actions/runs/29058563279`; `gh run + view 29058563279 --json status,conclusion,jobs,url,headSha,displayTitle` → + `status=completed`, `conclusion=success`, `headSha=f89c63c...`; code-quality job + completed successfully including `Verify public API type completeness`; required-ci + completed successfully. +- Deviations / follow-ups: CI initially had no `workflow_dispatch` trigger, so the + first `gh workflow run ci.yml --ref refactor/public-api-hardening-p1` returned + HTTP 422; added dispatch support to CI only, then reran successfully. + +### 2026-07-09 20:40 — P1-05 started +- What: Wire the new public API gates into CI quality contracts without touching + publish workflows. +- Evidence: P1-01 snapshot test is collected by full pytest; P1-02 + `type-completeness` exists locally and now needs CI workflow coverage. +- Deviations / follow-ups: none. + ### 2026-07-09 20:40 — P1-04 done - What: Broadened Ruff TID251 to ban `ser._internal` for public source files, changed the TID251 lane to scan public `ser` files only, and made per-file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c994024..e679c75 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,9 +10,10 @@ repos: rev: v0.15.1 hooks: - id: ruff - args: [--fix] + args: [--fix, --ignore, TID251] stages: [pre-commit] - id: ruff + args: [--ignore, TID251] stages: [pre-push] - repo: https://github.com/psf/black @@ -44,6 +45,12 @@ repos: - repo: local hooks: + - id: import-lint + name: import-lint + entry: bash scripts/run_import_lint.sh + language: system + pass_filenames: false + stages: [pre-push] - id: pyright name: pyright entry: uv run --frozen --extra dev pyright --pythonversion 3.12 ser tests diff --git a/Makefile b/Makefile index 3868afd..744842b 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ setup-runtime: fmt: uv run --frozen --extra dev pyupgrade --py312-plus --exit-zero-even-if-changed $$(rg --files ser tests -g '*.py') - uv run --frozen --extra dev ruff check --fix ser tests + uv run --frozen --extra dev ruff check --fix --ignore TID251 ser tests uv run --frozen --extra dev isort ser tests uv run --frozen --extra dev black ser tests diff --git a/tests/suites/integration/architecture/test_ci_workflow_contracts.py b/tests/suites/integration/architecture/test_ci_workflow_contracts.py index c3d94b8..d2abfd6 100644 --- a/tests/suites/integration/architecture/test_ci_workflow_contracts.py +++ b/tests/suites/integration/architecture/test_ci_workflow_contracts.py @@ -210,14 +210,19 @@ def test_ci_required_aggregate_covers_all_critical_gates(repo_root: Path) -> Non def test_ci_visibility_and_lock_controls_are_wired(repo_root: Path) -> None: """Default CI should run lock, workflow, report, and artifact visibility controls.""" workflow = _workflow(repo_root, "ci.yml") + assert "workflow_dispatch" in _event_names(workflow) code_quality_commands = _run_commands(_job(workflow, "code-quality")) assert "make lock-check" in code_quality_commands + assert "make type-completeness" in code_quality_commands assert "make ci-contracts" in code_quality_commands assert "make workflow-lint" in code_quality_commands test_commands = _run_commands(_job(workflow, "tests")) assert "--junitxml=reports/pytest/pytest-${{ matrix.python-version }}.xml" in test_commands + assert ( + repo_root / "tests/suites/integration/architecture/test_public_api_snapshot.py" + ).exists() coverage_job = _job(workflow, "coverage") assert "make test-cov" in _run_commands(coverage_job) diff --git a/tests/suites/integration/architecture/test_import_lint_policy.py b/tests/suites/integration/architecture/test_import_lint_policy.py index 35f091c..9259af8 100644 --- a/tests/suites/integration/architecture/test_import_lint_policy.py +++ b/tests/suites/integration/architecture/test_import_lint_policy.py @@ -67,3 +67,13 @@ def test_import_lint_lane_runs_boundary_contract_tests(repo_root: Path) -> None: assert "ruff check --select TID251" in script assert "tests/suites/integration/architecture/test_api_import_boundary.py" in script assert "tests/suites/integration/architecture/test_import_lint_policy.py" in script + + +def test_prepush_lint_routes_tid251_through_import_lint(repo_root: Path) -> None: + """Pre-push Ruff should avoid broad TID251 and use the public-boundary lane.""" + config = (repo_root / ".pre-commit-config.yaml").read_text(encoding="utf-8") + + assert "args: [--fix, --ignore, TID251]" in config + assert "args: [--ignore, TID251]" in config + assert "id: import-lint" in config + assert "entry: bash scripts/run_import_lint.sh" in config