From 02b10b9e1fd2de6630004fc867292380d565107f Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 18 May 2026 19:38:01 -0700 Subject: [PATCH 1/8] Carry parameter defaults in a Param D generic Drop "default" from ParamQuals and add a fourth generic D = Never to Param. The presence of a default is now expressed by D being a real type (e.g. the parameter's type) instead of by a string in the qual set, which makes the default's type available to type-level operators via the new GetDefault[T: Param] alias. --- tests/test_dataclass_like.py | 7 ++++--- tests/test_fastapilike_1.py | 7 ++++--- tests/test_fastapilike_2.py | 7 ++++--- tests/test_type_dir.py | 11 +++++------ tests/test_type_eval.py | 12 ++++++------ typemap/type_eval/_eval_operators.py | 18 ++++++++++-------- typemap/typing.py | 14 +++++++------- 7 files changed, 40 insertions(+), 36 deletions(-) diff --git a/tests/test_dataclass_like.py b/tests/test_dataclass_like.py index 36a66bf..f268d85 100644 --- a/tests/test_dataclass_like.py +++ b/tests/test_dataclass_like.py @@ -70,13 +70,14 @@ def _check_hero_init() -> None: p.name, p.type, # All arguments are keyword-only + Literal["keyword"], # It takes a default if a default is specified in the class - Literal["keyword"] - if typing.IsAssignable[ + p.type + if not typing.IsAssignable[ GetDefault[p.init], Never, ] - else Literal["keyword", "default"], + else Never, ] for p in typing.Iter[typing.Attrs[T]] ], diff --git a/tests/test_fastapilike_1.py b/tests/test_fastapilike_1.py index 3456f23..04a2a3f 100644 --- a/tests/test_fastapilike_1.py +++ b/tests/test_fastapilike_1.py @@ -8,7 +8,7 @@ import enum import textwrap -from typing import Annotated, Callable, Literal, Union, Self +from typing import Annotated, Callable, Literal, Never, Union, Self from typemap.type_eval import eval_typing from typemap_extensions import ( @@ -57,12 +57,13 @@ class _Default: Param[ p.name, DropAnnotations[p.type], - Literal["keyword", "default"] + Literal["keyword"], + DropAnnotations[p.type] if IsAssignable[ Literal[PropQuals.HAS_DEFAULT], GetAnnotations[p.type], ] - else Literal["keyword"], + else Never, ] for p in Iter[Attrs[T]] ], diff --git a/tests/test_fastapilike_2.py b/tests/test_fastapilike_2.py index 6913e05..f2d187d 100644 --- a/tests/test_fastapilike_2.py +++ b/tests/test_fastapilike_2.py @@ -150,13 +150,14 @@ class Field[T: FieldArgs](typing.InitField[T]): p.name, p.type, # All arguments are keyword-only + Literal["keyword"], # It takes a default if a default is specified in the class - Literal["keyword"] - if typing.IsAssignable[ + p.type + if not typing.IsAssignable[ GetDefault[p.init], Never, ] - else Literal["keyword", "default"], + else Never, ] for p in typing.Iter[typing.Attrs[T]] ], diff --git a/tests/test_type_dir.py b/tests/test_type_dir.py index 894c5d2..41db3ff 100644 --- a/tests/test_type_dir.py +++ b/tests/test_type_dir.py @@ -385,10 +385,9 @@ def test_type_members_func_1(): str(typ) == "\ typing.Callable[\ -typemap.typing.Params[typemap.typing.Param[typing.Literal['self'], tests.test_type_dir.Base[int], typing.Never], \ -typemap.typing.Param[typing.Literal['a'], int | None, typing.Never], \ -typemap.typing.Param[typing.Literal['b'], int, typing.Literal['keyword', \ -'default']]], \ +typemap.typing.Params[typemap.typing.Param[typing.Literal['self'], tests.test_type_dir.Base[int], typing.Never, typing.Never], \ +typemap.typing.Param[typing.Literal['a'], int | None, typing.Never, typing.Never], \ +typemap.typing.Param[typing.Literal['b'], int, typing.Literal['keyword'], int]], \ dict[str, int]]" ) @@ -406,7 +405,7 @@ def test_type_members_func_2(): assert ( str(typ) == "\ -classmethod[tests.test_type_dir.Base[int], typemap.typing.Params[typemap.typing.Param[typing.Literal['a'], int | None, typing.Never], typemap.typing.Param[typing.Literal['b'], ~K, typing.Never]], dict[str, int]]" +classmethod[tests.test_type_dir.Base[int], typemap.typing.Params[typemap.typing.Param[typing.Literal['a'], int | None, typing.Never, typing.Never], typemap.typing.Param[typing.Literal['b'], ~K, typing.Never, typing.Never]], dict[str, int]]" ) @@ -425,7 +424,7 @@ def test_type_members_func_3(): ) assert ( str(evaled) - == "staticmethod[typemap.typing.Params[typemap.typing.Param[typing.Literal['a'], int | typing.Literal['gotcha!'] | Z | None, typing.Never], typemap.typing.Param[typing.Literal['b'], ~K, typing.Never]], dict[str, int | Z]]" + == "staticmethod[typemap.typing.Params[typemap.typing.Param[typing.Literal['a'], int | typing.Literal['gotcha!'] | Z | None, typing.Never, typing.Never], typemap.typing.Param[typing.Literal['b'], ~K, typing.Never, typing.Never]], dict[str, int | Z]]" ) diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index 136ba6b..8b5f468 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -157,10 +157,10 @@ def test_eval_types_4(): [ Param[Literal["a"], int, Literal["positional"]], Param[Literal["b"], int], - Param[Literal["c"], int, Literal["default"]], + Param[Literal["c"], int, Never, int], Param[None, int, Literal["*"]], Param[Literal["d"], int, Literal["keyword"]], - Param[Literal["e"], int, Literal["default", "keyword"]], + Param[Literal["e"], int, Literal["keyword"], int], Param[None, int, Literal["**"]], ], int, @@ -172,10 +172,10 @@ def test_eval_types_4(): [ Param[Literal["a"], int, Literal["positional"]], Param[Literal["b"], int], - Param[Literal["c"], int, Literal["default"]], + Param[Literal["c"], int, Never, int], Param[None, int, Literal["*"]], Param[Literal["d"], int, Literal["keyword"]], - Param[Literal["e"], int, Literal["default", "keyword"]], + Param[Literal["e"], int, Literal["keyword"], int], Param[None, int, Literal["**"]], ], int, @@ -1827,10 +1827,10 @@ def test_callable_to_signature_01(): Params[ Param[None, int], Param[Literal["b"], int], - Param[Literal["c"], int, Literal["default"]], + Param[Literal["c"], int, Never, int], Param[None, int, Literal["*"]], Param[Literal["d"], int, Literal["keyword"]], - Param[Literal["e"], int, Literal["default", "keyword"]], + Param[Literal["e"], int, Literal["keyword"], int], Param[None, int, Literal["**"]], ], int, diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index cb9a799..3406c72 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -609,10 +609,10 @@ def _callable_type_to_signature(callable_type: object) -> inspect.Signature: else: kind = inspect.Parameter.POSITIONAL_OR_KEYWORD - # Handle default value + # Handle default value from the 4th Param arg (D) + default_type = param_args[3] if len(param_args) > 3 else typing.Never default: typing.Any - if "default" in quals: - # We don't have the actual default value, use a sentinel + if default_type is not typing.Never: default = _DUMMY_DEFAULT else: default = inspect.Parameter.empty @@ -661,10 +661,11 @@ def fn(*args, **kwargs): def _is_pos_only(param): - name, _, quals = typing.get_args(param) + args = typing.get_args(param) + name, _, quals = args[0], args[1], args[2] qual_set = _get_quals(quals) return "positional" in qual_set or ( - name is None and not (_get_quals(quals) & {"*", "**"}) + name is None and not (qual_set & {"*", "**"}) ) @@ -771,13 +772,14 @@ def _ann(x): quals.append("keyword") if p.kind == inspect.Parameter.POSITIONAL_ONLY: quals.append("positional") - if p.default is not empty: - quals.append("default") + ann_type = _ann(ann) + has_default = p.default is not empty params.append( Param[ typing.Literal[p.name], - _ann(ann), + ann_type, typing.Literal[*quals] if quals else typing.Never, + ann_type if has_default else typing.Never, ] ) diff --git a/typemap/typing.py b/typemap/typing.py index b258951..90a0938 100644 --- a/typemap/typing.py +++ b/typemap/typing.py @@ -198,23 +198,22 @@ class Member[ type definer = D -ParamQuals = Literal["*", "**", "keyword", "positional", "default"] +ParamQuals = Literal["*", "**", "keyword", "positional"] @has_associated_types -class Param[N: str | None, T, Q: ParamQuals = typing.Never]: +class Param[N: str | None, T, Q: ParamQuals = typing.Never, D = typing.Never]: type name = N type type = T type quals = Q + type default = D type PosParam[N: str | None, T] = Param[N, T, Literal["positional"]] -type PosDefaultParam[N: str | None, T] = Param[ - N, T, Literal["positional", "default"] -] -type DefaultParam[N: str, T] = Param[N, T, Literal["default"]] +type PosDefaultParam[N: str | None, T] = Param[N, T, Literal["positional"], T] +type DefaultParam[N: str, T] = Param[N, T, typing.Never, T] type NamedParam[N: str, T] = Param[N, T, Literal["keyword"]] -type NamedDefaultParam[N: str, T] = Param[N, T, Literal["keyword", "default"]] +type NamedDefaultParam[N: str, T] = Param[N, T, Literal["keyword"], T] type ArgsParam[T] = Param[Literal[None], T, Literal["*"]] type KwargsParam[T] = Param[Literal[None], T, Literal["**"]] @@ -236,6 +235,7 @@ def __class_getitem__(cls, params): type GetName[T: Member | Param] = T.name type GetType[T: Member | Param] = T.type type GetQuals[T: Member | Param] = T.quals +type GetDefault[T: Param] = T.default type GetInit[T: Member] = T.init type GetDefiner[T: Member] = T.definer From d53ecb0c8b020e938b45e9fd2f7971bcba78a6c7 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 18 May 2026 19:52:24 -0700 Subject: [PATCH 2/8] Carry Literal default values in Param D generic For simple default types (None, int, str, bytes, bool, enum), store D = Literal[value] instead of D = ann_type, allowing real values to be extracted when converting back to inspect.Signature. --- tests/test_type_dir.py | 4 ++-- typemap/type_eval/_eval_operators.py | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/test_type_dir.py b/tests/test_type_dir.py index 41db3ff..867374a 100644 --- a/tests/test_type_dir.py +++ b/tests/test_type_dir.py @@ -198,7 +198,7 @@ class Final: fin: typing.Final[int] x: tests.test_type_dir.Wrapper[int | None] ordinary: str - def foo(self: Self, a: int | None, *, b: int = ...) -> dict[str, int]: ... + def foo(self: Self, a: int | None, *, b: int = 0) -> dict[str, int]: ... def base[Z](self: Self, a: int | Z | None, b: ~K) -> dict[str, int | Z]: ... @classmethod def cbase(cls: type[typing.Self], a: int | None, b: ~K) -> dict[str, int]: ... @@ -387,7 +387,7 @@ def test_type_members_func_1(): typing.Callable[\ typemap.typing.Params[typemap.typing.Param[typing.Literal['self'], tests.test_type_dir.Base[int], typing.Never, typing.Never], \ typemap.typing.Param[typing.Literal['a'], int | None, typing.Never, typing.Never], \ -typemap.typing.Param[typing.Literal['b'], int, typing.Literal['keyword'], int]], \ +typemap.typing.Param[typing.Literal['b'], int, typing.Literal['keyword'], typing.Literal[0]]], \ dict[str, int]]" ) diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index 3406c72..218b1e3 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -2,6 +2,7 @@ import collections.abc import contextlib import dataclasses +import enum import functools import inspect import itertools @@ -613,7 +614,13 @@ def _callable_type_to_signature(callable_type: object) -> inspect.Signature: default_type = param_args[3] if len(param_args) > 3 else typing.Never default: typing.Any if default_type is not typing.Never: - default = _DUMMY_DEFAULT + if ( + _typing_inspect.is_literal(default_type) + and len(default_type.__args__) == 1 # type: ignore[union-attr] + ): + default = default_type.__args__[0] # type: ignore[union-attr] + else: + default = _DUMMY_DEFAULT else: default = inspect.Parameter.empty @@ -774,12 +781,20 @@ def _ann(x): quals.append("positional") ann_type = _ann(ann) has_default = p.default is not empty + if has_default: + v = p.default + if v is None or isinstance(v, (int, str, bytes, bool, enum.Enum)): + default_type = typing.Literal[(v,)] + else: + default_type = ann_type + else: + default_type = typing.Never params.append( Param[ typing.Literal[p.name], ann_type, typing.Literal[*quals] if quals else typing.Never, - ann_type if has_default else typing.Never, + default_type, ] ) From a6520e0b98d6e7661fbc8e7ee7cc400acc5ded21 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 13:03:04 -0700 Subject: [PATCH 3/8] xfail mypy proto tests blocked on mypy-typemap stub update test_dataclass_like and test_fastapilike_2 both depend on the 4-arg Param shape, which the pinned mypy-typemap stub fork doesn't yet expose. Mark them strict-xfail so they'll surface as XPASS failures the moment the stubs catch up. --- tests/test_mypy_proto.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/test_mypy_proto.py b/tests/test_mypy_proto.py index cd82a35..cf920a7 100644 --- a/tests/test_mypy_proto.py +++ b/tests/test_mypy_proto.py @@ -14,6 +14,11 @@ MYPY_SOURCE_DIR = pathlib.Path(_mypy_source).resolve() if _mypy_source else None +# Files that depend on the 4-arg Param shape, which the pinned +# mypy-typemap stub fork hasn't been updated for yet. +_XFAIL_PARAM_D = {"test_dataclass_like", "test_fastapilike_2"} + + def _collect_mypy_test_files(): """Collect test files that don't have # SKIP MYPY.""" tests_dir = pathlib.Path(__file__).parent @@ -22,7 +27,15 @@ def _collect_mypy_test_files(): continue text = path.read_text() if "# SKIP MYPY" not in text: - yield pytest.param(path, id=path.stem) + marks = [] + if path.stem in _XFAIL_PARAM_D: + marks.append( + pytest.mark.xfail( + reason="mypy-typemap stubs still have 3-arg Param", + strict=True, + ) + ) + yield pytest.param(path, id=path.stem, marks=marks) @pytest.mark.parametrize("test_file", _collect_mypy_test_files()) From c7b0fb63344295ec755235c06bb44ecd754a4eb7 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 21:14:30 -0700 Subject: [PATCH 4/8] Use GetDefault[p.init] directly as Param D in InitFnType examples GetDefault already returns Never when there's no default and the default's type otherwise, so the surrounding "p.type if ... else Never" conditional is redundant. Using GetDefault directly also propagates the actual default value (as a Literal) through to the synthesized __init__ signature, surfacing "= None" instead of "= ...". --- tests/test_dataclass_like.py | 13 ++++--------- tests/test_fastapilike_2.py | 17 ++++++----------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/tests/test_dataclass_like.py b/tests/test_dataclass_like.py index f268d85..9b108c9 100644 --- a/tests/test_dataclass_like.py +++ b/tests/test_dataclass_like.py @@ -4,7 +4,6 @@ Literal, ReadOnly, TypedDict, - Never, ) import typemap_extensions as typing @@ -71,13 +70,9 @@ def _check_hero_init() -> None: p.type, # All arguments are keyword-only Literal["keyword"], - # It takes a default if a default is specified in the class - p.type - if not typing.IsAssignable[ - GetDefault[p.init], - Never, - ] - else Never, + # GetDefault is Never when there's no default, so use it + # directly as D. + GetDefault[p.init], ] for p in typing.Iter[typing.Attrs[T]] ], @@ -157,5 +152,5 @@ class Hero: secret_name: str @classmethod def __init_subclass__[T](cls: type[T]) -> typemap.typing.UpdateClass[InitFnType[T]]: ... - def __init__(self: Self, *, id: int | None = ..., name: str, age: int | None = ..., secret_name: str) -> None: ... + def __init__(self: Self, *, id: int | None = None, name: str, age: int | None = None, secret_name: str) -> None: ... """) diff --git a/tests/test_fastapilike_2.py b/tests/test_fastapilike_2.py index f2d187d..a22b488 100644 --- a/tests/test_fastapilike_2.py +++ b/tests/test_fastapilike_2.py @@ -6,7 +6,6 @@ Union, ReadOnly, TypedDict, - Never, Self, ) @@ -151,13 +150,9 @@ class Field[T: FieldArgs](typing.InitField[T]): p.type, # All arguments are keyword-only Literal["keyword"], - # It takes a default if a default is specified in the class - p.type - if not typing.IsAssignable[ - GetDefault[p.init], - Never, - ] - else Never, + # GetDefault is Never when there's no default, so use it + # directly as D. + GetDefault[p.init], ] for p in typing.Iter[typing.Attrs[T]] ], @@ -268,7 +263,7 @@ class AddInit[tests.test_fastapilike_2.Hero]: name: str = Field(index=True) age: int | None = Field(default=None, index=True) secret_name: str = Field(hidden=True) - def __init__(self: Self, *, id: int | None = ..., name: str, age: int | None = ..., secret_name: str) -> None: ... + def __init__(self: Self, *, id: int | None = None, name: str, age: int | None = None, secret_name: str) -> None: ... """) @@ -294,7 +289,7 @@ class AddInit[tests.test_fastapilike_2.Create[tests.test_fastapilike_2.Hero]]: name: str age: int | None = None secret_name: str - def __init__(self: Self, *, name: str, age: int | None = ..., secret_name: str) -> None: ... + def __init__(self: Self, *, name: str, age: int | None = None, secret_name: str) -> None: ... """) @@ -307,5 +302,5 @@ class AddInit[tests.test_fastapilike_2.Update[tests.test_fastapilike_2.Hero]]: name: str | None = None age: int | None = None secret_name: str | None = None - def __init__(self: Self, *, name: str | None = ..., age: int | None = ..., secret_name: str | None = ...) -> None: ... + def __init__(self: Self, *, name: str | None = None, age: int | None = None, secret_name: str | None = None) -> None: ... """) From aa974549ca05b211b6ccda00d8c1a63a7d2f8140 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 21:51:21 -0700 Subject: [PATCH 5/8] Rename Param's Q/quals to K/kind and reject multi-kind literals A Param can carry at most one kind value ("*", "**", "keyword", or "positional"), so the prior "set of qualifiers" framing was overkill and Q/quals also overlapped semantically with Member.quals. Rename the Param generic to K and the associated type to .kind, and tighten the runtime to raise TypeError when more than one kind is given in the Literal. GetQuals is narrowed to Member; a new GetKind alias covers Param. --- tests/test_type_eval.py | 13 ++++++ typemap/type_eval/_eval_operators.py | 65 +++++++++++++--------------- typemap/typing.py | 9 ++-- 3 files changed, 49 insertions(+), 38 deletions(-) diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index 8b5f468..5915ecc 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -1847,6 +1847,19 @@ def test_callable_to_signature_01(): ) +def test_callable_to_signature_multi_kind_error(): + from typemap.type_eval._eval_operators import _callable_type_to_signature + + # Param can carry at most one kind; combining "positional" with + # "keyword" is nonsense and should be rejected. + callable_type = Callable[ + Params[Param[Literal["x"], int, Literal["positional", "keyword"]]], + int, + ] + with pytest.raises(TypeError, match="at most one"): + _callable_type_to_signature(callable_type) + + def test_new_protocol_with_methods_01(): class C: def member_method(self, x: int) -> int: ... diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index 218b1e3..b71c4fe 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -494,13 +494,19 @@ def _eval_Bool(tp, *, ctx): ################################################################## -def _get_quals(quals_type): - # Extract qualifiers from Literal["*", "**", ...] or Never - if _typing_inspect.is_literal(quals_type): - qual_args = typing.get_args(quals_type) - return set(qual_args) - else: - return set() +def _get_kind(kind_type) -> str | None: + # Extract the single kind from Literal["*"|"**"|"keyword"|"positional"] + # or Never. Multiple kinds in one Literal are an error. + if kind_type is typing.Never: + return None + if not _typing_inspect.is_literal(kind_type): + return None + kind_args = typing.get_args(kind_type) + if len(kind_args) > 1: + raise TypeError( + f"Param kind must have at most one value, got {kind_type}" + ) + return kind_args[0] if kind_args else None class _DummyDefault: @@ -563,7 +569,7 @@ def _callable_type_to_signature(callable_type: object) -> inspect.Signature: saw_keyword_only = False for param_type in param_types: - # Extract Param arguments: Param[name, type, quals] + # Extract Param arguments: Param[name, type, kind] origin = typing.get_origin(param_type) if origin is not Param: raise TypeError(f"Expected Param type, got {param_type}") @@ -576,34 +582,27 @@ def _callable_type_to_signature(callable_type: object) -> inspect.Signature: name_type = param_args[0] annotation = param_args[1] - quals_type = param_args[2] if len(param_args) > 2 else typing.Never + kind_type = param_args[2] if len(param_args) > 2 else typing.Never # Extract name from Literal[name] or None name = _from_literal(name_type) - # Extract qualifiers from Literal["*", "**", ...] or Never - quals: set[str] = set() - if quals_type is not typing.Never: - if _typing_inspect.is_literal(quals_type): - qual_args = typing.get_args(quals_type) - quals = set(qual_args) - else: - quals = set() + param_kind = _get_kind(kind_type) # Determine parameter kind and default kind: inspect._ParameterKind - if "**" in quals: + if param_kind == "**": kind = inspect.Parameter.VAR_KEYWORD name = name or "kwargs" - elif "*" in quals: + elif param_kind == "*": kind = inspect.Parameter.VAR_POSITIONAL name = name or "args" # XXX: not sure we need this saw_keyword_only = True - elif "keyword" in quals: + elif param_kind == "keyword": kind = inspect.Parameter.KEYWORD_ONLY saw_keyword_only = True - elif "positional" in quals or name is None: + elif param_kind == "positional" or name is None: kind = inspect.Parameter.POSITIONAL_ONLY elif saw_keyword_only: kind = inspect.Parameter.KEYWORD_ONLY @@ -669,11 +668,9 @@ def fn(*args, **kwargs): def _is_pos_only(param): args = typing.get_args(param) - name, _, quals = args[0], args[1], args[2] - qual_set = _get_quals(quals) - return "positional" in qual_set or ( - name is None and not (qual_set & {"*", "**"}) - ) + name, _, kind_type = args[0], args[1], args[2] + kind = _get_kind(kind_type) + return kind == "positional" or (name is None and kind not in ("*", "**")) def _callable_type_to_method(name, typ, ctx): @@ -700,14 +697,14 @@ def _callable_type_to_method(name, typ, ctx): # We have to make class positional only if there is some other # positional only argument. Annoying! has_pos_only = any(_is_pos_only(p) for p in param_list) - quals = typing.Literal["positional"] if has_pos_only else typing.Never + kind = typing.Literal["positional"] if has_pos_only else typing.Never # Override the receiver type with type[Self]. if name == "__init_subclass__" and isinstance(cls, typing.TypeVar): # For __init_subclass__ generic on cls: T, keep type[T] cls_typ = type[cls] else: cls_typ = type[typing.Self] - cls_param = Param[typing.Literal["cls"], cls_typ, quals] + cls_param = Param[typing.Literal["cls"], cls_typ, kind] typ = typing.Callable[Params[cls_param, *param_list], ret] elif head is staticmethod: raw_params, ret = typing.get_args(typ) @@ -770,15 +767,15 @@ def _ann(x): else: specified_receiver = ann - quals = [] + kinds = [] if p.kind == inspect.Parameter.VAR_POSITIONAL: - quals.append("*") + kinds.append("*") if p.kind == inspect.Parameter.VAR_KEYWORD: - quals.append("**") + kinds.append("**") if p.kind == inspect.Parameter.KEYWORD_ONLY: - quals.append("keyword") + kinds.append("keyword") if p.kind == inspect.Parameter.POSITIONAL_ONLY: - quals.append("positional") + kinds.append("positional") ann_type = _ann(ann) has_default = p.default is not empty if has_default: @@ -793,7 +790,7 @@ def _ann(x): Param[ typing.Literal[p.name], ann_type, - typing.Literal[*quals] if quals else typing.Never, + typing.Literal[*kinds] if kinds else typing.Never, default_type, ] ) diff --git a/typemap/typing.py b/typemap/typing.py index 90a0938..213696b 100644 --- a/typemap/typing.py +++ b/typemap/typing.py @@ -198,14 +198,14 @@ class Member[ type definer = D -ParamQuals = Literal["*", "**", "keyword", "positional"] +ParamKind = Literal["*", "**", "keyword", "positional"] @has_associated_types -class Param[N: str | None, T, Q: ParamQuals = typing.Never, D = typing.Never]: +class Param[N: str | None, T, K: ParamKind = typing.Never, D = typing.Never]: type name = N type type = T - type quals = Q + type kind = K type default = D @@ -234,7 +234,8 @@ def __class_getitem__(cls, params): type GetName[T: Member | Param] = T.name type GetType[T: Member | Param] = T.type -type GetQuals[T: Member | Param] = T.quals +type GetQuals[T: Member] = T.quals +type GetKind[T: Param] = T.kind type GetDefault[T: Param] = T.default type GetInit[T: Member] = T.init type GetDefiner[T: Member] = T.definer From 48d8f6ac98cd612c5580c12e022887287d6908f1 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 21:55:03 -0700 Subject: [PATCH 6/8] Drop the name from PosParam aliases, use bare None elsewhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PosParam and PosDefaultParam are positional-only, so the name is always None — there's no value in parameterizing it. The Args/Kwargs aliases also drop Literal[None] in favor of bare None for consistency with the PEP's helper definitions. --- typemap/typing.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/typemap/typing.py b/typemap/typing.py index 213696b..b90fbc1 100644 --- a/typemap/typing.py +++ b/typemap/typing.py @@ -209,13 +209,13 @@ class Param[N: str | None, T, K: ParamKind = typing.Never, D = typing.Never]: type default = D -type PosParam[N: str | None, T] = Param[N, T, Literal["positional"]] -type PosDefaultParam[N: str | None, T] = Param[N, T, Literal["positional"], T] +type PosParam[T] = Param[None, T, Literal["positional"]] +type PosDefaultParam[T] = Param[None, T, Literal["positional"], T] type DefaultParam[N: str, T] = Param[N, T, typing.Never, T] type NamedParam[N: str, T] = Param[N, T, Literal["keyword"]] type NamedDefaultParam[N: str, T] = Param[N, T, Literal["keyword"], T] -type ArgsParam[T] = Param[Literal[None], T, Literal["*"]] -type KwargsParam[T] = Param[Literal[None], T, Literal["**"]] +type ArgsParam[T] = Param[None, T, Literal["*"]] +type KwargsParam[T] = Param[None, T, Literal["**"]] class Params: From 4da0d9632920d35f81ebbca94510a2cca0453248 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 22:30:09 -0700 Subject: [PATCH 7/8] Bump mypy-typemap pin to the 4-arg Param stubs, drop xfail The fork now exposes Param[N, T, K, D] (with K replacing Q/quals and D carrying the default's type), so the previously-xfailed test_mypy[test_dataclass_like] and test_mypy[test_fastapilike_2] cases pass cleanly. Remove the xfail block. --- pyproject.toml | 2 +- tests/test_mypy_proto.py | 15 +-------------- uv.lock | 6 +++--- 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ee5d963..5c44225 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ include = ["typemap", "typemap.*", "typemap_extensions"] test = [ "pytest>=7.0", "ruff", - "mypy @ git+https://github.com/msullivan/mypy-typemap@fbc5d6c16834379307857318e6c32326b5d8a201", + "mypy @ git+https://github.com/msullivan/mypy-typemap@f127ae7a0b79b0d6b3fee9c82e75e62a12ac39e3", ] [tool.uv] diff --git a/tests/test_mypy_proto.py b/tests/test_mypy_proto.py index cf920a7..cd82a35 100644 --- a/tests/test_mypy_proto.py +++ b/tests/test_mypy_proto.py @@ -14,11 +14,6 @@ MYPY_SOURCE_DIR = pathlib.Path(_mypy_source).resolve() if _mypy_source else None -# Files that depend on the 4-arg Param shape, which the pinned -# mypy-typemap stub fork hasn't been updated for yet. -_XFAIL_PARAM_D = {"test_dataclass_like", "test_fastapilike_2"} - - def _collect_mypy_test_files(): """Collect test files that don't have # SKIP MYPY.""" tests_dir = pathlib.Path(__file__).parent @@ -27,15 +22,7 @@ def _collect_mypy_test_files(): continue text = path.read_text() if "# SKIP MYPY" not in text: - marks = [] - if path.stem in _XFAIL_PARAM_D: - marks.append( - pytest.mark.xfail( - reason="mypy-typemap stubs still have 3-arg Param", - strict=True, - ) - ) - yield pytest.param(path, id=path.stem, marks=marks) + yield pytest.param(path, id=path.stem) @pytest.mark.parametrize("test_file", _collect_mypy_test_files()) diff --git a/uv.lock b/uv.lock index d13dd20..ba4599c 100644 --- a/uv.lock +++ b/uv.lock @@ -56,8 +56,8 @@ wheels = [ [[package]] name = "mypy" -version = "1.20.0+dev.fbc5d6c16834379307857318e6c32326b5d8a201" -source = { git = "https://github.com/msullivan/mypy-typemap?rev=fbc5d6c16834379307857318e6c32326b5d8a201#fbc5d6c16834379307857318e6c32326b5d8a201" } +version = "1.20.0+dev.f127ae7a0b79b0d6b3fee9c82e75e62a12ac39e3" +source = { git = "https://github.com/msullivan/mypy-typemap?rev=f127ae7a0b79b0d6b3fee9c82e75e62a12ac39e3#f127ae7a0b79b0d6b3fee9c82e75e62a12ac39e3" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, @@ -168,7 +168,7 @@ test = [ [package.metadata.requires-dev] test = [ - { name = "mypy", git = "https://github.com/msullivan/mypy-typemap?rev=fbc5d6c16834379307857318e6c32326b5d8a201" }, + { name = "mypy", git = "https://github.com/msullivan/mypy-typemap?rev=f127ae7a0b79b0d6b3fee9c82e75e62a12ac39e3" }, { name = "pytest", specifier = ">=7.0" }, { name = "ruff" }, ] From d4a71b4392f192c744a8955d8acc962714b5cdb6 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 22:40:47 -0700 Subject: [PATCH 8/8] Make is_literal a TypeGuard, drop the type-ignores is_literal was returning bool, so mypy couldn't narrow callers' types after the check and Literal.__args__ access needed a type-ignore. is_generic_alias already returns TypeGuard[GenericAlias] right above it; is_literal had no reason not to. --- typemap/type_eval/_eval_operators.py | 17 ++++++++--------- typemap/type_eval/_typing_inspect.py | 2 +- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index b71c4fe..c983597 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -612,16 +612,15 @@ def _callable_type_to_signature(callable_type: object) -> inspect.Signature: # Handle default value from the 4th Param arg (D) default_type = param_args[3] if len(param_args) > 3 else typing.Never default: typing.Any - if default_type is not typing.Never: - if ( - _typing_inspect.is_literal(default_type) - and len(default_type.__args__) == 1 # type: ignore[union-attr] - ): - default = default_type.__args__[0] # type: ignore[union-attr] - else: - default = _DUMMY_DEFAULT - else: + if default_type is typing.Never: default = inspect.Parameter.empty + elif ( + _typing_inspect.is_literal(default_type) + and len(default_type.__args__) == 1 + ): + default = default_type.__args__[0] + else: + default = _DUMMY_DEFAULT # Generate a name for positional-only params if needed if name is None: diff --git a/typemap/type_eval/_typing_inspect.py b/typemap/type_eval/_typing_inspect.py index f5e96dd..0d7afa3 100644 --- a/typemap/type_eval/_typing_inspect.py +++ b/typemap/type_eval/_typing_inspect.py @@ -130,7 +130,7 @@ def is_optional_type(t: Any) -> TypeGuard[UnionType]: return is_union_type(t) and type(None) in get_args(t) -def is_literal(t: Any) -> bool: +def is_literal(t: Any) -> TypeGuard[GenericAlias]: return is_generic_alias(t) and get_origin(t) is Literal