Skip to content

Commit

Permalink
Merge branch 'master' into floatint
Browse files Browse the repository at this point in the history
  • Loading branch information
JelleZijlstra authored Jul 11, 2024
2 parents 76aeecb + 668ee5a commit d637578
Show file tree
Hide file tree
Showing 10 changed files with 127 additions and 36 deletions.
8 changes: 8 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
- 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)
- Improve representation of known module, function, and type objects
in error messages (#788)
- Add a mechanism to allow overriding the global variables in an
analyzed module. Use this mechanism to set the type of
`qcore.testing.Anything` to `Any`. (#786)
- Rename the `is_compatible` and `get_compatibility_error` functions
to `is_assignable` and `get_assignability_error` to align with the
terminology in the typing spec (#785)
- Fix binary operations involving unions wrapped in `Annotated` (#779)
- Fix various issues with Python 3.13 and 3.14 support (#773)
- Improve `ParamSpec` support (#772, #777)
Expand Down
14 changes: 10 additions & 4 deletions pyanalyze/implementation.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@
import qcore
import typing_extensions

from . import runtime
from .annotated_types import MaxLen, MinLen
from .annotations import type_from_value
from .error_code import ErrorCode
from .extensions import assert_type, reveal_locals, reveal_type
from .format_strings import parse_format_string
from .predicates import IsAssignablePredicate
from .runtime import is_compatible
from .safe import hasattr_static, is_union, safe_isinstance, safe_issubclass
from .signature import (
ANY_SIGNATURE,
Expand Down Expand Up @@ -1342,7 +1342,7 @@ def _assert_is_value_impl(ctx: CallContext) -> Value:
return KnownValue(None)


def _is_compatible_impl(ctx: CallContext) -> Value:
def _is_assignable_impl(ctx: CallContext) -> Value:
typ = ctx.vars["typ"]
if not isinstance(typ, KnownValue):
return TypedValue(bool)
Expand Down Expand Up @@ -1697,8 +1697,14 @@ def get_default_argspecs() -> Dict[object, Signature]:
Signature.make(
[SigParameter("value"), SigParameter("typ")],
return_annotation=TypedValue(bool),
impl=_is_compatible_impl,
callable=is_compatible,
impl=_is_assignable_impl,
callable=runtime.is_assignable,
),
Signature.make(
[SigParameter("value"), SigParameter("typ")],
return_annotation=TypedValue(bool),
impl=_is_assignable_impl,
callable=runtime.is_compatible, # static analysis: ignore[deprecated]
),
Signature.make(
[SigParameter("value", _POS_ONLY, annotation=TypeVarValue(T))],
Expand Down
27 changes: 25 additions & 2 deletions pyanalyze/name_check_visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
import asynq
import qcore
import typeshed_client
from qcore.testing import Anything
from typing_extensions import Annotated, Protocol, get_args, get_origin

from . import attributes, format_strings, importer, node_visitor, type_evaluation
Expand Down Expand Up @@ -627,6 +628,19 @@ def should_check_for_duplicate_values(cls: object, options: Options) -> bool:
return True


def _anything_to_any(obj: object) -> Optional[Value]:
if obj is Anything:
return AnyValue(AnySource.explicit)
return None


class TransformGlobals(PyObjectSequenceOption[Callable[[object], Optional[Value]]]):
"""Transform global variables."""

name = "transform_globals"
default_value = [_anything_to_any]


class IgnoredTypesForAttributeChecking(PyObjectSequenceOption[type]):
"""Used in the check for object attributes that are accessed but not set. In general, the check
will only alert about attributes that don't exist when it has visited all the base classes of
Expand Down Expand Up @@ -1181,6 +1195,7 @@ def __init__(
self.scopes = build_stacked_scopes(
self.module,
simplification_limit=self.options.get_value_for(UnionSimplificationLimit),
options=self.options,
)
self.node_context = StackedContexts()
self.asynq_checker = AsynqChecker(
Expand Down Expand Up @@ -5916,7 +5931,10 @@ def visit_expression(self, node: ast.AST) -> Value:


def build_stacked_scopes(
module: Optional[types.ModuleType], simplification_limit: Optional[int] = None
module: Optional[types.ModuleType],
simplification_limit: Optional[int] = None,
*,
options: Options,
) -> StackedScopes:
# Build a StackedScopes object.
# Not part of stacked_scopes.py to avoid a circular dependency.
Expand All @@ -5928,7 +5946,12 @@ def build_stacked_scopes(
for key, value in module.__dict__.items():
val = type_from_annotations(annotations, key, globals=module.__dict__)
if val is None:
val = KnownValue(value)
for transformer in options.get_value_for(TransformGlobals):
maybe_val = transformer(value)
if maybe_val is not None:
val = maybe_val
if val is None:
val = KnownValue(value)
module_vars[key] = val
return StackedScopes(module_vars, module, simplification_limit=simplification_limit)

Expand Down
42 changes: 31 additions & 11 deletions pyanalyze/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from functools import lru_cache
from typing import Optional

from typing_extensions import deprecated

import pyanalyze

from .annotations import type_from_runtime
Expand All @@ -19,38 +21,42 @@ def _get_checker() -> "pyanalyze.checker.Checker":
return pyanalyze.checker.Checker()


@used
def is_compatible(value: object, typ: object) -> bool:
"""Return whether ``value`` is compatible with ``type``.
def is_assignable(value: object, typ: object) -> bool:
"""Return whether ``value`` is assignable to ``typ``.
This is essentially a more powerful version of ``isinstance()``.
Examples::
>>> is_compatible(42, list[int])
>>> is_assignable(42, list[int])
False
>>> is_compatible([], list[int])
>>> is_assignable([], list[int])
True
>>> is_compatible(["x"], list[int])
>>> is_assignable(["x"], list[int])
False
The term "assignable" is defined in the typing specification:
https://typing.readthedocs.io/en/latest/spec/glossary.html#term-assignable
"""
val = type_from_runtime(typ)
can_assign = val.can_assign(KnownValue(value), _get_checker())
return not isinstance(can_assign, CanAssignError)


@used
def get_compatibility_error(value: object, typ: object) -> Optional[str]:
def get_assignability_error(value: object, typ: object) -> Optional[str]:
"""Return an error message explaining why ``value`` is not
compatible with ``type``, or None if they are compatible.
assignable to ``type``, or None if it is assignable.
Examples::
>>> print(get_compatibility_error(42, list[int]))
>>> print(get_assignability_error(42, list[int]))
Cannot assign Literal[42] to list
>>> print(get_compatibility_error([], list[int]))
>>> print(get_assignability_error([], list[int]))
None
>>> print(get_compatibility_error(["x"], list[int]))
>>> print(get_assignability_error(["x"], list[int]))
In element 0
Cannot assign Literal['x'] to int
Expand All @@ -60,3 +66,17 @@ def get_compatibility_error(value: object, typ: object) -> Optional[str]:
if isinstance(can_assign, CanAssignError):
return can_assign.display(depth=0)
return None


@used
@deprecated("Use is_assignable instead")
def is_compatible(value: object, typ: object) -> bool:
"""Deprecated alias for is_assignable(). Use that instead."""
return is_assignable(value, typ)


@used
@deprecated("Use get_assignability_error instead")
def get_compatibility_error(value: object, typ: object) -> Optional[str]:
"""Deprecated alias for get_assignability_error(). Use that instead."""
return get_assignability_error(value, typ)
7 changes: 7 additions & 0 deletions pyanalyze/test_name_check_visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,13 @@ def capybara(foo):

assert_is_value(assert_eq, KnownValue(_assert_eq))

@assert_passes()
def test_transform_globals(self):
from qcore.testing import Anything

def f():
assert_is_value(Anything, AnyValue(AnySource.explicit))


class TestComprehensions(TestNameCheckVisitorBase):
@assert_passes()
Expand Down
28 changes: 14 additions & 14 deletions pyanalyze/test_runtime.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
# static analysis: ignore
from typing import List

from .runtime import get_compatibility_error, is_compatible
from .runtime import get_assignability_error, is_assignable
from .test_name_check_visitor import TestNameCheckVisitorBase
from .test_node_visitor import assert_passes


def test_is_compatible() -> None:
assert not is_compatible(42, List[int])
assert is_compatible([], List[int])
assert not is_compatible(["x"], List[int])
assert is_compatible([1, 2, 3], List[int])
def test_is_assignable() -> None:
assert not is_assignable(42, List[int])
assert is_assignable([], List[int])
assert not is_assignable(["x"], List[int])
assert is_assignable([1, 2, 3], List[int])


def test_get_compatibility_error() -> None:
def test_get_assignability_error() -> None:
assert (
get_compatibility_error(42, List[int]) == "Cannot assign Literal[42] to list\n"
get_assignability_error(42, List[int]) == "Cannot assign Literal[42] to list\n"
)
assert get_compatibility_error([], List[int]) is None
assert get_assignability_error([], List[int]) is None
assert (
get_compatibility_error(["x"], List[int])
get_assignability_error(["x"], List[int])
== "In element 0\n Cannot assign Literal['x'] to int\n"
)
assert get_compatibility_error([1, 2, 3], List[int]) is None
assert get_assignability_error([1, 2, 3], List[int]) is None


class TestRuntimeTypeGuard(TestNameCheckVisitorBase):
Expand All @@ -31,7 +31,7 @@ def test_runtime(self):
from annotated_types import Predicate
from typing_extensions import Annotated

from pyanalyze.runtime import is_compatible
from pyanalyze.runtime import is_assignable

IsLower = Annotated[str, Predicate(str.islower)]

Expand All @@ -40,9 +40,9 @@ def want_lowercase(s: IsLower) -> None:

def capybara(s: str) -> None:
want_lowercase(s) # E: incompatible_argument
if is_compatible(s, IsLower):
if is_assignable(s, IsLower):
want_lowercase(s)

def asserting_capybara(s: str) -> None:
assert is_compatible(s, IsLower)
assert is_assignable(s, IsLower)
want_lowercase(s)
3 changes: 2 additions & 1 deletion pyanalyze/test_stacked_scopes.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# static analysis: ignore
from .error_code import ErrorCode
from .name_check_visitor import build_stacked_scopes
from .options import Options
from .stacked_scopes import ScopeType, uniq_chain
from .test_name_check_visitor import TestNameCheckVisitorBase
from .test_node_visitor import assert_passes, skip_before
Expand Down Expand Up @@ -29,7 +30,7 @@ class Module:

class TestStackedScopes:
def setup_method(self):
self.scopes = build_stacked_scopes(Module)
self.scopes = build_stacked_scopes(Module, options=Options({}))

def test_scope_type(self):
assert ScopeType.module_scope == self.scopes.scope_type()
Expand Down
12 changes: 10 additions & 2 deletions pyanalyze/test_value.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,16 @@ def test_any_value() -> None:
def test_known_value() -> None:
val = KnownValue(3)
assert 3 == val.val
assert "Literal[3]" == str(val)
assert "Literal['']" == str(KnownValue(""))
assert str(val) == "Literal[3]"
assert str(KnownValue("")) == "Literal['']"
assert str(KnownValue(None)) == "None"
assert str(KnownValue(str)) == "type 'str'"
assert str(KnownValue(KnownValue)) == "type 'pyanalyze.value.KnownValue'"
assert (
str(KnownValue(test_known_value))
== "function 'pyanalyze.test_value.test_known_value'"
)
assert str(KnownValue(ast)) == "module 'ast'"
assert val.is_type(int)
assert not val.is_type(str)

Expand Down
20 changes: 19 additions & 1 deletion pyanalyze/value.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def function(x: int, y: list[int], z: Any):
from collections import deque
from dataclasses import InitVar, dataclass, field
from itertools import chain
from types import FunctionType
from types import FunctionType, ModuleType
from typing import (
Any,
Callable,
Expand Down Expand Up @@ -583,6 +583,12 @@ def __hash__(self) -> int:
def __str__(self) -> str:
if self.val is None:
return "None"
elif isinstance(self.val, ModuleType):
return f"module {self.val.__name__!r}"
elif isinstance(self.val, FunctionType):
return f"function {get_fully_qualified_name(self.val)!r}"
elif isinstance(self.val, type):
return f"type {get_fully_qualified_name(self.val)!r}"
else:
return f"Literal[{self.val!r}]"

Expand All @@ -601,6 +607,18 @@ def simplify(self) -> Value:
return val.simplify()


def get_fully_qualified_name(obj: Union[FunctionType, type]) -> str:
mod = getattr(obj, "__module__", None)
if mod == "builtins":
mod = None
name = getattr(obj, "__qualname__", None)
if name is None:
return repr(obj)
if mod:
return f"{mod}.{name}"
return name


@dataclass(frozen=True)
class KnownValueWithTypeVars(KnownValue):
"""Subclass of KnownValue that records a TypeVar substitution."""
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ codemod
myst-parser==3.0.1
Sphinx==7.3.7
black==24.4.2
ruff==0.4.5
ruff==0.4.9

0 comments on commit d637578

Please sign in to comment.