diff --git a/.github/workflows/ty.yml b/.github/workflows/ty.yml new file mode 100644 index 000000000..164dd99c4 --- /dev/null +++ b/.github/workflows/ty.yml @@ -0,0 +1,20 @@ +name: ty + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ty: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v4 + - name: Run ty + # We type-check the test suite (not src/) because tyro's public + # surface is exercised most realistically through tests, and ty + # catches regressions like #460 there. + run: uv run --extra dev-nn --python 3.13 ty check tests/ diff --git a/pyproject.toml b/pyproject.toml index 4b98f0157..39f3a2780 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dev = [ "omegaconf>=2.2.2", "attrs>=21.4.0", "pyright>=1.1.349,!=1.1.379", + "ty>=0.0.33", "mypy>=1.4.1", "pydantic>=2.5.2,!=2.10.0", "coverage[toml]>=6.5.0", @@ -143,3 +144,23 @@ target-version = "py312" [tool.pyright] pythonVersion = "3.13" ignore = ["**/_argparse.py", "./docs/**/*"] + +[tool.ty.environment] +python-version = "3.13" + +[[tool.ty.overrides]] +# Generated tests are reformatted from the originals; ruff sometimes moves +# `# ty: ignore` comments off the line where the diagnostic actually fires. +# The originals are checked strictly, so suppressing on the generated copies +# is safe. +include = ["tests/test_py311_generated/**"] +rules = { invalid-type-form = "ignore", invalid-assignment = "ignore", unused-ignore-comment = "ignore" } + +[tool.ty.rules] +# `# type: ignore` comments are written for mypy/pyright; ty's narrower set +# of diagnostics often makes them redundant from its POV. +unused-type-ignore-comment = "ignore" +# `get_parser` is intentionally exercised by helptext tests. +deprecated = "ignore" +redundant-cast = "ignore" +possibly-missing-submodule = "ignore" diff --git a/src/tyro/_cli.py b/src/tyro/_cli.py index 8f48f7bbe..6d3cd7a8d 100644 --- a/src/tyro/_cli.py +++ b/src/tyro/_cli.py @@ -7,9 +7,9 @@ import sys import warnings from contextlib import nullcontext -from typing import Callable, Literal, Sequence, TypeVar, cast, overload +from typing import Callable, Literal, Sequence, Type, TypeVar, cast, overload -from typing_extensions import Annotated, assert_never, deprecated +from typing_extensions import Annotated, TypeForm, assert_never, deprecated from . import ( _arguments, @@ -28,18 +28,57 @@ NonpropagatingMissingType, PropagatingMissingType, ) -from ._typing import TypeForm from .constructors import ConstructorRegistry from .constructors._primitive_spec import UnsupportedTypeAnnotationError OutT = TypeVar("OutT") -# The overload here is necessary for pyright and pylance due to special-casing -# related to using typing.Type[] as a temporary replacement for -# typing.TypeForm[]. -# -# https://github.com/microsoft/pyright/issues/4298 +# Two parallel sets of `f` overloads. `Type[OutT]` exists for pyright/pylance +# (see microsoft/pyright#4298 and the comment in `_resolver.py`); `TypeForm` +# exists for ty and any checker implementing PEP 747, and additionally covers +# patterns the `Type[T]` hack misses (e.g. `Annotated[A] | Annotated[B]`). +# Each checker picks the first overload it can match. + + +@overload +def cli( + f: Type[OutT], + *, + prog: None | str = None, + description: None | str = None, + args: None | Sequence[str] = None, + default: OutT + | NonpropagatingMissingType + | PropagatingMissingType = MISSING_NONPROP, + return_unknown_args: Literal[False] = False, + use_underscores: bool = False, + console_outputs: bool = True, + add_help: bool = True, + compact_help: bool = False, + config: None | Sequence[conf._markers.Marker] = None, + registry: None | ConstructorRegistry = None, +) -> OutT: ... + + +@overload +def cli( + f: Type[OutT], + *, + prog: None | str = None, + description: None | str = None, + args: None | Sequence[str] = None, + default: OutT + | NonpropagatingMissingType + | PropagatingMissingType = MISSING_NONPROP, + return_unknown_args: Literal[True], + use_underscores: bool = False, + console_outputs: bool = True, + add_help: bool = True, + compact_help: bool = False, + config: None | Sequence[conf._markers.Marker] = None, + registry: None | ConstructorRegistry = None, +) -> tuple[OutT, list[str]]: ... @overload @@ -124,8 +163,8 @@ def cli( ) -> tuple[OutT, list[str]]: ... -def cli( - f: TypeForm[OutT] | Callable[..., OutT], +def cli( # pyright: ignore[reportInconsistentOverload] + f: Type[OutT] | Callable[..., OutT], *, prog: None | str = None, description: None | str = None, @@ -288,7 +327,7 @@ class Config: @overload @deprecated("get_parser() is deprecated and will be removed in a future version.") def get_parser( - f: TypeForm[OutT], + f: Type[OutT], *, prog: None | str = None, description: None | str = None, @@ -323,7 +362,7 @@ def get_parser( @deprecated("get_parser() is deprecated and will be removed in a future version.") def get_parser( - f: TypeForm[OutT] | Callable[..., OutT], + f: Type[OutT] | Callable[..., OutT], *, # We have no `args` argument, since this is only used when # parser.parse_args() is called. @@ -388,7 +427,7 @@ def get_parser( def _cli_impl( - f: TypeForm[OutT] | Callable[..., OutT], + f: Type[OutT] | Callable[..., OutT], *, prog: None | str = None, description: None | str, diff --git a/src/tyro/_fields.py b/src/tyro/_fields.py index a345f9e6b..2889790cd 100644 --- a/src/tyro/_fields.py +++ b/src/tyro/_fields.py @@ -8,7 +8,7 @@ import functools import inspect import sys -from typing import Any, Callable, Dict, Literal, Tuple +from typing import Any, Callable, Dict, Literal, Tuple, Type import docstring_parser from typing_extensions import ( @@ -26,7 +26,6 @@ from . import _docstrings, _resolver, _strings, _unsafe_cache from . import _fmtlib as fmt from ._singleton import MISSING_NONPROP, is_missing -from ._typing import TypeForm from ._typing_compat import is_typing_annotated, is_typing_unpack from .conf import _confstruct, _markers from .constructors._registry import ConstructorRegistry, check_default_instances @@ -44,9 +43,9 @@ class FieldDefinition: intern_name: str extern_name: str - type: TypeForm[Any] | Callable + type: Type[Any] | Callable """Full type, including runtime annotations.""" - type_stripped: TypeForm[Any] | Callable + type_stripped: Type[Any] | Callable default: Any helptext: str | Callable[[], str | None] | None markers: set[Any] @@ -90,7 +89,7 @@ def from_field_spec(field_spec: StructFieldSpec) -> FieldDefinition: @staticmethod def make( name: str, - typ: TypeForm[Any] | Callable, + typ: Type[Any] | Callable, default: Any, helptext: str | Callable[[], str | None] | None, call_argname_override: Any | None = None, @@ -187,7 +186,7 @@ def make( return out def with_new_type_stripped( - self, new_type_stripped: TypeForm[Any] | Callable + self, new_type_stripped: Type[Any] | Callable ) -> FieldDefinition: if is_typing_annotated(get_origin(self.type)): new_type = Annotated[(new_type_stripped, *get_args(self.type)[1:])] # type: ignore @@ -202,7 +201,7 @@ def with_new_type_stripped( @_unsafe_cache.unsafe_cache(maxsize=1024) def is_struct_type( - typ: TypeForm[Any] | Callable, default_instance: Any, in_union_context: bool + typ: Type[Any] | Callable, default_instance: Any, in_union_context: bool ) -> bool: """Determine whether a type should be treated as a 'struct type', where a single type can be broken down into multiple fields (eg for nested dataclasses or @@ -230,14 +229,14 @@ def is_struct_type( def field_list_from_type_or_callable( - f: Callable | TypeForm[Any], + f: Callable | Type[Any], default_instance: Any, support_single_arg_types: bool, in_union_context: bool, ) -> ( UnsupportedStructTypeMessage | InvalidDefaultInstanceError - | tuple[Callable | TypeForm[Any], list[FieldDefinition]] + | tuple[Callable | Type[Any], list[FieldDefinition]] ): """Generate a list of generic 'field' objects corresponding to the inputs of some annotated callable. diff --git a/src/tyro/_fmtlib.py b/src/tyro/_fmtlib.py index 803c185de..148c406a1 100644 --- a/src/tyro/_fmtlib.py +++ b/src/tyro/_fmtlib.py @@ -351,7 +351,7 @@ def render(self, width: int) -> list[str]: return ["".join(parts) for parts in out_parts] -_FORCE_UTF8_BOXES = False +_FORCE_UTF8_BOXES: bool = False @final diff --git a/src/tyro/_parsers.py b/src/tyro/_parsers.py index ef66a5345..f722893dd 100644 --- a/src/tyro/_parsers.py +++ b/src/tyro/_parsers.py @@ -25,7 +25,6 @@ _subcommand_matching, ) from . import _fmtlib as fmt -from ._typing import TypeForm from ._typing_compat import is_typing_union from .conf import _confstruct, _markers from .constructors._primitive_spec import ( @@ -443,7 +442,7 @@ class SubparsersSpecification: extern_prefix: str required: bool default_instance: Any - options: Tuple[Union[TypeForm[Any], Callable], ...] + options: Tuple[Union[Type[Any], Callable], ...] prog_suffix: str @staticmethod diff --git a/src/tyro/_resolver.py b/src/tyro/_resolver.py index ef6fb0ff7..e5c0c9c48 100644 --- a/src/tyro/_resolver.py +++ b/src/tyro/_resolver.py @@ -39,7 +39,6 @@ from . import _unsafe_cache, conf from ._singleton import is_missing, is_sentinel -from ._typing import TypeForm from ._typing_compat import ( is_typing_annotated, is_typing_classvar, @@ -57,7 +56,12 @@ Python. types.UnionType was added in Python 3.10, and is created when the `X | Y` syntax is used for unions.""" -TypeOrCallable = TypeVar("TypeOrCallable", TypeForm[Any], Callable) +# `Type[T]` is used loosely throughout tyro as a stand-in for PEP 747's +# `TypeForm[T]`: it's accepted by pyright for arbitrary type forms (Annotated, +# unions, etc.) via microsoft/pyright#4298, and pragmatically conveys "any +# type expression" for runtime introspection. Switch to `TypeForm` once it +# lands in `typing` and is supported across checkers. +TypeOrCallable = TypeVar("TypeOrCallable", Type[Any], Callable) @dataclasses.dataclass(frozen=True) @@ -81,13 +85,13 @@ def unwrap_origin_strip_extras(typ: TypeOrCallable) -> TypeOrCallable: return typ -def is_dataclass(cls: Union[TypeForm, Callable]) -> bool: +def is_dataclass(cls: Union[Type, Callable]) -> bool: """Same as `dataclasses.is_dataclass`, but also handles generic aliases.""" return dataclasses.is_dataclass(unwrap_origin_strip_extras(cls)) # type: ignore # @_unsafe_cache.unsafe_cache(maxsize=1024) -def resolved_fields(cls: TypeForm) -> List[dataclasses.Field]: +def resolved_fields(cls: Type) -> List[dataclasses.Field]: """Similar to dataclasses.fields(), but includes dataclasses.InitVar types and resolves forward references.""" @@ -117,7 +121,7 @@ def resolved_fields(cls: TypeForm) -> List[dataclasses.Field]: return fields -def is_namedtuple(cls: TypeForm) -> bool: +def is_namedtuple(cls: Type) -> bool: return ( isinstance(cls, type) and issubclass(cls, tuple) @@ -126,7 +130,7 @@ def is_namedtuple(cls: TypeForm) -> bool: ) -TypeOrCallableOrNone = TypeVar("TypeOrCallableOrNone", Callable, TypeForm[Any], None) +TypeOrCallableOrNone = TypeVar("TypeOrCallableOrNone", Callable, Type[Any], None) def resolve_newtype_and_aliases( @@ -231,14 +235,14 @@ def swap_type_using_confstruct(typ: TypeOrCallable) -> TypeOrCallable: def narrow_collection_types( typ: TypeOrCallable, default_instance: Any ) -> TypeOrCallable: - """TypeForm narrowing for containers. Infers types of container contents.""" + """Type narrowing for containers. Infers types of container contents.""" # Can't narrow if we don't have a default value! if is_missing(default_instance): return typ # We'll recursively narrow contained types too! - def _get_type(val: Any) -> TypeForm: + def _get_type(val: Any) -> Type: return narrow_collection_types(type(val), val) args = get_args(typ) @@ -291,7 +295,7 @@ def _get_type(val: Any) -> TypeForm: @overload def unwrap_annotated( typ: TypeOrCallable, - search_type: TypeForm[MetadataType], + search_type: Type[MetadataType], ) -> Tuple[TypeOrCallable, Tuple[MetadataType, ...]]: ... @@ -311,7 +315,7 @@ def unwrap_annotated( def unwrap_annotated( typ: TypeOrCallable, - search_type: Union[TypeForm[MetadataType], Literal["all"], object, None] = None, + search_type: Union[Type[MetadataType], Literal["all"], object, None] = None, ) -> Union[Tuple[TypeOrCallable, Tuple[MetadataType, ...]], TypeOrCallable]: """Helper for parsing typing.Annotated types. @@ -384,7 +388,7 @@ def unwrap_annotated( class TypeParamResolver: - param_assignments: List[Dict[TypeVar, TypeForm[Any]]] = [] + param_assignments: List[Dict[TypeVar, Type[Any]]] = [] @classmethod def get_assignment_context(cls, typ: TypeOrCallable) -> TypeParamAssignmentContext: @@ -559,7 +563,7 @@ class TypeParamAssignmentContext: def __init__( self, origin_type: TypeOrCallable, - type_from_typevar: Dict[TypeVar, TypeForm[Any]], + type_from_typevar: Dict[TypeVar, Type[Any]], ): # `Any` is needed for mypy... self.origin_type: Any = origin_type @@ -649,7 +653,7 @@ def isinstance_with_fuzzy_numeric_tower( def resolve_generic_types( typ: TypeOrCallable, -) -> Tuple[TypeOrCallable, Dict[TypeVar, TypeForm[Any]]]: +) -> Tuple[TypeOrCallable, Dict[TypeVar, Type[Any]]]: """If the input is a class: no-op. If it's a generic alias: returns the origin class, and a mapping from typevars to concrete types.""" @@ -666,7 +670,7 @@ def resolve_generic_types( # We'll ignore NewType when getting the origin + args for generics. origin_cls = get_origin(typ) - type_from_typevar: Dict[TypeVar, TypeForm[Any]] = {} + type_from_typevar: Dict[TypeVar, Type[Any]] = {} # Support typing.Self. # We'll do this by pretending that `Self` is a TypeVar... diff --git a/src/tyro/_typing.py b/src/tyro/_typing.py deleted file mode 100644 index b7558ae08..000000000 --- a/src/tyro/_typing.py +++ /dev/null @@ -1,22 +0,0 @@ -"""tyro's API relies heavily on parsing type annotations, which may be both -"regular" types like `int`, `str`, and classes, or more general forms like `int -| str` or `Annotated[str, ...]`. - -To correctly annotate variables that can take these more general type forms, we -need to wait for the typing.TypeForm PEP: - - https://peps.python.org/pep-0747/ - -This is still in draft form, so in the meantime we use Type[T] everywhere we would -otherwise have TypeForm[T]. This mostly works, and fortunately is supported by -pyright and pylance (relevant: -https://github.com/microsoft/pyright/issues/4298), but should be switched for -the correct typing.TypeForm annotation once it's available. - -Also relevant: -- mypy support for typing_extensions.TypeForm: https://github.com/python/mypy/issues/9773 -""" - -from typing import Type as TypeForm - -__all__ = ["TypeForm"] diff --git a/src/tyro/constructors/_primitive_spec.py b/src/tyro/constructors/_primitive_spec.py index dd5e5bb5c..71d4da7af 100644 --- a/src/tyro/constructors/_primitive_spec.py +++ b/src/tyro/constructors/_primitive_spec.py @@ -36,7 +36,6 @@ from .. import _fmtlib as fmt from .. import _resolver, _strings -from .._typing import TypeForm from ..conf import _markers from ._backtracking import parse_with_backtracking @@ -56,11 +55,11 @@ def __init__(self, message: tuple[fmt._Text, ...]): class PrimitiveTypeInfo: """Information used to generate constructors for primitive types.""" - type: TypeForm + type: Type """Annotated field type. Forward references, aliases, and type variables/parameters will have been resolved and runtime annotations (typing.Annotated) will have been stripped.""" - type_origin: TypeForm | None + type_origin: Type | None """The output of get_origin() on the static type.""" markers: set[_markers.Marker] """Set of tyro markers used to configure this field.""" @@ -69,7 +68,7 @@ class PrimitiveTypeInfo: @staticmethod def make( - raw_annotation: TypeForm | Callable, + raw_annotation: Type | Callable, parent_markers: set[_markers.Marker], exclude_markers: set[_markers.Marker] | None = None, ) -> PrimitiveTypeInfo: @@ -85,7 +84,7 @@ def make( if exclude_markers is not None: markers = markers - exclude_markers return PrimitiveTypeInfo( - type=cast(TypeForm, typ), + type=cast(Type, typ), type_origin=get_origin(typ), markers=markers, _primitive_spec=primitive_spec, @@ -226,12 +225,12 @@ def basics_rule(type_info: PrimitiveTypeInfo) -> PrimitiveConstructorSpec | None def torch_device_rule( type_info: PrimitiveTypeInfo, ) -> PrimitiveConstructorSpec | None: - if type_info.type is not torch.device: + if type_info.type is not torch.device: # pyright: ignore[reportPrivateImportUsage] return None return PrimitiveConstructorSpec( nargs=1, metavar=type_info.type.__name__.upper(), - instance_from_str=lambda args: torch.device(args[0]), + instance_from_str=lambda args: torch.device(args[0]), # pyright: ignore[reportPrivateImportUsage] is_instance=lambda x: isinstance(x, type_info.type), str_from_instance=lambda instance: [str(instance)], ) @@ -724,7 +723,7 @@ def union_rule( # General unions, eg Union[int, bool]. We'll try to convert these from left to # right. - option_specs: dict[TypeForm[object], PrimitiveConstructorSpec] = {} + option_specs: dict[Type[object], PrimitiveConstructorSpec] = {} choices: tuple[str, ...] | None = () nargs: int | tuple[int, ...] | Literal["*"] = 1 first = True diff --git a/src/tyro/constructors/_struct_spec.py b/src/tyro/constructors/_struct_spec.py index 8ed9e3b89..8a4ac6492 100644 --- a/src/tyro/constructors/_struct_spec.py +++ b/src/tyro/constructors/_struct_spec.py @@ -4,7 +4,7 @@ import dataclasses import enum import functools -from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Sequence, Sized +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Sequence, Sized, Type from typing_extensions import cast, get_args, get_origin, is_typeddict @@ -22,7 +22,6 @@ MISSING_NONPROP, is_missing, ) -from .._typing import TypeForm from ..conf import _confstruct, _markers if TYPE_CHECKING: @@ -50,7 +49,7 @@ class StructFieldSpec: name: str """The name of the field. This will be used as a keyword argument for the struct's associated ``instantiate(**kwargs)`` function.""" - type: TypeForm + type: Type """The type of the field. Can be either a primitive or a nested struct type.""" default: Any """The default value of the field.""" @@ -87,7 +86,7 @@ class StructConstructorSpec: class StructTypeInfo: """Information used to generate constructors for struct types.""" - type: TypeForm + type: Type """The type of the (potential) struct.""" markers: tuple[Any, ...] """Markers from :mod:`tyro.conf` that are associated with this field.""" @@ -104,7 +103,7 @@ class StructTypeInfo: @staticmethod def make( - f: TypeForm | Callable, default: Any, in_union_context: bool + f: Type | Callable, default: Any, in_union_context: bool ) -> StructTypeInfo: _, parent_markers = _resolver.unwrap_annotated(f, _markers._Marker) f, found_subcommand_configs = _resolver.unwrap_annotated( @@ -140,7 +139,7 @@ def make( f = _resolver.narrow_collection_types(f, default) return StructTypeInfo( - cast(TypeForm, f), + cast(Type, f), parent_markers, default, typevar_context, diff --git a/src/tyro/extras/_base_configs.py b/src/tyro/extras/_base_configs.py index 800e02c36..1d066b7e7 100644 --- a/src/tyro/extras/_base_configs.py +++ b/src/tyro/extras/_base_configs.py @@ -1,14 +1,12 @@ from __future__ import annotations -from typing import Any, Mapping, Sequence, Tuple, TypeVar, Union +from typing import Any, Mapping, Sequence, Tuple, Type, TypeVar, Union from typing_extensions import Annotated from tyro.conf._markers import Suppress from tyro.constructors import ConstructorRegistry -from .._typing import TypeForm - T = TypeVar("T") @@ -115,7 +113,7 @@ def subcommand_type_from_defaults( *, prefix_names: bool = True, sort_subcommands: bool = False, -) -> TypeForm[T]: +) -> Type[T]: """Construct a Union type for defining subcommands that choose between defaults. For example, when ``defaults`` is set to: diff --git a/src/tyro/extras/_choices_type.py b/src/tyro/extras/_choices_type.py index 4df719994..843bd746e 100644 --- a/src/tyro/extras/_choices_type.py +++ b/src/tyro/extras/_choices_type.py @@ -1,12 +1,10 @@ import enum -from typing import Iterable, Literal, TypeVar, Union - -from .._typing import TypeForm +from typing import Iterable, Literal, Type, TypeVar, Union T = TypeVar("T", bound=Union[int, str, bool, enum.Enum]) -def literal_type_from_choices(choices: Iterable[T]) -> TypeForm[T]: +def literal_type_from_choices(choices: Iterable[T]) -> Type[T]: """Generate a :py:data:`typing.Literal` type that constrains values to a set of choices. Using ``Literal[...]`` directly should generally be preferred, but this function can be diff --git a/tests/helptext_utils.py b/tests/helptext_utils.py index c583bc08f..51bc1f9ce 100644 --- a/tests/helptext_utils.py +++ b/tests/helptext_utils.py @@ -4,7 +4,7 @@ import contextlib import io import os -from typing import Any, Callable +from typing import Any import pytest @@ -15,7 +15,9 @@ def get_helptext_with_checks( - f: Callable[..., Any], + # `f` is anything `tyro.cli` accepts: a callable, a class, or a type form + # (Annotated, unions, etc). We use `Any` to avoid having to spell that out. + f: Any, args: list[str] = ["--help"], use_underscores: bool = False, default: Any = MISSING_NONPROP, diff --git a/tests/test_add_help.py b/tests/test_add_help.py index 0f44e3dcc..1e0888f93 100644 --- a/tests/test_add_help.py +++ b/tests/test_add_help.py @@ -4,6 +4,7 @@ import dataclasses import io import sys +from typing import Any, Callable, Dict import pytest @@ -128,7 +129,10 @@ def cmd1(x: int) -> int: def cmd2(y: str) -> str: return y.upper() - subcommands = {"multiply": cmd1, "uppercase": cmd2} + # Annotated explicitly so type checkers pick the `Callable[..., Any]` + # overload instead of trying to unify cmd1/cmd2's return types under one + # TypeVar. + subcommands: Dict[str, Callable[..., Any]] = {"multiply": cmd1, "uppercase": cmd2} # Test with add_help=False result = tyro.extras.subcommand_cli_from_dict( diff --git a/tests/test_collections_abc_callable_min_py310.py b/tests/test_collections_abc_callable_min_py310.py index b663765ce..8684aa64e 100644 --- a/tests/test_collections_abc_callable_min_py310.py +++ b/tests/test_collections_abc_callable_min_py310.py @@ -118,7 +118,7 @@ def test_collections_abc_callable_with_generic_resolution() -> None: class GenericContainer(Generic[T]): x: int # ClassVar with Callable that has a type parameter. - FUNC: ClassVar[Callable[[T], str]] = str # pyright: ignore[reportGeneralTypeIssues] + FUNC: ClassVar[Callable[[T], str]] = str # pyright: ignore[reportGeneralTypeIssues] # ty: ignore[invalid-type-form] # When we use GenericContainer[int], the T in Callable[[T], str] should resolve to int. result = tyro.cli(GenericContainer[int], args=["--x", "42"]) @@ -151,7 +151,7 @@ def test_collections_abc_callable_type_param_resolution_direct() -> None: # 1. origin is collections.abc.Callable (not typing.Callable). # 2. First arg is a list: [T, int]. # 3. collections.abc.Callable lacks copy_with(), so we need special handling. - resolved = TypeParamResolver.resolve_params_and_aliases(callable_type) # pyright: ignore[reportArgumentType] + resolved = TypeParamResolver.resolve_params_and_aliases(callable_type) # pyright: ignore[reportArgumentType] # ty: ignore[invalid-argument-type] # Verify the result: T should be replaced with float. resolved_args = get_args(resolved) diff --git a/tests/test_conf.py b/tests/test_conf.py index b3c227a25..8973349fd 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -1663,7 +1663,7 @@ def instantiate_dataclasses( tyro.conf.OmitArgPrefixes[ # type: ignore # Convert (type1, type2) into Tuple[type1, type2] Tuple[ # type: ignore - tuple(Annotated[c, tyro.conf.arg(name=c.__name__)] for c in classes) + tuple(Annotated[c, tyro.conf.arg(name=c.__name__)] for c in classes) # ty: ignore[invalid-type-form] ] ], args=args, diff --git a/tests/test_dict_namedtuple.py b/tests/test_dict_namedtuple.py index 5be9609de..340b867d8 100644 --- a/tests/test_dict_namedtuple.py +++ b/tests/test_dict_namedtuple.py @@ -489,7 +489,7 @@ class HelptextNamedTuple(NamedTuple): def test_nested_dict() -> None: - loaded_config = { + loaded_config: Dict[str, Any] = { "batch_size": 32, "optimizer": { "learning_rate": 1e-4, @@ -519,7 +519,7 @@ def test_nested_dict() -> None: def test_nested_dict_use_underscores() -> None: - loaded_config = { + loaded_config: Dict[str, Any] = { "batch_size": 32, "optimizer": { "learning_rate": 1e-4, @@ -552,7 +552,7 @@ def test_nested_dict_use_underscores() -> None: def test_nested_dict_hyphen() -> None: # We do a lot of underscore <=> conversion in the code; this is just to make sure it # doesn't break anything! - loaded_config = { + loaded_config: Dict[str, Any] = { "batch-size": 32, "optimizer": { "learning-rate": 1e-4, @@ -584,7 +584,7 @@ def test_nested_dict_hyphen() -> None: def test_nested_dict_hyphen_use_underscores() -> None: # We do a lot of underscore <=> conversion in the code; this is just to make sure it # doesn't break anything! - loaded_config = { + loaded_config: Dict[str, Any] = { "batch-size": 32, "optimizer": { "learning-rate": 1e-4, @@ -635,7 +635,7 @@ def test_nested_dict_hyphen_use_underscores() -> None: def test_nested_dict_annotations() -> None: - loaded_config = { + loaded_config: Dict[str, Any] = { "optimizer": { "scheduler": {"schedule-type": "constant"}, }, diff --git a/tests/test_extras_verbosity.py b/tests/test_extras_verbosity.py index f6b389018..4668dc525 100644 --- a/tests/test_extras_verbosity.py +++ b/tests/test_extras_verbosity.py @@ -47,7 +47,9 @@ def test_verbosity_default_is_warning() -> None: def test_verbosity_is_frozen() -> None: with pytest.raises((AttributeError, dataclasses.FrozenInstanceError)): - Verbosity(verbose=0, quiet=0).verbose = 1 # type: ignore[misc] + # Use setattr to bypass static frozen-attribute check; we are + # asserting the runtime FrozenInstanceError is raised. + setattr(Verbosity(verbose=0, quiet=0), "verbose", 1) def test_cli_defaults() -> None: diff --git a/tests/test_nested.py b/tests/test_nested.py index 0268a2530..e714eb945 100644 --- a/tests/test_nested.py +++ b/tests/test_nested.py @@ -578,7 +578,7 @@ class DefaultSMTPServer: @dataclasses.dataclass class DefaultSubparser: x: int - bc: Union[DefaultHTTPServer, DefaultSMTPServer] = dataclasses.field( + bc: Union[DefaultHTTPServer, DefaultSMTPServer] = dataclasses.field( # ty: ignore[invalid-assignment] default_factory=lambda: 5 # type: ignore ) diff --git a/tests/test_new_style_annotations_min_py310.py b/tests/test_new_style_annotations_min_py310.py index 3e2e90a55..e80a6a977 100644 --- a/tests/test_new_style_annotations_min_py310.py +++ b/tests/test_new_style_annotations_min_py310.py @@ -93,6 +93,8 @@ def test_type_default_factory() -> None: @dataclasses.dataclass class Config: foo: int - bar: type[Type] = dataclasses.field(default_factory=lambda: Type) + # ty doesn't model `dataclasses.field(default_factory=...)` as + # producing the field's declared type the way mypy/pyright do. + bar: type[Type] = dataclasses.field(default_factory=lambda: Type) # ty: ignore[invalid-assignment] assert tyro.cli(Config, args=["--foo", "5"]) == Config(5) diff --git a/tests/test_new_style_annotations_min_py312.py b/tests/test_new_style_annotations_min_py312.py index 5e1ed253d..4d15b29cb 100644 --- a/tests/test_new_style_annotations_min_py312.py +++ b/tests/test_new_style_annotations_min_py312.py @@ -190,14 +190,16 @@ class Config: ) == Config(Inner("3", "5")) +# NewType with PEP 695 type-alias bases: accepted at runtime and by mypy/ +# pyright; ty's `invalid-newtype` rule rejects it. type Int0 = int -Int1 = NewType("Int1", Int0) +Int1 = NewType("Int1", Int0) # ty: ignore[invalid-newtype] type Int2 = Int1 -Int3 = NewType("Int3", Int2) +Int3 = NewType("Int3", Int2) # ty: ignore[invalid-newtype] type Int4 = Int3 -Int5 = NewType("Int5", Int4) +Int5 = NewType("Int5", Int4) # ty: ignore[invalid-newtype] type Int6 = Int5 -Int7 = NewType("Int7", Int6) +Int7 = NewType("Int7", Int6) # ty: ignore[invalid-newtype] def test_pep695_new_type_alias() -> None: diff --git a/tests/test_protocol_typevar.py b/tests/test_protocol_typevar.py index b85fd6f99..4797d4d02 100644 --- a/tests/test_protocol_typevar.py +++ b/tests/test_protocol_typevar.py @@ -18,7 +18,8 @@ def build(self, **kwd_override: Any) -> OutT_co: ... class SomeConfig(Generic[OutT]): _target: Type[OutT] - def _configure(self) -> OutT: ... + def _configure(self) -> OutT: + raise NotImplementedError class Foo: ... diff --git a/tests/test_py311_generated/test_add_help_generated.py b/tests/test_py311_generated/test_add_help_generated.py index 068e33b75..45715694d 100644 --- a/tests/test_py311_generated/test_add_help_generated.py +++ b/tests/test_py311_generated/test_add_help_generated.py @@ -4,6 +4,7 @@ import dataclasses import io import sys +from typing import Any, Callable, Dict import pytest @@ -128,7 +129,10 @@ def cmd1(x: int) -> int: def cmd2(y: str) -> str: return y.upper() - subcommands = {"multiply": cmd1, "uppercase": cmd2} + # Annotated explicitly so type checkers pick the `Callable[..., Any]` + # overload instead of trying to unify cmd1/cmd2's return types under one + # TypeVar. + subcommands: Dict[str, Callable[..., Any]] = {"multiply": cmd1, "uppercase": cmd2} # Test with add_help=False result = tyro.extras.subcommand_cli_from_dict( diff --git a/tests/test_py311_generated/test_collections_abc_callable_min_py310_generated.py b/tests/test_py311_generated/test_collections_abc_callable_min_py310_generated.py index b663765ce..8684aa64e 100644 --- a/tests/test_py311_generated/test_collections_abc_callable_min_py310_generated.py +++ b/tests/test_py311_generated/test_collections_abc_callable_min_py310_generated.py @@ -118,7 +118,7 @@ def test_collections_abc_callable_with_generic_resolution() -> None: class GenericContainer(Generic[T]): x: int # ClassVar with Callable that has a type parameter. - FUNC: ClassVar[Callable[[T], str]] = str # pyright: ignore[reportGeneralTypeIssues] + FUNC: ClassVar[Callable[[T], str]] = str # pyright: ignore[reportGeneralTypeIssues] # ty: ignore[invalid-type-form] # When we use GenericContainer[int], the T in Callable[[T], str] should resolve to int. result = tyro.cli(GenericContainer[int], args=["--x", "42"]) @@ -151,7 +151,7 @@ def test_collections_abc_callable_type_param_resolution_direct() -> None: # 1. origin is collections.abc.Callable (not typing.Callable). # 2. First arg is a list: [T, int]. # 3. collections.abc.Callable lacks copy_with(), so we need special handling. - resolved = TypeParamResolver.resolve_params_and_aliases(callable_type) # pyright: ignore[reportArgumentType] + resolved = TypeParamResolver.resolve_params_and_aliases(callable_type) # pyright: ignore[reportArgumentType] # ty: ignore[invalid-argument-type] # Verify the result: T should be replaced with float. resolved_args = get_args(resolved) diff --git a/tests/test_py311_generated/test_conf_generated.py b/tests/test_py311_generated/test_conf_generated.py index 4e7d3a13f..427bd447d 100644 --- a/tests/test_py311_generated/test_conf_generated.py +++ b/tests/test_py311_generated/test_conf_generated.py @@ -1670,7 +1670,7 @@ def instantiate_dataclasses( tyro.conf.OmitArgPrefixes[ # type: ignore # Convert (type1, type2) into Tuple[type1, type2] Tuple[ # type: ignore - tuple(Annotated[c, tyro.conf.arg(name=c.__name__)] for c in classes) + tuple(Annotated[c, tyro.conf.arg(name=c.__name__)] for c in classes) # ty: ignore[invalid-type-form] ] ], args=args, diff --git a/tests/test_py311_generated/test_dict_namedtuple_generated.py b/tests/test_py311_generated/test_dict_namedtuple_generated.py index a64527662..15a3e1717 100644 --- a/tests/test_py311_generated/test_dict_namedtuple_generated.py +++ b/tests/test_py311_generated/test_dict_namedtuple_generated.py @@ -499,7 +499,7 @@ class HelptextNamedTuple(NamedTuple): def test_nested_dict() -> None: - loaded_config = { + loaded_config: Dict[str, Any] = { "batch_size": 32, "optimizer": { "learning_rate": 1e-4, @@ -529,7 +529,7 @@ def test_nested_dict() -> None: def test_nested_dict_use_underscores() -> None: - loaded_config = { + loaded_config: Dict[str, Any] = { "batch_size": 32, "optimizer": { "learning_rate": 1e-4, @@ -562,7 +562,7 @@ def test_nested_dict_use_underscores() -> None: def test_nested_dict_hyphen() -> None: # We do a lot of underscore <=> conversion in the code; this is just to make sure it # doesn't break anything! - loaded_config = { + loaded_config: Dict[str, Any] = { "batch-size": 32, "optimizer": { "learning-rate": 1e-4, @@ -594,7 +594,7 @@ def test_nested_dict_hyphen() -> None: def test_nested_dict_hyphen_use_underscores() -> None: # We do a lot of underscore <=> conversion in the code; this is just to make sure it # doesn't break anything! - loaded_config = { + loaded_config: Dict[str, Any] = { "batch-size": 32, "optimizer": { "learning-rate": 1e-4, @@ -645,7 +645,7 @@ def test_nested_dict_hyphen_use_underscores() -> None: def test_nested_dict_annotations() -> None: - loaded_config = { + loaded_config: Dict[str, Any] = { "optimizer": { "scheduler": {"schedule-type": "constant"}, }, diff --git a/tests/test_py311_generated/test_extras_verbosity_generated.py b/tests/test_py311_generated/test_extras_verbosity_generated.py index 627b2d573..5c679a432 100644 --- a/tests/test_py311_generated/test_extras_verbosity_generated.py +++ b/tests/test_py311_generated/test_extras_verbosity_generated.py @@ -47,7 +47,9 @@ def test_verbosity_default_is_warning() -> None: def test_verbosity_is_frozen() -> None: with pytest.raises((AttributeError, dataclasses.FrozenInstanceError)): - Verbosity(verbose=0, quiet=0).verbose = 1 # type: ignore[misc] + # Use setattr to bypass static frozen-attribute check; we are + # asserting the runtime FrozenInstanceError is raised. + setattr(Verbosity(verbose=0, quiet=0), "verbose", 1) def test_cli_defaults() -> None: diff --git a/tests/test_py311_generated/test_nested_generated.py b/tests/test_py311_generated/test_nested_generated.py index a11a8ebe9..078c685d3 100644 --- a/tests/test_py311_generated/test_nested_generated.py +++ b/tests/test_py311_generated/test_nested_generated.py @@ -589,7 +589,7 @@ class DefaultSMTPServer: @dataclasses.dataclass class DefaultSubparser: x: int - bc: DefaultHTTPServer | DefaultSMTPServer = dataclasses.field( + bc: DefaultHTTPServer | DefaultSMTPServer = dataclasses.field( # ty: ignore[invalid-assignment] default_factory=lambda: 5 # type: ignore ) diff --git a/tests/test_py311_generated/test_new_style_annotations_min_py310_generated.py b/tests/test_py311_generated/test_new_style_annotations_min_py310_generated.py index 3e2e90a55..e80a6a977 100644 --- a/tests/test_py311_generated/test_new_style_annotations_min_py310_generated.py +++ b/tests/test_py311_generated/test_new_style_annotations_min_py310_generated.py @@ -93,6 +93,8 @@ def test_type_default_factory() -> None: @dataclasses.dataclass class Config: foo: int - bar: type[Type] = dataclasses.field(default_factory=lambda: Type) + # ty doesn't model `dataclasses.field(default_factory=...)` as + # producing the field's declared type the way mypy/pyright do. + bar: type[Type] = dataclasses.field(default_factory=lambda: Type) # ty: ignore[invalid-assignment] assert tyro.cli(Config, args=["--foo", "5"]) == Config(5) diff --git a/tests/test_py311_generated/test_new_style_annotations_min_py312_generated.py b/tests/test_py311_generated/test_new_style_annotations_min_py312_generated.py index 723ceea9e..77d514285 100644 --- a/tests/test_py311_generated/test_new_style_annotations_min_py312_generated.py +++ b/tests/test_py311_generated/test_new_style_annotations_min_py312_generated.py @@ -190,14 +190,16 @@ class Config: ) == Config(Inner("3", "5")) +# NewType with PEP 695 type-alias bases: accepted at runtime and by mypy/ +# pyright; ty's `invalid-newtype` rule rejects it. type Int0 = int -Int1 = NewType("Int1", Int0) +Int1 = NewType("Int1", Int0) # ty: ignore[invalid-newtype] type Int2 = Int1 -Int3 = NewType("Int3", Int2) +Int3 = NewType("Int3", Int2) # ty: ignore[invalid-newtype] type Int4 = Int3 -Int5 = NewType("Int5", Int4) +Int5 = NewType("Int5", Int4) # ty: ignore[invalid-newtype] type Int6 = Int5 -Int7 = NewType("Int7", Int6) +Int7 = NewType("Int7", Int6) # ty: ignore[invalid-newtype] def test_pep695_new_type_alias() -> None: diff --git a/tests/test_py311_generated/test_protocol_typevar_generated.py b/tests/test_py311_generated/test_protocol_typevar_generated.py index b85fd6f99..4797d4d02 100644 --- a/tests/test_py311_generated/test_protocol_typevar_generated.py +++ b/tests/test_py311_generated/test_protocol_typevar_generated.py @@ -18,7 +18,8 @@ def build(self, **kwd_override: Any) -> OutT_co: ... class SomeConfig(Generic[OutT]): _target: Type[OutT] - def _configure(self) -> OutT: ... + def _configure(self) -> OutT: + raise NotImplementedError class Foo: ... diff --git a/tests/test_py311_generated/test_torch_exclude_py313_generated.py b/tests/test_py311_generated/test_torch_exclude_py313_generated.py index 6823f2941..2a454ab38 100644 --- a/tests/test_py311_generated/test_torch_exclude_py313_generated.py +++ b/tests/test_py311_generated/test_torch_exclude_py313_generated.py @@ -1,3 +1,7 @@ +# Recent torch stubs no longer re-export `device` from `torch/__init__.pyi`; +# the symbol is still public though, and we use it throughout. +# pyright: reportPrivateImportUsage=none + from dataclasses import dataclass from typing import Any, Callable, Tuple diff --git a/tests/test_torch_exclude_py313.py b/tests/test_torch_exclude_py313.py index 6823f2941..2a454ab38 100644 --- a/tests/test_torch_exclude_py313.py +++ b/tests/test_torch_exclude_py313.py @@ -1,3 +1,7 @@ +# Recent torch stubs no longer re-export `device` from `torch/__init__.pyi`; +# the symbol is still public though, and we use it throughout. +# pyright: reportPrivateImportUsage=none + from dataclasses import dataclass from typing import Any, Callable, Tuple