Skip to content

feat: Union and tuple support for is_instance_of, and Union for is_instance_of_any - #33

Open
dpinol wants to merge 1 commit into
Solganis:mainfrom
dpinol:is_instance_union
Open

feat: Union and tuple support for is_instance_of, and Union for is_instance_of_any#33
dpinol wants to merge 1 commit into
Solganis:mainfrom
dpinol:is_instance_union

Conversation

@dpinol

@dpinol dpinol commented Aug 27, 2026

Copy link
Copy Markdown
Contributor
  • ClassInfo used in is_instance_of to support whatever python's isintance supports
  • TypeForm allows converting type hint type[U] to Assertion[U]
  • is_instance_of signature at _typing.py uses tuple[type | UnionType, ...] instead of ClassInfo to avoid ty error:

Variable of type tuple[<class 'bool'>, <class 'int'>] is not allowed in a type expression

@codspeed-hq

codspeed-hq Bot commented Aug 27, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 41 untouched benchmarks


Comparing dpinol:is_instance_union (5d2277b) with main (8083fe9)

Open in CodSpeed

@dpinol
dpinol force-pushed the is_instance_union branch from 09b266d to b68b277 Compare August 27, 2026 12:08
@dpinol dpinol changed the title feat: Union and tuple support for is_instance_of and is_instance_of_any feat: Union and tuple support for is_instance_of, and Union for is_instance_of_any Aug 27, 2026
@Solganis

Copy link
Copy Markdown
Owner

Thanks for this one. It is a real bug, and a bigger one than the title claims. Here is main today:

assert_that(Person()).is_instance_of(Person | Car)    # passes
assert_that(Person()).is_instance_of((Person, Car))   # passes
assert_that(Car()).is_instance_of(Person | str)       # AssertionFailure: ... instance of class <Union>
assert_that(Car()).is_instance_of((Person, str))      # AttributeError: 'tuple' object has no attribute '__name__'

The tuple form does not fail, it crashes, and an AttributeError out of my own formatting code carries none of the actual/expected/diff that docs/concepts/stability.md promises.

What makes it slightly embarrassing is that I fixed this once already and only on one half. Go through a matcher and the same case behaves:

assert_that({"who": Car()}).matches_structure({"who": match.is_instance_of((Person, str))})
# AssertionFailure: ... <Person, str>, but was <Car object> of type <Car>

That path uses _type_expression_name in assertpy2/_matcher_impls.py, which already knows about unions, tuples and nesting and has tests in tests/test_matchers.py. Could you use it instead of the new _class_name? Lift it somewhere neutral and call it from both sides. Two renderers answering the same question is how the halves drifted apart to begin with.

The rest of your change I want: ClassInfo on is_instance_of, the same widening on is_instance_of_any, and that shared renderer. One thing is missing from it, though. BaseMixin.is_instance_of in assertpy2/base.py still reads some_class: type, so at the moment only the checker-only declarations move and the runtime method is left behind.

The TypeForm[_U] overload I would rather defer, and not because I dislike it. I gate on four checkers and it fails one of them:

mypy --strict     accepts
ty                accepts
pyrefly           accepts
pyright 1.1.411   rejects: Argument of type "UnionType" cannot be assigned to
                  parameter of type "TypeForm[_U]", and the return becomes Unknown

Pyright does take it under enableExperimentalFeatures, but I cannot ask users to switch that on, since the whole promise is that this works with a stock checker. The Unknown return is the worse half: I took the exported surface to 100% type completeness this week and put a gate on it, and that return walks straight back through it. ClassInfo on its own passes all four, so the fix and the narrowing come apart cleanly. Deferring costs you the narrowing, not the fix, and I would happily take it as its own PR the day stock pyright supports TypeForm.

About the wall of red CI: that is mine, not yours, and there was no way to find it from the repository. Three files under assertpy2/_engine/ are generated, tests/api_snapshot.json records the public surface and has to be re-recorded when a signature changes, and the generators carry hard-coded import lines that need UnionType adding, which is where your F821 Undefined name 'ClassInfo' comes from. I walked into that same trap myself this week. Tell me the scope split works for you and I will push those commits to your branch so the PR stays yours, or if you would rather do it:

uv run python scripts/generate_poll_protocols.py
uv run python scripts/generate_check_protocols.py
ASSERTPY2_UPDATE_API=1 uv run pytest tests/test_api_compatibility.py

Either way, none of that being discoverable is a problem on my side, and writing it down has gone on my list.

Two small things on the tests. Catch AssertionFailure rather than AssertionError and read actual and expected off it, since that guarantee is what makes this a bug rather than a feature request. And is_instance_of_any wants a failing case too: its message goes from <Union> to the member names, and right now only the passing and typing side is covered.

@Solganis

Copy link
Copy Markdown
Owner

Pushed to main, so your branch shifts a little. Please rebase.

  • The renderer is now _type_expression_name in assertpy2/errors.py. Import it, nothing to lift.
  • Three siblings had the same defect and are fixed: is_subclass_of, caused_by, has_root_cause. On 3.10 they crashed on unions too, since __name__ only arrived on unions in 3.14.
  • tests/test_type_expression_failures.py is the gate for the class. Both of yours sit in its _PENDING table, and that entry fails the day this merges, which is the reminder to move them across.

Your scope is unchanged: is_instance_of and is_instance_of_any, plus BaseMixin.is_instance_of in assertpy2/base.py.

@dpinol

dpinol commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

The TypeForm[_U] overload I would rather defer, and not because I dislike it. I gate on four checkers and it fails one of them:

mypy --strict     accepts
ty                accepts
pyrefly           accepts
pyright 1.1.411   rejects: Argument of type "UnionType" cannot be assigned to
                  parameter of type "TypeForm[_U]", and the return becomes Unknown

How do you run pyright? With 1.1.413 (only on npm) I get no errors

(assertpy2) ➜ assertpy2 git:(is_instance_union) ✗ npx pyright tests/test_typing.py
0 errors, 0 warnings, 0 informations

And on 1.1.411 it's even more "clever"

(assertpy2) ➜ assertpy2 git:(is_instance_union) ✗ pyright --pythonversion 3.14 tests/test_typing.py
/home/dani/dev/python/assertpy2/tests/test_typing.py
/home/dani/dev/python/assertpy2/tests/test_typing.py:232:17 - error: "assert_type" mismatch: expected "_ObjectAssertion[bool | int]" but received "_BoolAssertion | _NumericAssertion[int]" (reportAssertTypeFailure)

@dpinol

dpinol commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

The rest of your change I want: ClassInfo on is_instance_of, the same widening on is_instance_of_any

Do you really want ClassInfo (which contains tuples) for is_instance_of_any?
I left type | UnionType because this looks weird:
assert_that(x).is_instance_of_any(float, (int,str))
when you can do
assert_that(x).is_instance_of_any(float, int, str)

@Solganis

Solganis commented Aug 27, 2026

Copy link
Copy Markdown
Owner

You were right, I measured 1.1.411. Both my objections fall:

                  completeness   tests/test_typing.py
pyright 1.1.411   100%           0 errors
pyright 1.1.413   100%           0 errors
mypy --strict / ty / pyrefly     clean

Only union call sites fail on 1.1.411, and main has none.
Take TypeForm whole.
I am pinning 1.1.413 on my side first, so your branch lands on a gate that understands it.

On is_instance_of_any I disagree, and it took a measurement.
Your style point stands, the flat form is the one to write.
But the runtime delegates to isinstance, which nests to any depth:

isinstance(1.5, (float, (int, str)))                     # True
assert_that(x).is_instance_of_any(float, (int, str))     # AttributeError: 'tuple' object has no attribute '__name__'

Same bug you reported, one level in.

ClassInfo had a comment saying it was non-recursive because not every checker understood a recursive alias.
Stale, written before pyrefly. Measured today on the package:

ClassInfo: TypeAlias = "type | UnionType | tuple[ClassInfo, ...]"

mypy --strict  clean   pyright 1.1.413  0 errors
ty             clean   pyrefly          0 errors

It is recursive on main now, so use ClassInfo there too. It permits the ugly spelling, it does not recommend it.

Still yours: is_instance_of, is_instance_of_any, and BaseMixin.is_instance_of in assertpy2/base.py, still some_class: type.

_type_expression_name in assertpy2/errors.py renders unions, tuples and nesting.

@dpinol
dpinol force-pushed the is_instance_union branch from b68b277 to fd1855f Compare August 27, 2026 20:16
@dpinol

dpinol commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Amazing, I amended my commit. Please let me know if I missed something.
btw, every day I learn a new assertpy2 feature. I love the verdict twins :-)

@Solganis

Solganis commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Keep TypeForm. I almost asked you to drop it, then measured. Eight arguments, mypy --strict and pyright 1.1.413:

argument main your branch isinstance
list[int] accepts accepts TypeError
Any mypy yes, pyright no same TypeError
Never, Literal[1], Annotated[int, ...] refuses refuses n/a
int | None refuses accepts True
int | list[int] refuses accepts depends

Removing TypeForm and keeping ClassInfo gives identical results on all eight.
So the two differences are ClassInfo, and list[int] and Any are ours already.

Known limitation, ours, not typeable:

isinstance(1,   int | list[int])   # True, `int` matched first
isinstance("x", int | list[int])   # TypeError
isinstance(1,   list[int] | int)   # TypeError

Your side, dead since you moved to _type_expression_name:

-def _class_name(some_class: type | UnionType) -> str:
-    if isinstance(some_class, UnionType):
-        return " | ".join(arg.__name__ for arg in some_class.__args__)
-    if isinstance(some_class, tuple):
-        return ", ".join(_class_name(arg) for arg in some_class)
-    return some_class.__name__

plus its from types import UnionType.

Mine, pushed to your branch once that is gone: the facade ladder in _capable_typing.py, and regeneration plus snapshot (needs UnionType and ClassInfo in three import templates, else 17 F821).

ClassInfo used in is_instance_of to support whatever python's isintance supports
TypeForm allows converting type[U]->Assertion[U]

is_instance_of signature at _typing.py uses tuple[type | UnionType, ...] instead of ClassInfo to avoid ty error: Variable of type `tuple[<class 'bool'>, <class 'int'>]` is not allowed in a type expression

_type_expression_name to also strip namespace from Union'ed types for consistency
Added class tests for is_instance_of_any
@dpinol
dpinol force-pushed the is_instance_union branch from fd1855f to 5d2277b Compare August 28, 2026 06:37
@dpinol

dpinol commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Cool, I removed the stale _capable_typing and UnionType
Was that all from my side?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants