assert_that() hands back a different set of methods depending on what you gave it, and four checkers have to
agree on which set. Read this before changing a public signature. None of it is discoverable from the files.
The runtime is a stack of mixins. AssertionBuilder inherits StringMixin, NumericMixin, DictMixin and a
dozen more, so every assertion exists on every value and refuses what it cannot handle.
The typed surface is Protocols in assertpy2/_engine/_typing.py, one per kind of value, picked by an overload
ladder on assert_that in assertpy2/assertpy.py. A checker sees only the protocol, so
assert_that({}).is_positive() is a type error and assert_that(1).is_positive() is not.
Neither half knows about the other. Everything below keeps them from drifting apart.
Most of it is not yours to edit:
| file | protocols | declarations |
|---|---|---|
_engine/_typing.py |
43 | 414 |
_engine/_check_typing.py |
34 | 325 |
_engine/_builder_check_typing.py |
1 | 319 |
_engine/_capable_typing.py |
10 | 323 |
_engine/_poll_typing.py |
22 | 1499 |
_engine/_negated_typing.py |
23 | 1049 |
One is written by hand. tests/test_architecture_doc.py recomputes this table. No line counts, which
move on every edit to a generated file.
| file | written by | built from |
|---|---|---|
_engine/_typing.py |
hand | the protocols themselves |
assertpy.py overload ladder |
hand | which protocol each subject resolves to |
_engine/_poll_typing.py |
scripts/generate_poll_protocols.py |
_typing.py, assertpy.py, _engine/_operations.py |
_engine/_builder_check_typing.py |
scripts/generate_poll_protocols.py |
the same three |
_engine/_capable_typing.py |
scripts/generate_poll_protocols.py |
the runtime mixins under assertpy2/*.py, _engine/_operations.py |
_engine/_negated_typing.py |
scripts/generate_poll_protocols.py |
_typing.py and _engine/_operations.py |
_engine/_check_typing.py |
scripts/generate_check_protocols.py |
_typing.py, _engine/_operations.py |
One script writes four of them. That is the part people miss.
- polling twins answer
eventually()andeventually_sync(), so every assertion hands back the chain. - verdict twins answer
check(), returning anAssertionOutcomeinstead ofSelf. - the capability façade stands in for the builder itself, so it reads the mixins rather than the views:
reading the views would narrow it to what one value type answers. It skips its own hand-written list, and
takes which mixin methods pivot from
_engine/_operations.py, since the mixins write those-> Selftoo. - negation twins answer
.not_, where one assertion hands back the view the chain had before it.
_engine/_operations.py decides what the first two leave out: NOT_AN_OPERATION, WITHOUT_A_VERDICT.
uv run python scripts/regenerate.pyThat runs both generators, re-records tests/api_snapshot.json, which pins the public surface, and
re-records the table of sizes above. Read the diff it leaves.
The generators carry hard-coded import lines. A signature naming something the generated file did not
already import produces a file that will not compile, and ruff reports F821 Undefined name against a file you
never edited. Add the name to the import template in the generator.
The runtime comes first, because the rest is generated or recorded from it.
- Write the mixin, compose it into
AssertionBuilder's bases. - Add it to
_COVERAGEintests/test_protocol_parity.py, which has its own gate. - Declare the protocol in
_engine/_typing.py. - Add the arm to the
assert_thatladder inassertpy.py. - Pin it with
assert_typeintests/test_typing.py, the file the README badge is about. - Regenerate.
- Re-record the snapshot, last.
Regenerating before step 1 produces a stale façade. Recording the snapshot before the mixin is composed makes its gate red again.
The skipped step is the third.
- Write the method on its mixin.
- Declare it on the protocols it applies to in
_engine/_typing.py, and on those only. A declaration on_CoreAssertionoffers it to every value, which is the thing the typed surface exists to avoid. - Register it in
_engine/_operations.pyunless it is a plain assertion. No verdict goes inWITHOUT_A_VERDICTunder what it does instead, sincecheck()andnot_mean nothing on it and both used to accept them. A pivot that also asserts goes inALSO_ASSERTS, since reachingself.error()does not separate a verdict from a precondition. - If the polling twin is meant to leave it out, add the name to
_AFTER_A_CALLintests/test_poll_protocols.py. The whole caught-exception family is absent from that twin, and the list is what says so on purpose rather than by accident. - Regenerate. The generators emit what they find, so a missing declaration surfaces not here but in
test_protocol_parity.py::test_mixin_methods_are_declared_on_its_protocols.
A signature naming a type the generated files do not import produces a file that does not compile. The import lines are templates inside the generators.
| gate | the question it answers |
|---|---|
test_typing.py |
does each subject resolve to the protocol it should, under all four checkers |
test_pin_coverage.py |
does every declaration that changes the type have a pin above |
test_protocol_parity.py |
does every declared method exist on the runtime builder |
test_typing_conformance.py |
do the declaration and the runtime agree parameter by parameter |
test_poll_protocols.py |
are the polling twins what the generator produces today |
test_check_protocols.py |
are the verdict twins |
test_capable_protocol.py |
is the façade |
test_api_compatibility.py |
has the public surface moved without the snapshot being re-recorded |
test_typing_completeness.py |
can a checker name a type for every exported symbol |
test_pyright_baseline.py |
has a new pyright diagnostic appeared in the package |
test_typing_promises.py |
is the object at the end of a chain the type the checker was promised |
test_negated_protocols.py |
does every reachable view have a negation twin that hands the view back, and is the file what the generator produces |
test_architecture_doc.py |
does this document still describe the tree, counts included |
test_typing_negative.py |
do the checkers still refuse what they should |
test_typing_chain_corpus.py |
do the chains the chain model takes, to two pivots, still type the way they were recorded |
test_typing_from_a_wheel.py |
does the typed surface survive packaging |
test_typing_integrations.py, test_typing_http.py |
do real pandas, polars, numpy and HTTP values still resolve |
test_typing_harness.py |
is pyright's report read off its output alone, and refused where that is not one report |
test_overload_order.py |
is the frame overload still above every other shape-keyed one |
test_operation_contract.py |
is the register of operations that reach no verdict still the true one |
test_typing_claims.py |
are the four checkers still run with zero suppressions, as the badge says |
test_docs_typing.py |
do the snippets in the README and the guides type-check |
test_public_surface.py |
does import assertpy2 still give what it gave |
Which one goes red tells you what you did:
- a declaration the runtime does not have:
test_protocol_parity.py - a declaration whose parameters the runtime does not match:
test_typing_conformance.py - a generated file edited by hand: that file's own gate, one of the four
- a protocol changed without regenerating: the polling, verdict and negation gates, never the facade one, which reads the mixins instead
- a subject resolving to the wrong protocol:
test_typing.py, and only under whichever checker sees it - a public name added or moved:
test_api_compatibility.py, which is a snapshot rather than a rule
The four checkers are ty, mypy --strict, Pyright and Pyrefly. Where they disagree it is recorded, in
tests/typing_negative_baseline.py, tests/typing_integrations_baseline.py and
tests/typing_chain_corpus_baseline.json.
Pyright's engine is pinned in tests/typing_harness.py. The PyPI distribution is a launcher for a node package
and the two move at different speeds.
assert_that's implementation returnsAny. The real return made Pyright allocate four gigabytes against a protocol carrying the whole surface and die. The overloads are what callers see.- Some overlapping overloads are deliberate, recorded per method in
tests/pyright_baseline.py. - Some variance suggestions are refused, recorded there too, per TypeVar.
tyis scoped toassertpy2/andtests/test_typing.py. Over the whole tree it reports hundreds of diagnostics fromtests/, which is full of calls that must not type-check.
Which methods are offered. At run time every assertion exists on every value; a typed view offers only the ones that apply. That gap is the point of the typed surface.
What an offered method accepts. Here they must agree exactly, and both directions have shipped as defects:
- the type refuses what the runtime accepts, so working code is rejected
- the type accepts what the runtime refuses, so a checker approves a call that raises
The runtime is the fact and the declaration follows it. ClassInfo in _matcher_impls.py is the worked
example: recursive because isinstance accepts tuples nested to any depth.