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_dataclass_like.py b/tests/test_dataclass_like.py index 36a66bf..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 @@ -70,13 +69,10 @@ def _check_hero_init() -> None: p.name, p.type, # All arguments are keyword-only - # It takes a default if a default is specified in the class - Literal["keyword"] - if typing.IsAssignable[ - GetDefault[p.init], - Never, - ] - else Literal["keyword", "default"], + Literal["keyword"], + # 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]] ], @@ -156,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_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..a22b488 100644 --- a/tests/test_fastapilike_2.py +++ b/tests/test_fastapilike_2.py @@ -6,7 +6,6 @@ Union, ReadOnly, TypedDict, - Never, Self, ) @@ -150,13 +149,10 @@ class Field[T: FieldArgs](typing.InitField[T]): p.name, p.type, # All arguments are keyword-only - # It takes a default if a default is specified in the class - Literal["keyword"] - if typing.IsAssignable[ - GetDefault[p.init], - Never, - ] - else Literal["keyword", "default"], + Literal["keyword"], + # 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]] ], @@ -267,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: ... """) @@ -293,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: ... """) @@ -306,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: ... """) diff --git a/tests/test_type_dir.py b/tests/test_type_dir.py index 894c5d2..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]: ... @@ -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'], typing.Literal[0]]], \ 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..5915ecc 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, @@ -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 cb9a799..c983597 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 @@ -493,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: @@ -562,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}") @@ -575,47 +582,45 @@ 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 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 - 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: @@ -661,11 +666,10 @@ def fn(*args, **kwargs): def _is_pos_only(param): - name, _, quals = typing.get_args(param) - qual_set = _get_quals(quals) - return "positional" in qual_set or ( - name is None and not (_get_quals(quals) & {"*", "**"}) - ) + args = typing.get_args(param) + 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): @@ -692,14 +696,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) @@ -762,22 +766,31 @@ 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") - if p.default is not empty: - quals.append("default") + kinds.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(ann), - typing.Literal[*quals] if quals else typing.Never, + ann_type, + typing.Literal[*kinds] if kinds else typing.Never, + default_type, ] ) 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 diff --git a/typemap/typing.py b/typemap/typing.py index b258951..b90fbc1 100644 --- a/typemap/typing.py +++ b/typemap/typing.py @@ -198,25 +198,24 @@ class Member[ type definer = D -ParamQuals = Literal["*", "**", "keyword", "positional", "default"] +ParamKind = Literal["*", "**", "keyword", "positional"] @has_associated_types -class Param[N: str | None, T, Q: ParamQuals = 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 -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 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", "default"]] -type ArgsParam[T] = Param[Literal[None], T, Literal["*"]] -type KwargsParam[T] = Param[Literal[None], T, Literal["**"]] +type NamedDefaultParam[N: str, T] = Param[N, T, Literal["keyword"], T] +type ArgsParam[T] = Param[None, T, Literal["*"]] +type KwargsParam[T] = Param[None, T, Literal["**"]] class Params: @@ -235,7 +234,9 @@ 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 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" }, ]