From dd303ede4d1989817ad4ff6b80850ddd55d4e7d1 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 3 Jun 2024 12:14:26 -0700 Subject: [PATCH 1/8] New implementation of float/int promotion --- docs/changelog.md | 3 + pyanalyze/annotations.py | 4 ++ pyanalyze/test_annotations.py | 92 +++++++++++++++++++--------- pyanalyze/test_attributes.py | 8 +-- pyanalyze/test_generators.py | 4 +- pyanalyze/test_implementation.py | 10 +-- pyanalyze/test_name_check_visitor.py | 14 ++--- pyanalyze/test_operations.py | 10 +-- pyanalyze/test_signature.py | 2 +- pyanalyze/test_type_evaluation.py | 4 +- pyanalyze/test_typeshed.py | 4 +- pyanalyze/test_typevar.py | 2 +- pyanalyze/test_value.py | 6 +- pyanalyze/type_object.py | 7 --- 14 files changed, 103 insertions(+), 67 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index a81531e9..138e1290 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,9 @@ ## Unreleased +- Change implementation of implicit int/float and float/complex promotion + in accordance with https://github.com/python/typing/pull/1748. Now, + annotations of `float` implicitly mean `float | int`. (#778) - Fix various issues with Python 3.13 and 3.14 support (#773) - Improve `ParamSpec` support (#772, #777) - Fix handling of stub functions with positional-only parameters with diff --git a/pyanalyze/annotations.py b/pyanalyze/annotations.py index a4939fa2..9669b659 100644 --- a/pyanalyze/annotations.py +++ b/pyanalyze/annotations.py @@ -1282,6 +1282,10 @@ def _maybe_typed_value(val: Union[type, str]) -> Value: return _HashableValue(val) elif val is Callable or is_typing_name(val, "Callable"): return CallableValue(ANY_SIGNATURE) + elif val is float: + return TypedValue(float) | TypedValue(int) + elif val is complex: + return TypedValue(complex) | TypedValue(float) | TypedValue(int) return TypedValue(val) diff --git a/pyanalyze/test_annotations.py b/pyanalyze/test_annotations.py index cc88d9f2..f7422399 100644 --- a/pyanalyze/test_annotations.py +++ b/pyanalyze/test_annotations.py @@ -37,7 +37,7 @@ def capybara() -> Union[int, str]: def kerodon() -> Optional[int]: return None - def complex() -> Union[List[str], Set[int], Dict[float, List[str]], int]: + def complex() -> Union[List[str], Set[int], Dict[bytes, List[str]], int]: return [] def union_in_subscript() -> List[Union[str, int]]: @@ -58,7 +58,7 @@ def check() -> None: GenericValue(set, [TypedValue(int)]), GenericValue( dict, - [TypedValue(float), GenericValue(list, [TypedValue(str)])], + [TypedValue(bytes), GenericValue(list, [TypedValue(str)])], ), TypedValue(int), ] @@ -756,14 +756,14 @@ def capybara( y: Callable[[int], str], id_func: Callable[[T], T], takes_seq: Callable[[Sequence[T]], T], - two_args: Callable[[int, str], float], + two_args: Callable[[int, str], bytes], ): assert_is_value(x(), TypedValue(int)) assert_is_value(x(arg=3), TypedValue(int)) assert_is_value(y(1), TypedValue(str)) assert_is_value(id_func(1), KnownValue(1)) assert_is_value(takes_seq([int("1")]), TypedValue(int)) - assert_is_value(two_args(1, "x"), TypedValue(float)) + assert_is_value(two_args(1, "x"), TypedValue(bytes)) @assert_passes() def test_stringified(self): @@ -776,14 +776,14 @@ def capybara( y: "Callable[[int], str]", id_func: "Callable[[T], T]", takes_seq: "Callable[[Sequence[T]], T]", - two_args: "Callable[[int, str], float]", + two_args: "Callable[[int, str], bytes]", ): assert_is_value(x(), TypedValue(int)) assert_is_value(x(arg=3), TypedValue(int)) assert_is_value(y(1), TypedValue(str)) assert_is_value(id_func(1), KnownValue(1)) assert_is_value(takes_seq([int("1")]), TypedValue(int)) - assert_is_value(two_args(1, "x"), TypedValue(float)) + assert_is_value(two_args(1, "x"), TypedValue(bytes)) @skip_before((3, 9)) @assert_passes() @@ -798,14 +798,14 @@ def capybara( y: Callable[[int], str], id_func: Callable[[T], T], takes_seq: Callable[[Sequence[T]], T], - two_args: Callable[[int, str], float], + two_args: Callable[[int, str], bytes], ): assert_is_value(x(), TypedValue(int)) assert_is_value(x(arg=3), TypedValue(int)) assert_is_value(y(1), TypedValue(str)) assert_is_value(id_func(1), KnownValue(1)) assert_is_value(takes_seq([int("1")]), TypedValue(int)) - assert_is_value(two_args(1, "x"), TypedValue(float)) + assert_is_value(two_args(1, "x"), TypedValue(bytes)) @assert_passes() def test_known_value(self): @@ -1043,21 +1043,24 @@ def test_callable_compatibility(self): AnyStr = TypeVar("AnyStr", bytes, str) IntT = TypeVar("IntT", bound=int) - class SupportsIsInteger(Protocol): - def is_integer(self) -> bool: + class SupportsInt(Protocol): + def __int__(self) -> int: raise NotImplementedError - SupportsIsIntegerT = TypeVar("SupportsIsIntegerT", bound=SupportsIsInteger) + SupportsIntT = TypeVar("SupportsIntT", bound=SupportsInt) - def find_int(objs: Iterable[SupportsIsIntegerT]) -> SupportsIsIntegerT: + def find_int(objs: Iterable[SupportsIntT]) -> SupportsIntT: for obj in objs: - if obj.is_integer(): + if obj.__int__() == obj: return obj raise ValueError def wants_float_func(f: Callable[[Iterable[float]], float]) -> float: return f([1.0, 2.0]) + def wants_int_func(f: Callable[[Iterable[int]], int]) -> int: + return f([1, 2]) + def want_anystr_func( f: Callable[[AnyStr], AnyStr], s: Union[str, bytes] ) -> str: @@ -1087,7 +1090,10 @@ def capybara(): want_bounded_func(anystr_func, 1) # E: incompatible_argument want_str_func(anystr_func) want_str_func(int_func) # E: incompatible_argument - wants_float_func(find_int) + assert_is_value(find_int([1.0, 2.0]), KnownValue(1.0) | KnownValue(2.0)) + # TODO this should work with wants_float_func but it doesn't. + # Some bug with binding TypeVars to a union? + wants_int_func(find_int) wants_float_func(int_func) # E: incompatible_argument @assert_passes() @@ -1503,7 +1509,7 @@ def test_typing_extensions(self): class RNR(TypedDict): a: int b: Required[str] - c: NotRequired[float] + c: NotRequired[bytes] def take_rnr(td: RNR) -> None: assert_is_value( @@ -1512,7 +1518,7 @@ def take_rnr(td: RNR) -> None: { "a": TypedDictEntry(TypedValue(int)), "b": TypedDictEntry(TypedValue(str)), - "c": TypedDictEntry(TypedValue(float), required=False), + "c": TypedDictEntry(TypedValue(bytes), required=False), } ), ) @@ -1520,7 +1526,7 @@ def take_rnr(td: RNR) -> None: class NotTotal(TypedDict, total=False): a: int b: Required[str] - c: NotRequired[float] + c: NotRequired[bytes] def take_not_total(td: NotTotal) -> None: assert_is_value( @@ -1529,7 +1535,7 @@ def take_not_total(td: NotTotal) -> None: { "a": TypedDictEntry(TypedValue(int), required=False), "b": TypedDictEntry(TypedValue(str)), - "c": TypedDictEntry(TypedValue(float), required=False), + "c": TypedDictEntry(TypedValue(bytes), required=False), } ), ) @@ -1537,7 +1543,7 @@ def take_not_total(td: NotTotal) -> None: class Stringify(TypedDict): a: "int" b: "Required[str]" - c: "NotRequired[float]" + c: "NotRequired[bytes]" def take_stringify(td: Stringify) -> None: assert_is_value( @@ -1546,7 +1552,7 @@ def take_stringify(td: Stringify) -> None: { "a": TypedDictEntry(TypedValue(int)), "b": TypedDictEntry(TypedValue(str)), - "c": TypedDictEntry(TypedValue(float), required=False), + "c": TypedDictEntry(TypedValue(bytes), required=False), } ), ) @@ -1560,7 +1566,7 @@ def test_typeddict_from_call(self): class Stringify(TypedDict): a: "int" b: "Required[str]" - c: "NotRequired[float]" + c: "NotRequired[bytes]" def make_td() -> Any: return Stringify @@ -1579,7 +1585,7 @@ def capybara() -> None: { "a": TypedDictEntry(TypedValue(int)), "b": TypedDictEntry(TypedValue(str)), - "c": TypedDictEntry(TypedValue(float), required=False), + "c": TypedDictEntry(TypedValue(bytes), required=False), } ), ) @@ -1590,7 +1596,7 @@ def capybara() -> None: { "a": TypedDictEntry(TypedValue(int)), "b": TypedDictEntry(TypedValue(str)), - "c": TypedDictEntry(TypedValue(float), required=False), + "c": TypedDictEntry(TypedValue(bytes), required=False), } ), ) @@ -1604,7 +1610,7 @@ def test_typing(self): class RNR(TypedDict): a: int b: Required[str] - c: NotRequired[float] + c: NotRequired[bytes] def take_rnr(td: RNR) -> None: assert_is_value( @@ -1613,7 +1619,7 @@ def take_rnr(td: RNR) -> None: { "a": TypedDictEntry(TypedValue(int)), "b": TypedDictEntry(TypedValue(str)), - "c": TypedDictEntry(TypedValue(float), required=False), + "c": TypedDictEntry(TypedValue(bytes), required=False), } ), ) @@ -1621,7 +1627,7 @@ def take_rnr(td: RNR) -> None: class NotTotal(TypedDict, total=False): a: int b: Required[str] - c: NotRequired[float] + c: NotRequired[bytes] def take_not_total(td: NotTotal) -> None: assert_is_value( @@ -1630,7 +1636,7 @@ def take_not_total(td: NotTotal) -> None: { "a": TypedDictEntry(TypedValue(int), required=False), "b": TypedDictEntry(TypedValue(str)), - "c": TypedDictEntry(TypedValue(float), required=False), + "c": TypedDictEntry(TypedValue(bytes), required=False), } ), ) @@ -1638,7 +1644,7 @@ def take_not_total(td: NotTotal) -> None: class Stringify(TypedDict): a: "int" b: "Required[str]" - c: "NotRequired[float]" + c: "NotRequired[bytes]" def take_stringify(td: Stringify) -> None: assert_is_value( @@ -1647,7 +1653,7 @@ def take_stringify(td: Stringify) -> None: { "a": TypedDictEntry(TypedValue(int)), "b": TypedDictEntry(TypedValue(str)), - "c": TypedDictEntry(TypedValue(float), required=False), + "c": TypedDictEntry(TypedValue(bytes), required=False), } ), ) @@ -2014,3 +2020,31 @@ def capybara(t: "TypedCapybara", u: "UntypedCapybara") -> None: assert_type(u.x, Any) assert_type(u.y, Any) + + +class TestFloatInt(TestNameCheckVisitorBase): + @assert_passes() + def test(self): + from typing_extensions import assert_type + + def capybara(x: float): + assert_is_value(x, TypedValue(float) | TypedValue(int)) + assert_type(x, float) + + if isinstance(x, float): + # can't express this type for assert_type() + assert_is_value(x, TypedValue(float)) + else: + assert_is_value(x, TypedValue(int)) + assert_type(x, int) + + @assert_passes() + def test_cast(self): + from typing import cast + + from typing_extensions import assert_type + + def capybara(x): + f = cast(float, x) + assert_is_value(x, TypedValue(float) | TypedValue(int)) + assert_type(x, float) diff --git a/pyanalyze/test_attributes.py b/pyanalyze/test_attributes.py index 883f574a..b8986fb9 100644 --- a/pyanalyze/test_attributes.py +++ b/pyanalyze/test_attributes.py @@ -14,7 +14,7 @@ assert_is_value, ) -_global_dict: Dict[Union[int, str], float] = {} +_global_dict: Dict[Union[int, str], bytes] = {} class TestAttributes(TestNameCheckVisitorBase): @@ -106,11 +106,11 @@ class B: x: str class C(B): - y: float + y: bytes def capybara() -> None: assert_is_value(A().x, TypedValue(int)) - assert_is_value(C().y, TypedValue(float)) + assert_is_value(C().y, TypedValue(bytes)) assert_is_value(C().x, TypedValue(str)) @assert_passes() @@ -304,7 +304,7 @@ def capybara(): assert_is_value( test_attributes._global_dict, GenericValue( - dict, [TypedValue(int) | TypedValue(str), TypedValue(float)] + dict, [TypedValue(int) | TypedValue(str), TypedValue(bytes)] ), ) assert_is_value( diff --git a/pyanalyze/test_generators.py b/pyanalyze/test_generators.py index 2101f4b1..b02c5a7d 100644 --- a/pyanalyze/test_generators.py +++ b/pyanalyze/test_generators.py @@ -10,7 +10,7 @@ class TestGenerator(TestNameCheckVisitorBase): def test_generator_return(self): from typing import Generator - def gen(cond) -> Generator[int, str, float]: + def gen(cond) -> Generator[int, str, bytes]: x = yield 1 assert_is_value(x, TypedValue(str)) yield "x" # E: incompatible_yield @@ -21,7 +21,7 @@ def gen(cond) -> Generator[int, str, float]: def capybara() -> Generator[int, int, int]: x = yield from gen(True) # E: incompatible_yield - assert_is_value(x, TypedValue(float)) + assert_is_value(x, TypedValue(bytes)) return 3 diff --git a/pyanalyze/test_implementation.py b/pyanalyze/test_implementation.py index 930c96c0..141412cd 100644 --- a/pyanalyze/test_implementation.py +++ b/pyanalyze/test_implementation.py @@ -1099,10 +1099,10 @@ def capybara(x): def test_tuple_annotation(self): from typing import Tuple - def capybara(tpl: Tuple[int, str, float]) -> None: + def capybara(tpl: Tuple[int, str, bytes]) -> None: assert_is_value(tpl[0], TypedValue(int)) assert_is_value(tpl[-2], TypedValue(str)) - assert_is_value(tpl[2], TypedValue(float)) + assert_is_value(tpl[2], TypedValue(bytes)) @assert_passes() def test_list_in_lambda(self): @@ -1189,7 +1189,7 @@ def test_complex_incomplete(self): from typing_extensions import NotRequired, TypedDict class TD(TypedDict): - a: float + a: bytes b: NotRequired[bool] def capybara(i: int, seq: Sequence[int], td: TD, s: str): @@ -1216,9 +1216,9 @@ def capybara(i: int, seq: Sequence[int], td: TD, s: str): d4 = {**d3, **td} assert_is_value(d4[1], KnownValue(1)) - assert_is_value(d4["a"], TypedValue(float)) + assert_is_value(d4["a"], TypedValue(bytes)) assert_is_value(d4["b"], KnownValue(2) | TypedValue(bool)) - assert_is_value(d4[s], TypedValue(float) | KnownValue(2) | TypedValue(bool)) + assert_is_value(d4[s], TypedValue(bytes) | KnownValue(2) | TypedValue(bool)) @assert_passes() def test(self): diff --git a/pyanalyze/test_name_check_visitor.py b/pyanalyze/test_name_check_visitor.py index 60618e65..bc75054f 100644 --- a/pyanalyze/test_name_check_visitor.py +++ b/pyanalyze/test_name_check_visitor.py @@ -640,14 +640,14 @@ def test_metaclass_method(self): from typing import Type class EnumMeta(type): - def __getitem__(self, x: str) -> float: + def __getitem__(self, x: str) -> bytes: return 42.0 class Enum(metaclass=EnumMeta): pass def capybara(enum: Type[Enum]) -> None: - assert_is_value(enum["x"], TypedValue(float)) + assert_is_value(enum["x"], TypedValue(bytes)) @assert_passes() def test_type_union(self): @@ -1478,14 +1478,14 @@ class MyMapping: def keys(self) -> List[bool]: raise NotImplementedError - def __getitem__(self, key: bool) -> float: + def __getitem__(self, key: bool) -> bytes: raise NotImplementedError def capybara(m: MyMapping): assert_is_value( {**m}, DictIncompleteValue( - dict, [KVPair(TypedValue(bool), TypedValue(float), is_many=True)] + dict, [KVPair(TypedValue(bool), TypedValue(bytes), is_many=True)] ), ) @@ -1729,11 +1729,11 @@ def capybara() -> None: assert_is_value(x, KnownValue(3)) y: int = 3 assert_is_value(y, TypedValue(int)) - z: float + z: bytes print(z) # E: undefined_name - y: float = 4.0 # E: already_declared - assert_is_value(y, TypedValue(float)) + y: bytes = b"ytes" # E: already_declared + assert_is_value(y, TypedValue(bytes)) @assert_passes() def test_final(self): diff --git a/pyanalyze/test_operations.py b/pyanalyze/test_operations.py index 0df5a0cc..053da8ed 100644 --- a/pyanalyze/test_operations.py +++ b/pyanalyze/test_operations.py @@ -116,8 +116,8 @@ def capybara(x): assert_is_value(1 + int(x), TypedValue(int)) assert_is_value(3 * int(x), TypedValue(int)) assert_is_value("foo" + str(x), TypedValue(str)) - assert_is_value(1 + float(x), TypedValue(float)) - assert_is_value(1.0 + int(x), TypedValue(float)) + assert_is_value(1 + float(x), TypedValue(float) | TypedValue(int)) + assert_is_value(1.0 + int(x), TypedValue(float) | TypedValue(int)) assert_is_value(3 * 3.0 + 1, KnownValue(10.0)) @assert_passes() @@ -169,7 +169,7 @@ def capybara(): @assert_passes() def test_int_float_product(self): def capybara(f: float, i: int): - assert_is_value(i * f, TypedValue(float)) + assert_is_value(i * f, TypedValue(float) | TypedValue(int)) @assert_passes() def test_contains(self): @@ -209,7 +209,9 @@ def __eq__(self, other: int) -> float: return 3.14 def capybara(x: X): - assert_is_value(x == 1, TypedValue(float), skip_annotated=True) + assert_is_value( + x == 1, TypedValue(float) | TypedValue(int), skip_annotated=True + ) assert_is_value(x == "x", TypedValue(bool), skip_annotated=True) class Container: diff --git a/pyanalyze/test_signature.py b/pyanalyze/test_signature.py index 3b773784..2a21256b 100644 --- a/pyanalyze/test_signature.py +++ b/pyanalyze/test_signature.py @@ -1125,7 +1125,7 @@ def pacarana(f: float): assert_is_value(f.__round__(), TypedValue(int)) assert_is_value(f.__round__(None), TypedValue(int)) f.__round__(ndigits=None) # E: incompatible_call - assert_is_value(f.__round__(1), TypedValue(float)) + assert_is_value(f.__round__(1), TypedValue(float) | TypedValue(int)) @assert_passes() def test_runtime(self): diff --git a/pyanalyze/test_type_evaluation.py b/pyanalyze/test_type_evaluation.py index b3a09523..69b55760 100644 --- a/pyanalyze/test_type_evaluation.py +++ b/pyanalyze/test_type_evaluation.py @@ -113,11 +113,11 @@ def compare_evaluated(x: object) -> Union[int, str, float]: def capybara(unannotated): assert_is_value(compare_evaluated(None), TypedValue(str)) - assert_is_value(compare_evaluated(1), TypedValue(float)) + assert_is_value(compare_evaluated(1), TypedValue(float) | TypedValue(int)) assert_is_value(compare_evaluated("x"), TypedValue(int)) assert_is_value( compare_evaluated(None if unannotated else 1), - TypedValue(str) | TypedValue(float), + TypedValue(str) | TypedValue(float) | TypedValue(int), ) @assert_passes() diff --git a/pyanalyze/test_typeshed.py b/pyanalyze/test_typeshed.py index 017e89fb..9714e876 100644 --- a/pyanalyze/test_typeshed.py +++ b/pyanalyze/test_typeshed.py @@ -56,7 +56,7 @@ def test_types(self): import math from typing import Container - assert_is_value(math.exp(1.0), TypedValue(float)) + assert_is_value(math.exp(1.0), TypedValue(float) | TypedValue(int)) assert_is_value("".isspace(), TypedValue(bool)) def capybara(x: Container[int]) -> None: @@ -209,7 +209,7 @@ def test_get_attribute(self) -> None: { "a": TypedDictEntry(TypedValue(int)), "b": TypedDictEntry(TypedValue(str)), - "c": TypedDictEntry(TypedValue(float)), + "c": TypedDictEntry(TypedValue(float) | TypedValue(int)), } ), } diff --git a/pyanalyze/test_typevar.py b/pyanalyze/test_typevar.py index cf32730a..efea41fc 100644 --- a/pyanalyze/test_typevar.py +++ b/pyanalyze/test_typevar.py @@ -305,7 +305,7 @@ def f(x: T) -> T: def capybara(si: SupportsIndex): assert_is_value(f(1), TypedValue(int)) assert_is_value(f(si), TypedValue(SupportsIndex)) - assert_is_value(f(1.0), TypedValue(float)) + assert_is_value(f(1.0), TypedValue(float) | TypedValue(int)) @assert_passes() def test_lots_of_constraints(self): diff --git a/pyanalyze/test_value.py b/pyanalyze/test_value.py index a9476a08..99856783 100644 --- a/pyanalyze/test_value.py +++ b/pyanalyze/test_value.py @@ -140,10 +140,10 @@ def test_typed_value() -> None: float_val = TypedValue(float) assert str(float_val) == "float" assert_can_assign(float_val, KnownValue(1.0)) - assert_can_assign(float_val, KnownValue(1)) + assert_cannot_assign(float_val, KnownValue(1)) assert_cannot_assign(float_val, KnownValue("")) assert_can_assign(float_val, TypedValue(float)) - assert_can_assign(float_val, TypedValue(int)) + assert_cannot_assign(float_val, TypedValue(int)) assert_cannot_assign(float_val, TypedValue(str)) assert_can_assign(float_val, TypedValue(mock.Mock)) @@ -199,7 +199,7 @@ def test_subclass_value() -> None: assert TypedValue(str) == val.typ assert val.is_type(str) assert not val.is_type(int) - val = SubclassValue(TypedValue(float)) + val = SubclassValue(TypedValue(float)) | SubclassValue(TypedValue(int)) assert_can_assign(val, KnownValue(int)) assert_can_assign(val, SubclassValue(TypedValue(int))) diff --git a/pyanalyze/type_object.py b/pyanalyze/type_object.py index 4c06247e..cadb299b 100644 --- a/pyanalyze/type_object.py +++ b/pyanalyze/type_object.py @@ -66,13 +66,6 @@ def __post_init__(self) -> None: self.is_universally_assignable = issubclass(self.typ, mock.NonCallableMock) self.is_thrift_enum = hasattr(self.typ, "_VALUES_TO_NAMES") self.base_classes |= set(get_mro(self.typ)) - # As a special case, the Python type system treats int as - # a subtype of float, and both int and float as subtypes of complex. - if self.typ is int or safe_in(int, self.base_classes): - self.artificial_bases.add(float) - self.artificial_bases.add(complex) - if self.typ is float or safe_in(float, self.base_classes): - self.artificial_bases.add(complex) if self.is_thrift_enum: self.artificial_bases.add(int) self.base_classes |= self.artificial_bases From b0ff521434d1daeb1feeed69059bfd1f8597ab65 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 4 Jun 2024 16:44:42 -0700 Subject: [PATCH 2/8] fix tests --- pyanalyze/test_annotations.py | 4 ++-- pyanalyze/test_generators.py | 2 +- pyanalyze/test_name_check_visitor.py | 2 +- pyanalyze/test_signature.py | 2 ++ 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pyanalyze/test_annotations.py b/pyanalyze/test_annotations.py index f7422399..d41765d9 100644 --- a/pyanalyze/test_annotations.py +++ b/pyanalyze/test_annotations.py @@ -2046,5 +2046,5 @@ def test_cast(self): def capybara(x): f = cast(float, x) - assert_is_value(x, TypedValue(float) | TypedValue(int)) - assert_type(x, float) + assert_is_value(f, TypedValue(float) | TypedValue(int)) + assert_type(f, float) diff --git a/pyanalyze/test_generators.py b/pyanalyze/test_generators.py index b02c5a7d..22ac2d7d 100644 --- a/pyanalyze/test_generators.py +++ b/pyanalyze/test_generators.py @@ -15,7 +15,7 @@ def gen(cond) -> Generator[int, str, bytes]: assert_is_value(x, TypedValue(str)) yield "x" # E: incompatible_yield if cond: - return 3.0 + return b"hello" else: return "hello" # E: incompatible_return_value diff --git a/pyanalyze/test_name_check_visitor.py b/pyanalyze/test_name_check_visitor.py index bc75054f..d94bccbe 100644 --- a/pyanalyze/test_name_check_visitor.py +++ b/pyanalyze/test_name_check_visitor.py @@ -641,7 +641,7 @@ def test_metaclass_method(self): class EnumMeta(type): def __getitem__(self, x: str) -> bytes: - return 42.0 + return b"hi" class Enum(metaclass=EnumMeta): pass diff --git a/pyanalyze/test_signature.py b/pyanalyze/test_signature.py index 2a21256b..7de6d72b 100644 --- a/pyanalyze/test_signature.py +++ b/pyanalyze/test_signature.py @@ -1122,6 +1122,8 @@ def capybara(): print("x", file="not a file") # E: incompatible_argument def pacarana(f: float): + # https://github.com/python/cpython/issues/120080 + assert isinstance(f, float) assert_is_value(f.__round__(), TypedValue(int)) assert_is_value(f.__round__(None), TypedValue(int)) f.__round__(ndigits=None) # E: incompatible_call From eb0d4ac7d255d3940abde05d9810f5e98d114d04 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 4 Jun 2024 16:54:48 -0700 Subject: [PATCH 3/8] Fix crash on subclasses --- pyanalyze/arg_spec.py | 13 +++++++--- pyanalyze/test_annotations.py | 46 +++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/pyanalyze/arg_spec.py b/pyanalyze/arg_spec.py index 563adf3a..5d2bef1d 100644 --- a/pyanalyze/arg_spec.py +++ b/pyanalyze/arg_spec.py @@ -1067,10 +1067,7 @@ def _get_generic_bases_cached(self, typ: Union[type, str]) -> GenericBases: assert isinstance( typ, type ), f"failed to extract typeshed bases for {typ!r}" - bases = [ - type_from_runtime(base, ctx=self.default_context) - for base in self.get_runtime_bases(typ) - ] + bases = [self._type_from_base(base) for base in self.get_runtime_bases(typ)] generic_bases = self._extract_bases(typ, bases) assert ( generic_bases is not None @@ -1078,6 +1075,14 @@ def _get_generic_bases_cached(self, typ: Union[type, str]) -> GenericBases: self.generic_bases_cache[typ] = generic_bases return generic_bases + def _type_from_base(self, base: object) -> Value: + # Avoid promoting float to float|int here. + if base is float: + return TypedValue(float) + elif base is complex: + return TypedValue(complex) + return type_from_runtime(base, ctx=self.default_context) + def _extract_bases( self, typ: Union[type, str], bases: Optional[Sequence[Value]] ) -> Optional[GenericBases]: diff --git a/pyanalyze/test_annotations.py b/pyanalyze/test_annotations.py index d41765d9..17d8fb67 100644 --- a/pyanalyze/test_annotations.py +++ b/pyanalyze/test_annotations.py @@ -2038,6 +2038,22 @@ def capybara(x: float): assert_is_value(x, TypedValue(int)) assert_type(x, int) + @assert_passes() + def test_complex(self): + from typing_extensions import assert_type + + def capybara(x: complex): + assert_is_value( + x, TypedValue(complex) | TypedValue(float) | TypedValue(int) + ) + assert_type(x, complex) + + if isinstance(x, float): + # can't express this type for assert_type() + assert_is_value(x, TypedValue(float)) + else: + assert_is_value(x, TypedValue(int) | TypedValue(complex)) + @assert_passes() def test_cast(self): from typing import cast @@ -2048,3 +2064,33 @@ def capybara(x): f = cast(float, x) assert_is_value(f, TypedValue(float) | TypedValue(int)) assert_type(f, float) + + @assert_passes() + def test_float_subclass(self): + from typing_extensions import assert_type + + class MyFloat(float): + pass + + def capybara(x: MyFloat): + assert_is_value(x, TypedValue(MyFloat)) + assert_type(x, MyFloat) + + def caller(): + capybara(MyFloat(1.0)) + capybara(1.0) # E: incompatible_argument + + @assert_passes() + def test_complex_subclass(self): + from typing_extensions import assert_type + + class MyComplex(complex): + pass + + def capybara(x: MyComplex): + assert_is_value(x, TypedValue(MyComplex)) + assert_type(x, MyComplex) + + def caller(): + capybara(MyComplex(1.0)) + capybara(1.0j) # E: incompatible_argument From 43cccc28a0ab04610646b03ae27b44952f16dc9a Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 4 Jul 2025 18:39:55 -0700 Subject: [PATCH 4/8] new test --- pycroscope/test_value.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pycroscope/test_value.py b/pycroscope/test_value.py index 3039041c..71a098d5 100644 --- a/pycroscope/test_value.py +++ b/pycroscope/test_value.py @@ -612,6 +612,11 @@ def test_annotated_value() -> None: assert_can_assign(AnnotatedValue(tv_int, [tv_int]), tv_int) assert_can_assign(tv_int, AnnotatedValue(tv_int, [tv_int])) + union = TypedValue(int) | TypedValue(float) + annotated = AnnotatedValue(union, [KnownValue(1)]) + assert_can_assign(annotated, union) + assert_can_assign(union, annotated) + class A: pass From dccadbbf68edeb378a751f30e07429508ce20006 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 4 Jul 2025 18:50:27 -0700 Subject: [PATCH 5/8] fix --- pycroscope/relations.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/pycroscope/relations.py b/pycroscope/relations.py index 00ce328d..ac80aacc 100644 --- a/pycroscope/relations.py +++ b/pycroscope/relations.py @@ -191,7 +191,15 @@ def _has_relation( right: GradualType, relation: Literal[Relation.SUBTYPE, Relation.ASSIGNABLE], ctx: CanAssignContext, + *, + original_left: Optional[GradualType] = None, + original_right: Optional[GradualType] = None, ) -> CanAssign: + if original_right is None: + original_right = right + if original_left is None: + original_left = left + # TypeVarValue if isinstance(left, TypeVarValue): if left == right: @@ -224,7 +232,7 @@ def _has_relation( # AnnotatedValue if isinstance(left, AnnotatedValue): left_inner = gradualize(left.value) - can_assign = _has_relation(left_inner, right, relation, ctx) + can_assign = _has_relation(left_inner, right, relation, ctx, original_left=left) if isinstance(can_assign, CanAssignError): return can_assign bounds_maps = [can_assign] @@ -234,9 +242,11 @@ def _has_relation( return custom_can_assign bounds_maps.append(custom_can_assign) return unify_bounds_maps(bounds_maps) - if isinstance(right, AnnotatedValue) and not isinstance(left, MultiValuedValue): + if isinstance(right, AnnotatedValue): right_inner = gradualize(right.value) - can_assign = _has_relation(left, right_inner, relation, ctx) + can_assign = _has_relation( + left, right_inner, relation, ctx, original_right=right + ) if isinstance(can_assign, CanAssignError): return can_assign bounds_maps = [can_assign] @@ -252,14 +262,14 @@ def _has_relation( # Try to simplify first left = intersect_multi(left.vals, ctx) if not isinstance(left, IntersectionValue): - return _has_relation(left, right, relation, ctx) + return _has_relation(left, original_right, relation, ctx) if isinstance(right, IntersectionValue): right = intersect_multi(right.vals, ctx) # Must be a subtype of all the members bounds_maps = [] errors = [] for val in left.vals: - can_assign = _has_relation(gradualize(val), right, relation, ctx) + can_assign = _has_relation(gradualize(val), original_right, relation, ctx) if isinstance(can_assign, CanAssignError): errors.append(can_assign) else: @@ -272,12 +282,12 @@ def _has_relation( if isinstance(right, IntersectionValue): right = intersect_multi(right.vals, ctx) if not isinstance(right, IntersectionValue): - return _has_relation(left, right, relation, ctx) + return _has_relation(original_left, right, relation, ctx) # At least one member must be a subtype bounds_maps = [] errors = [] for val in right.vals: - can_assign = _has_relation(left, gradualize(val), relation, ctx) + can_assign = _has_relation(original_left, gradualize(val), relation, ctx) if isinstance(can_assign, CanAssignError): errors.append(can_assign) else: @@ -304,7 +314,7 @@ def _has_relation( errors = [] for val in left.vals: val = gradualize(val) - can_assign = _has_relation(val, right, relation, ctx) + can_assign = _has_relation(val, original_right, relation, ctx) if isinstance(can_assign, CanAssignError): errors.append(can_assign) else: @@ -326,7 +336,7 @@ def _has_relation( bounds_maps = [] for val in right.vals: val = gradualize(val) - can_assign = _has_relation(left, val, relation, ctx) + can_assign = _has_relation(original_left, val, relation, ctx) if isinstance(can_assign, CanAssignError): # Adding an additional layer here isn't helpful return can_assign From 5eb7d021a0ee6fa90ac04ccf82eb01779d1bc0e7 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 4 Jul 2025 21:02:55 -0700 Subject: [PATCH 6/8] fix NewType --- docs/changelog.md | 1 + pycroscope/relations.py | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 827c8302..eba3ef7f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,7 @@ - Change implementation of implicit int/float and float/complex promotion in accordance with https://github.com/python/typing/pull/1748. Now, annotations of `float` implicitly mean `float | int`. +- Fix assignability for certain combinations of unions, `Annotated`, and `NewType`. - Reduce more uninhabited intersections to `Never` ## Version 0.2.0 (June 26, 2025) diff --git a/pycroscope/relations.py b/pycroscope/relations.py index ac80aacc..9b867ab7 100644 --- a/pycroscope/relations.py +++ b/pycroscope/relations.py @@ -257,6 +257,19 @@ def _has_relation( bounds_maps.append(custom_can_assign) return unify_bounds_maps(bounds_maps) + # NewTypeValue + if isinstance(left, NewTypeValue): + if isinstance(right, NewTypeValue): + if left.newtype is right.newtype: + return {} + else: + return CanAssignError(f"{right} is not {relation.description} {left}") + else: + return CanAssignError(f"{right} is not {relation.description} {left}") + if isinstance(right, NewTypeValue): + right_inner = gradualize(right.value) + return _has_relation(left, right_inner, relation, ctx) + # IntersectionValue if isinstance(left, IntersectionValue): # Try to simplify first @@ -405,19 +418,6 @@ def _has_relation( if isinstance(right, ParamSpecKwargsValue): return has_relation(left, right.get_fallback_value(), relation, ctx) - # NewTypeValue - if isinstance(left, NewTypeValue): - if isinstance(right, NewTypeValue): - if left.newtype is right.newtype: - return {} - else: - return CanAssignError(f"{right} is not {relation.description} {left}") - else: - return CanAssignError(f"{right} is not {relation.description} {left}") - if isinstance(right, NewTypeValue): - right_inner = gradualize(right.value) - return _has_relation(left, right_inner, relation, ctx) - # UnboundMethodValue if isinstance(left, UnboundMethodValue): if isinstance(right, UnboundMethodValue) and left == right: From c65290481de36d9a0b51dbc3df594d71d9cfc6c9 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 4 Jul 2025 21:22:47 -0700 Subject: [PATCH 7/8] fix --- pycroscope/relations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pycroscope/relations.py b/pycroscope/relations.py index 9b867ab7..6806f558 100644 --- a/pycroscope/relations.py +++ b/pycroscope/relations.py @@ -268,7 +268,7 @@ def _has_relation( return CanAssignError(f"{right} is not {relation.description} {left}") if isinstance(right, NewTypeValue): right_inner = gradualize(right.value) - return _has_relation(left, right_inner, relation, ctx) + return _has_relation(left, right_inner, relation, ctx, original_right=right) # IntersectionValue if isinstance(left, IntersectionValue): From 49486e8a6df2514c712b558f36b8dbe7f93f1f4a Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 4 Jul 2025 21:38:09 -0700 Subject: [PATCH 8/8] tweak --- pycroscope/relations.py | 3 +++ pycroscope/test_value.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/pycroscope/relations.py b/pycroscope/relations.py index 6806f558..3e50dc36 100644 --- a/pycroscope/relations.py +++ b/pycroscope/relations.py @@ -264,6 +264,8 @@ def _has_relation( return {} else: return CanAssignError(f"{right} is not {relation.description} {left}") + elif isinstance(right, AnyValue): + pass else: return CanAssignError(f"{right} is not {relation.description} {left}") if isinstance(right, NewTypeValue): @@ -382,6 +384,7 @@ def _has_relation( return {} # Any is assignable to everything else: assert_never(relation) + assert not isinstance(left, NewTypeValue) # SyntheticModuleValue if isinstance(left, SyntheticModuleValue): diff --git a/pycroscope/test_value.py b/pycroscope/test_value.py index 71a098d5..feeb9490 100644 --- a/pycroscope/test_value.py +++ b/pycroscope/test_value.py @@ -606,6 +606,9 @@ def test_new_type_value() -> None: assert_cannot_assign(nt1_val, TypedValue(Capybara)) assert_cannot_assign(nt1_val, KnownValue(Capybara.hydrochaeris)) + assert_can_assign(nt1_val, AnyValue(AnySource.marker)) + assert_can_assign(AnyValue(AnySource.marker), nt1_val) + def test_annotated_value() -> None: tv_int = TypedValue(int)