Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions pyiron_workflow/type_hinting.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,31 +49,39 @@ def type_hint_to_tuple(type_hint) -> tuple:
return (type_hint,)


def _get_type_hints(type_hint) -> tuple[type | None, typing.Any]:
hint = typing.get_origin(type_hint)
if hint is typing.Annotated:
return typing.get_origin(type_hint.__origin__), type_hint.__origin__
else:
return hint, type_hint


def type_hint_is_as_or_more_specific_than(hint, other) -> bool:
hint_origin = typing.get_origin(hint)
other_origin = typing.get_origin(other)
hint_origin, hint_type = _get_type_hints(hint)
other_origin, other_type = _get_type_hints(other)
if {hint_origin, other_origin} & {types.UnionType, typing.Union}:
# If either hint is a union, turn both into tuples and call recursively
return all(
any(
type_hint_is_as_or_more_specific_than(h, o)
for o in type_hint_to_tuple(other)
for o in type_hint_to_tuple(other_type)
)
for h in type_hint_to_tuple(hint)
for h in type_hint_to_tuple(hint_type)
)
elif hint_origin is None and other_origin is None:
# Once both are raw classes, just do a subclass test
try:
return issubclass(hint, other)
return issubclass(hint_type, other_type)
except TypeError:
return hint == other
return hint_type == other_type
elif other_origin is None and hint_origin is not None:
# When the hint adds specificity to an empty origin
return hint_origin == other
return hint_origin == other_type
elif hint_origin == other_origin:
# If they both have an origin, break into arguments and treat cases
hint_args = typing.get_args(hint)
other_args = typing.get_args(other)
hint_args = typing.get_args(hint_type)
other_args = typing.get_args(other_type)
if len(hint_args) == 0 and len(other_args) > 0:
# Failing to specify anything is not being more specific
return False
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/test_type_hinting.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pint import UnitRegistry

from pyiron_workflow.type_hinting import (
_get_type_hints,
type_hint_is_as_or_more_specific_than,
valid_value,
)
Expand Down Expand Up @@ -95,15 +96,39 @@ def test_hint_comparisons(self):
typing.Callable[[int, float], float],
False,
),
(
typing.Annotated[int, "foo"],
int,
True,
),
(
int,
typing.Annotated[int, "foo"],
True,
),
]:
with self.subTest(
target=target, reference=reference, expected=is_more_specific
):
self.assertEqual(
type_hint_is_as_or_more_specific_than(target, reference),
is_more_specific,
msg=f"{target} is {'not ' if not is_more_specific else ''}more specific than {reference}",
)

def test_get_type_hints(self):
for hint, origin in [
(int | float, type(int| float)),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it really intentional to do type(int | float) and not type[int | float] here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does type[int | float] mean?

Copy link
Member

@liamhuber liamhuber Jan 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a type hint, that we expect the type int or the type float. In this case bool also works, since it's a subclass of int, but, e.g. string doesn't.

The int | float is generating a typing union, and we're treating type as a generic (i.e. takes subtyoes like list[], so what we've written is shorthand for x: type[int] | type[float].

Concretely, the following runs and mypyp will complain only about foo(str):

def foo(x: type[int, float]) -> None:
    print(x)

foo(int)  # Fine -- in the type hint
foo(bool)  # Fine -- subclass of int
foo(str)  # mypy complains


def equivalent_to_foo(x: type[int] | type[float]) -> None:
    print(x)

type(int | float) is an instance of types.UnionType. Looking at it again, I guess this is actually exactly what you want, since the hint int | float should indeed have a UnionType origin. It's not clear to me whether it matters that you put int | float inside the union type, as I don't know how detailed its == comparison is.

(typing.Annotated[int | float, "foo"], type(int | float)),
(int, None),
(typing.Annotated[int, "foo"], None),
(typing.Annotated[list[int], "foo"], list),
(list[int], list),
]:
with self.subTest(hint=hint, origin=origin):
self.assertEqual(_get_type_hints(hint)[0], origin)



if __name__ == "__main__":
unittest.main()
Loading