Datagrunt 4.5.11: Widening the MyPy Gate to the Whole Source Tree and Closing the Native Stub Drift Gap with Stubtest

July 31, 2026by Martin Graham

Datagrunt 4.5.11 finishes a promise made two releases ago. Datagrunt 4.5.8 introduced a formal PEP 544 ComputeBackendProtocol, handwritten type stubs for our compiled Rust extension, and a blocking MyPy gate in CI — but that gate was deliberately scoped to three files: the dispatcher module, the Protocol definition, and the stub file itself. Everything else in src/datagrunt, including the twelve call sites that actually invoke the compute backend, sat outside it.

This release, delivered in PR #324 and closing issue #314, widens that gate to the entire source tree, makes the pure-Python parity oracle signature-strict rather than merely member-strict, and adds a stubtest check that catches the one class of drift the original contract could not: a compiled Rust signature change that ships a stale stub without a single test noticing.


The Challenge: An Honest Ratchet, Not a Rewrite

Issue #314 predates 4.5.8. Its original framing was blunt — src/datagrunt/py.typed had shipped for releases already, promising downstream type checkers a verified typed surface, while nothing in our own dev dependencies or CI actually verified it. Datagrunt 4.5.8 (PR #316) resolved that framing by adding MyPy to dev dependencies and a blocking gate, scoped to the three files doing the heaviest lifting. Rather than close #314, we retitled it: four in-code comments already pointed at the issue number, and the remaining widening work — call sites, the oracle, the stub, the public API — still needed a home.

The retitled issue also corrected its own cost estimate. The original body had justified narrow scope by citing “948 findings.” That number was a ruff --select ANN count, not a MyPy count — a proxy for a much larger, much scarier problem than the one that actually existed. Measured after 4.5.8 shipped:

$ mypy src/datagrunt
42 errors   # 13 import-untyped (third-party), 29 substantive

Widening MyPy across the whole tree was roughly 20x cheaper than the issue’s own opening paragraph had claimed. That correction changed the sequencing: instead of one large, risky flip, #314 laid out five ratchet steps in cost order, cheapest and highest-value first.

  graph LR
    A["#314 retitled<br/>(3-file gate, 4.5.8)"] --> B["1. backend() call sites<br/>12 sites, 0 findings"]
    B --> C["2. _compute_python oracle<br/>signature-strict Protocol"]
    C --> D["3. _native.pyi<br/>stubtest, blocking"]
    D --> E["4. Public API bases<br/>ClassVar sentinels"]
    E --> F["5. files = ['src/datagrunt']<br/>check_untyped_defs = true"]
    F --> G["#314 closed<br/>(4.5.11)"]

PR #324 executed all five steps as five separate commits (plus one follow-up fix), each proven non-vacuous before being trusted: inject a type error, watch the gate turn red, revert, watch it go green again.

Surface 4.5.8 (before) 4.5.11 (after)
[tool.mypy] files 3 files (_compute.py, _compute_protocol.py, _native.pyi) ["src/datagrunt"] — the whole tree
Unannotated function bodies Not checked (check_untyped_defs unset) Checked tree-wide (check_untyped_defs = true)
backend() call sites (12) Outside files; enclosing methods unannotated Checked; zero findings
_compute_python (the oracle) Excluded on purpose; member-presence-strict only disallow_untyped_defs; signature-strict
_native.pyi vs. compiled module Name-set equality only (AST test) + stubtest --ignore-positional-only, blocking
Public API base classes Bare = None sentinels (MyPy infers None) ClassVar[str | None]

1. The backend() Call Sites: Zero Findings, Full Coverage

The single highest value-to-cost step was also the one that had been sitting exposed the longest. backend() itself was already annotated to return ComputeBackendProtocol, but its twelve call sites — ten in csvcomponents.py, one each in excelcomponents.py and parquetcomponents.py — were unchecked for two independent reasons: the files weren’t listed under [tool.mypy] files, and their enclosing methods were unannotated with check_untyped_defs off. A bad argument at any of these call sites passed silently in either direction. A representative site, before this release, looked like:

def infer_csv_file_delimiter(self):
    """Infer the delimiter of the CSV file via the active compute backend.

    Routes to ``_compute.backend().infer_delimiter`` (Rust by default; pure
    Python when the toggle is on), reproducing the same precedence: safe
    delimiter > consistent punctuation > space > comma, with TSV-extension
    and empty/blank handling.
    """
    return _compute.backend().infer_delimiter(str(self.filepath))

No return annotation on the enclosing method, and csvcomponents.py wasn’t in files at all, so nothing verified that str(self.filepath) was the argument ComputeBackendProtocol.infer_delimiter actually expects, or that its str return flowed correctly to every caller.

Both conditions had to be fixed together. Widening files alone does nothing to an unannotated method body; flipping check_untyped_defs alone does nothing to a file MyPy never reads. We verified this the boring way before relying on it: with a file merely added to files, a bare x: int = "str" at module scope produced no diagnostic until check_untyped_defs was also on.

Adding the three call-site files with check_untyped_defs = true reported zero findings. That is the correct outcome for a codebase that has been passing its differential parity suite for months — it is also the whole point of gating them now, before a future call site can drift.


2. Making the Oracle Signature-Strict, Not Just Member-Strict

This is the part of the release worth sitting with. Recall the TYPE_CHECKING conformance assignment 4.5.8 introduced in _compute.py:

if TYPE_CHECKING:
    _python_backend: ComputeBackendProtocol = _compute_python
    _rust_backend: ComputeBackendProtocol = _native

With _compute_python unannotated, this line was real but weaker than it looked. An unannotated function’s parameters and return value are implicit Any to MyPy, and Any is assignment-compatible with everything — so the conformance check could only ever confirm that each Protocol member existed as a callable on the oracle module. It caught a renamed or deleted function. It could not catch a function that kept its name but returned the wrong type, or dropped an argument.

4.5.11 annotates every function in _compute_python.py with the exact ComputeBackendProtocol types — StrPath, HeaderProbe, SniffedDialect — imported from _compute_protocol:

def is_legacy_mac_newlines(filepath: StrPath) -> bool: ...
def count_leading_comments(filepath: StrPath) -> int: ...
def leading_rows(filepath: StrPath, limit: int) -> list[str]: ...

Notice filepath, not path. The Rust stub uses path; the oracle keeps its own name. That is not an oversight — it is why every ComputeBackendProtocol method ends in a positional-only marker (/), a design decision from 4.5.8 made exactly so the two backends’ parameter names are free to differ without breaking the contract. The oracle module is now listed alongside _native, _compute, and _compute_protocol under the strict disallow_untyped_defs override, so it is held to the same bar as the Rust-facing stub.

One cast survived review with a comment rather than being designed away: sniff_dialect’s return value narrows csv.Dialect.quotechar from typeshed’s str | None down to str, because CPython’s own Sniffer.sniff() unconditionally sets quotechar = quotechar or '"' before returning — never None. warn_redundant_casts is already on, so if a future typeshed release narrows the upstream type itself, the cast becomes redundant and the gate says so.

No logic changed in this step. The parity suite, not the type checker, remains the runtime witness that both backends agree.


3. Gating _native.pyi Signature Drift with stubtest

4.5.8 shipped an AST-level test, test_stub_matches_the_compiled_module, that parses _native.pyi and compares its function names against dir(_native). It is a real guard, but it compares name sets only. A Rust change that keeps a function’s name but changes its arity or argument types leaves that test green while the stub — which ships inside the wheel — quietly becomes wrong. Downstream type checkers consume that stub, not the compiled binary.

4.5.11 adds stubtest as a second, blocking CI step:

      # The pytest stub guard compares name sets only; stubtest compares
      # signatures, so a Rust arity/type change cannot ship a stale stub.
      - name: "stubtest (blocking — _native.pyi vs compiled module)"
        run: .venv/bin/python -m mypy.stubtest datagrunt._native --ignore-positional-only

Turning it on surfaced exactly one finding: a missing __all__ in _native.pyi, now added with all eleven exported function names. --ignore-positional-only stays on deliberately, for the same reason the Protocol is positional-only — the two backends’ parameter names are allowed to differ.

Worth stating plainly: against a compiled PyO3 module, stubtest --ignore-positional-only catches arity and name drift only. Compiled positional-only parameters expose no runtime type information for stubtest to compare, so it cannot itself verify that a stub’s declared types match the binary’s real ones. That verification still comes from the MyPy Protocol conformance line in step 2 — stubtest and the mypy gate are complementary, not redundant. (An earlier version of the plan proposed proving stubtest’s non-vacuity by swapping two argument types in the stub; that proof would itself have been vacuous against a compiled module for the same reason. Review caught it, and the version that landed breaks arity instead — a check stubtest can actually see.)


4. ClassVar: The Bare = None Sentinel, Four Times Over

Bringing the public API packages (csv_api, excel_api, parquet_api, pdf_api) under the gate turned up the same defect shape four times: each _engine_backed.py module declares a class attribute as a bare sentinel —

_engine_role = None  # "reader" or "writer"

— which MyPy infers as type None, not str | None. Every subclass that assigns a real role string to it is then a type error, at every one of the four base classes. The fix is the same at each site:

_engine_role: ClassVar[str | None] = None  # "reader" or "writer"

ClassVar because no instance ever assigns over it — subclasses set it at class scope, matching how it’s actually used — confirmed by grepping both src and tests before landing the change. Annotation-only; nothing about _engine_role’s runtime behavior moves.


5. Silencing Third-Party Stubs in Config, and the Whole-Tree Flip

Checking the whole tree meant meeting several third-party libraries with no stubs and no py.typed marker: pytesseract, pypdfium2, xlsxwriter. Each is silenced with ignore_missing_imports = true in a [[tool.mypy.overrides]] block in pyproject.toml — never with an inline # type: ignore, so every suppression is visible in one place rather than scattered through the source.

pyarrow joined that list for a subtler reason than the other three. Its py.typed marker is version-dependent, not absent. The local dev environment resolves pyarrow==24.0.0, which does ship py.typed — MyPy sees it and checks real types, and the override is inert there. CI’s fresh dependency resolve landed on pyarrow==25.0.0, whose marker MyPy can’t see, producing nine import-untyped errors across seven files that had never appeared locally. Same library, same >=24.0.0 floor, just a marker-visibility difference between two versions of the same dependency — not a code problem, and safe to silence the same way as the other three: ignore_missing_imports only suppresses the “missing stubs” diagnostic, it never substitutes Any for a type MyPy can actually resolve.

The same final commit also cleared the remaining tree-wide inventory, which turned out to be variations on a theme rather than new categories of defect. CSVEngineFactory.READER_ENGINES/WRITER_ENGINES are dicts literal-initialized with three concrete engine classes each; typed naively as dict[str, type[CSVBaseReaderEngine]], MyPy widens the values to the shared (abstract) base and then flags the dict itself as trying to instantiate an abstract class. ClassVar[dict[str, Callable[..., CSVBaseReaderEngine]]] describes the exact same runtime values — three concrete callables — without tripping that check. And DataFrameDerivedReaderMixin.to_dataframe, which the mixin calls but never defines, needed a declared-not-assigned annotation (to_dataframe: Callable[..., pl.DataFrame]) so MyPy understands the concrete class it’s mixed into is the one supplying the real method.

With the call sites, the oracle, the stub, and the public API bases all clean, the final commit flipped the gate itself:

[tool.mypy]
files = ["src/datagrunt"]
check_untyped_defs = true

All four in-code comments that had referenced #314 — two in pyproject.toml, two in .github/workflows/ci.yml — were retired along with the issue.


Zero Runtime Behavior Change

Every gate ran clean, and both backends’ test counts matched their pre-branch baseline exactly — the expected signature of an annotation-only branch:

Gate Result
mypy (whole tree, config-driven) Success, 60 source files
stubtest datagrunt._native --ignore-positional-only Success
pytest tests/ -q (Rust backend) 1,476 passed
DATAGRUNT_DISABLE_RUST=1 pytest tests/ -q (Python backend) 1,476 passed
pytest tests/parity/ -q (differential parity suite) 666 passed
ruff check + ruff format --check clean

Two edits went beyond pure annotation, and both are worth disclosing rather than burying in a diff. In pymupdf_backend.py, Image.frombytes’s size argument moved from a list literal to a tuple literal — PIL only ever indexes it, so this is observationally identical, but frombytes is typed to want tuple[int, int], not list[Any]. And in pdf_io/engines.py, PDFBaseWriterEngine._reader gained a declared @abstractmethod, where before it was only ever called (self._reader()) and implemented by both concrete engine subclasses without ever being declared abstract on the base itself. The base class was already effectively abstract — render_pages_as_images is a pre-existing abstractmethod on the same class — and nothing in src or tests instantiates PDFBaseWriterEngine directly. Both changes are annotation-adjacent, not behavioral, and both are called out explicitly rather than folded silently into the rest of the ratchet.


Why This Matters

src/datagrunt/py.typed has shipped in every recent wheel, which is datagrunt’s promise to downstream type checkers that our public surface is verified, not just annotated. Before this release, that promise was actually checked for three files. After it, the whole tree is checked, and the stub that ships inside the wheel cannot silently drift from the compiled Rust module underneath it without a blocking CI failure.

There is no new API, and no user-facing behavior changed — the point of this release is entirely underneath the surface. The payoff shows up in the editor and CI of everyone who depends on datagrunt: better autocomplete, and type errors caught in their type checker before their code ever runs, instead of surfacing later as a runtime AttributeError or a silent wrong-type bug we’d have to trace back ourselves.

For an enterprise data-engineering dependency, that distinction matters more than it might for a typical library. Datagrunt’s dual-backend design means a caller can be running against Rust today and the pure-Python oracle tomorrow, under a toggle they may not even control directly. A typed surface that is only verified in three files is a promise with three files of evidence behind it; a typed surface verified across the whole tree, with the shipped stub gated against the compiled binary it describes, is a promise we can actually stand behind.


Upgrading to Datagrunt v4.5.11

This upgrade changes nothing about how you call datagrunt. It only strengthens what we and MyPy verify before code ships:

# Force upgrade via uv
uv pip install --upgrade datagrunt

# Verify installation details
python -c "import datagrunt; print(f'Version: {datagrunt.__version__}')"

To see how the Rust and Python compute backends fit together, and why the Protocol between them is positional-only by design, visit our CSV Engines and Rust Acceleration Guide.