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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
14 changes: 5 additions & 9 deletions tests/test_dataclass_like.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
Literal,
ReadOnly,
TypedDict,
Never,
)

import typemap_extensions as typing
Expand Down Expand Up @@ -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]]
],
Expand Down Expand Up @@ -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: ...
""")
7 changes: 4 additions & 3 deletions tests/test_fastapilike_1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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]]
],
Expand Down
18 changes: 7 additions & 11 deletions tests/test_fastapilike_2.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
Union,
ReadOnly,
TypedDict,
Never,
Self,
)

Expand Down Expand Up @@ -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]]
],
Expand Down Expand Up @@ -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: ...
""")


Expand All @@ -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: ...
""")


Expand All @@ -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: ...
""")
13 changes: 6 additions & 7 deletions tests/test_type_dir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]: ...
Expand Down Expand Up @@ -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]]"
)

Expand All @@ -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]]"
)


Expand All @@ -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]]"
)


Expand Down
25 changes: 19 additions & 6 deletions tests/test_type_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Comment thread
dnwpark marked this conversation as resolved.
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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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: ...
Expand Down
97 changes: 55 additions & 42 deletions typemap/type_eval/_eval_operators.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import collections.abc
import contextlib
import dataclasses
import enum
import functools
import inspect
import itertools
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}")
Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -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,
]
)

Expand Down
Loading
Loading