Skip to content

Commit b8dcaa2

Browse files
mstoclaude
andauthored
chore: annotate parser type_ arg as TypeAnnotation (#302)
## Summary Fixes #301 Annotates the `type_` parameter on `list_parser`, `set_parser`, `tuple_parser`, `dict_parser`, and `_get_parser` with `fgpyo.util.types.TypeAnnotation` — the existing alias for the union of runtime objects (`type | typing._GenericAlias | UnionType | types.GenericAlias`) these parsers receive (`list[int]`, `int | None`, `Literal["a", "b"]`, etc.). `TypeAlias` is the marker for declaring aliases (`Foo: TypeAlias = ...`), so `TypeAnnotation` is the better fit at this position. **Annotation-only — runtime behavior is unchanged**, and the four public parser entry points keep the same dispatch logic and return types. Callers passing the kinds of annotation objects already exercised by `tests/fgpyo/util/test_inspect.py` are unaffected. With the more specific annotation in place, mypy can now check these signatures meaningfully. That lets us drop five `# type: ignore[comparison-overlap]` comments on the `typing.get_origin(...) is list/set/tuple/dict/Literal` branches, and remove a vestigial `parser: partial[type_]` forward declaration. A few internal calls (dict lookup keyed by `type_`, `partial(type_)` in the constructor branches, and calls into helpers in `types.py` that still take `Type[UnionType]` / `Type[LiteralType]`) get narrow `# type: ignore[...]` comments — these paths are runtime-safe today, and widening the `types.py` helper signatures would let the ignores collapse together in a follow-up. ## Test plan - \`uv run poe fix-and-check-all\` is clean (ruff, mypy, 842 pytest, 17 doctests). - No new tests — the four public parsers' dispatch paths are already covered in \`tests/fgpyo/util/test_inspect.py\`. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c627d50 commit b8dcaa2

2 files changed

Lines changed: 47 additions & 48 deletions

File tree

fgpyo/util/inspect.py

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from dataclasses import is_dataclass as is_dataclasses_class
1111
from enum import Enum
1212
from functools import partial
13-
from pathlib import PurePath
1413
from typing import TYPE_CHECKING
1514
from typing import Any
1615
from typing import ClassVar
@@ -21,6 +20,7 @@
2120
from typing import TypeVar
2221

2322
import fgpyo.util.types as types
23+
from fgpyo.util.types import TypeAnnotation
2424

2525
attr: python_types.ModuleType | None
2626
MISSING: frozenset[Any]
@@ -150,7 +150,7 @@ def split_at_given_level(
150150

151151

152152
def list_parser(
153-
cls: type, type_: TypeAlias, parsers: dict[type, Callable[[str], Any]] | None = None
153+
cls: type, type_: TypeAnnotation, parsers: dict[type, Callable[[str], Any]] | None = None
154154
) -> partial:
155155
"""
156156
Returns a function that parses a "stringified" list into a `List` of the correct type.
@@ -179,7 +179,7 @@ def list_parser(
179179

180180

181181
def set_parser(
182-
cls: type, type_: TypeAlias, parsers: dict[type, Callable[[str], Any]] | None = None
182+
cls: type, type_: TypeAnnotation, parsers: dict[type, Callable[[str], Any]] | None = None
183183
) -> partial:
184184
"""
185185
Returns a function that parses a stringified set into a `Set` of the correct type.
@@ -210,7 +210,7 @@ def set_parser(
210210

211211

212212
def tuple_parser(
213-
cls: type, type_: TypeAlias, parsers: dict[type, Callable[[str], Any]] | None = None
213+
cls: type, type_: TypeAnnotation, parsers: dict[type, Callable[[str], Any]] | None = None
214214
) -> partial:
215215
"""
216216
Returns a function that parses a stringified tuple into a `Tuple` of the correct type.
@@ -254,7 +254,7 @@ def tuple_parse(tuple_string: str) -> tuple[Any, ...]:
254254

255255

256256
def dict_parser(
257-
cls: type, type_: TypeAlias, parsers: dict[type, Callable[[str], Any]] | None = None
257+
cls: type, type_: TypeAnnotation, parsers: dict[type, Callable[[str], Any]] | None = None
258258
) -> partial:
259259
"""
260260
Returns a function that parses a stringified dict into a `Dict` of the correct type.
@@ -307,7 +307,7 @@ def dict_parse(dict_string: str) -> dict[Any, Any]:
307307

308308

309309
def _get_parser( # noqa: C901
310-
cls: type[Any], type_: TypeAlias, parsers: dict[type, Callable[[str], Any]] | None = None
310+
cls: type[Any], type_: TypeAnnotation, parsers: dict[type, Callable[[str], Any]] | None = None
311311
) -> partial:
312312
"""
313313
Attempts to find a parser for a provided type.
@@ -319,7 +319,6 @@ def _get_parser( # noqa: C901
319319
parsers: an optional mapping from type to the function to use for parsing that type (allows
320320
for parsing of more complex types)
321321
"""
322-
parser: partial[type_]
323322
if parsers is None:
324323
parsers = cls._parsers()
325324

@@ -328,13 +327,9 @@ def get_parser() -> partial: # noqa: C901
328327
nonlocal type_
329328
nonlocal parsers
330329
try:
331-
return functools.partial(parsers[type_])
330+
return functools.partial(parsers[type_]) # type: ignore[index]
332331
except KeyError as ex:
333-
if (
334-
type_ in [str, int, float]
335-
or isinstance(type_, type)
336-
and issubclass(type_, PurePath)
337-
):
332+
if types.is_known_str_constructible(type_):
338333
return functools.partial(type_)
339334
elif type_ is bool:
340335
return functools.partial(types.parse_bool)
@@ -346,13 +341,13 @@ def get_parser() -> partial: # noqa: C901
346341
raise ValueError("Unable to parse set (try typing.Set[type])") from ex
347342
elif type_ is dict:
348343
raise ValueError("Unable to parse dict (try typing.Mapping[type])") from ex
349-
elif typing.get_origin(type_) is list: # type: ignore[comparison-overlap]
344+
elif typing.get_origin(type_) is list:
350345
return list_parser(cls, type_, parsers)
351-
elif typing.get_origin(type_) is set: # type: ignore[comparison-overlap]
346+
elif typing.get_origin(type_) is set:
352347
return set_parser(cls, type_, parsers)
353-
elif typing.get_origin(type_) is tuple: # type: ignore[comparison-overlap]
348+
elif typing.get_origin(type_) is tuple:
354349
return tuple_parser(cls, type_, parsers)
355-
elif typing.get_origin(type_) is dict: # type: ignore[comparison-overlap]
350+
elif typing.get_origin(type_) is dict:
356351
return dict_parser(cls, type_, parsers)
357352
elif isinstance(type_, type) and issubclass(type_, Enum):
358353
return types.make_enum_parser(type_)
@@ -365,7 +360,7 @@ def get_parser() -> partial: # noqa: C901
365360
union=type_,
366361
parsers=[_get_parser(cls, arg, parsers) for arg in typing.get_args(type_)],
367362
)
368-
elif typing.get_origin(type_) is Literal: # type: ignore[comparison-overlap]
363+
elif typing.get_origin(type_) is Literal:
369364
return types.make_literal_parser(
370365
type_,
371366
[_get_parser(cls, type(arg), parsers) for arg in typing.get_args(type_)],

fgpyo/util/types.py

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from collections.abc import Sequence
99
from enum import Enum
1010
from functools import partial
11+
from pathlib import PurePath
1112
from types import UnionType
1213
from typing import Literal
1314
from typing import TypeAlias
@@ -25,6 +26,21 @@
2526
T = TypeVar("T")
2627

2728

29+
# NB: since `_GenericAlias` is a private attribute of the `typing` module, mypy doesn't find it
30+
TypeAnnotation: TypeAlias = type | typing._GenericAlias | UnionType | types.GenericAlias # type: ignore[name-defined]
31+
"""
32+
A function parameter's type annotation may be any of the following:
33+
1) `type`, when declaring any of the built-in Python types
34+
2) `typing._GenericAlias`, when declaring generic collection types or union types using pre-PEP
35+
585 and pre-PEP 604 syntax (e.g. `List[int]`, `Optional[int]`, or `Union[int, None]`)
36+
3) `types.UnionType`, when declaring union types using PEP604 syntax (e.g. `int | None`)
37+
4) `types.GenericAlias`, when declaring generic collection types using PEP 585 syntax (e.g.
38+
`list[int]`)
39+
`types.GenericAlias` is a subclass of `type`, but `typing._GenericAlias` and `types.UnionType` are
40+
not and must be considered explicitly.
41+
"""
42+
43+
2844
class InspectException(Exception): # noqa: N818
2945
"""Raised when type inspection or parsing fails."""
3046

@@ -64,40 +80,28 @@ def make_enum_parser(enum: type[EnumType]) -> partial:
6480
return partial(_make_enum_parser_worker, enum)
6581

6682

67-
def is_constructible_from_str(type_: type) -> bool:
68-
"""Returns true if the provided type can be constructed from a string."""
83+
def is_known_str_constructible(type_: TypeAnnotation) -> TypeGuard[type]:
84+
"""
85+
Returns true if `type_` is one of the built-in types known to be constructible from a str.
86+
87+
Complements `is_constructible_from_str`, which detects str-constructibility via constructor
88+
signature inspection. This predicate covers types whose constructors aren't annotated for
89+
introspection (e.g. `int`, `str`, `float`) or whose subclasses don't all share an annotation
90+
(e.g. `PurePath`).
91+
"""
92+
return isinstance(type_, type) and (type_ in (str, int, float) or issubclass(type_, PurePath))
93+
94+
95+
def is_constructible_from_str(type_: TypeAnnotation) -> TypeGuard[type]:
96+
"""Returns true if the provided type is a class constructible from a single str argument."""
97+
if not isinstance(type_, type):
98+
return False
6999
try:
70100
sig = inspect.signature(type_)
71101
((argname, _),) = sig.bind(object()).arguments.items()
72-
except TypeError: # Can be raised by signature() or Signature.bind().
102+
except (TypeError, ValueError):
73103
return False
74-
except ValueError:
75-
# Can be raised for classes, if the relevant info is in `__init__`.
76-
if not isinstance(type_, type):
77-
raise
78-
else:
79-
if sig.parameters[argname].annotation is str:
80-
return True
81-
# FIXME
82-
# if isinstance(type_, type):
83-
# # signature() first checks __new__, if it is present.
84-
# return _is_constructible_from_str(type_.__init__(object(), type_))
85-
return False
86-
87-
88-
# NB: since `_GenericAlias` is a private attribute of the `typing` module, mypy doesn't find it
89-
TypeAnnotation: TypeAlias = type | typing._GenericAlias | UnionType | types.GenericAlias # type: ignore[name-defined]
90-
"""
91-
A function parameter's type annotation may be any of the following:
92-
1) `type`, when declaring any of the built-in Python types
93-
2) `typing._GenericAlias`, when declaring generic collection types or union types using pre-PEP
94-
585 and pre-PEP 604 syntax (e.g. `List[int]`, `Optional[int]`, or `Union[int, None]`)
95-
3) `types.UnionType`, when declaring union types using PEP604 syntax (e.g. `int | None`)
96-
4) `types.GenericAlias`, when declaring generic collection types using PEP 585 syntax (e.g.
97-
`list[int]`)
98-
`types.GenericAlias` is a subclass of `type`, but `typing._GenericAlias` and `types.UnionType` are
99-
not and must be considered explicitly.
100-
"""
104+
return sig.parameters[argname].annotation is str
101105

102106

103107
def _is_optional(dtype: TypeAnnotation) -> bool:

0 commit comments

Comments
 (0)