diff --git a/docs/changelog.md b/docs/changelog.md index 9a87200e..0a8645b4 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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) diff --git a/pyanalyze/implementation.py b/pyanalyze/implementation.py index a77fa35f..3676aa4e 100644 --- a/pyanalyze/implementation.py +++ b/pyanalyze/implementation.py @@ -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, @@ -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) @@ -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))], diff --git a/pyanalyze/name_check_visitor.py b/pyanalyze/name_check_visitor.py index 855b14ae..0a9e02d2 100644 --- a/pyanalyze/name_check_visitor.py +++ b/pyanalyze/name_check_visitor.py @@ -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 @@ -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 @@ -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( @@ -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. @@ -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) diff --git a/pyanalyze/runtime.py b/pyanalyze/runtime.py index 3ef2097d..22e5d0c8 100644 --- a/pyanalyze/runtime.py +++ b/pyanalyze/runtime.py @@ -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 @@ -19,19 +21,23 @@ 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()) @@ -39,18 +45,18 @@ def is_compatible(value: object, typ: object) -> bool: @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 @@ -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) diff --git a/pyanalyze/test_name_check_visitor.py b/pyanalyze/test_name_check_visitor.py index d94bccbe..538ee04a 100644 --- a/pyanalyze/test_name_check_visitor.py +++ b/pyanalyze/test_name_check_visitor.py @@ -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() diff --git a/pyanalyze/test_runtime.py b/pyanalyze/test_runtime.py index 5b0b0c11..8a1f489c 100644 --- a/pyanalyze/test_runtime.py +++ b/pyanalyze/test_runtime.py @@ -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): @@ -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)] @@ -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) diff --git a/pyanalyze/test_stacked_scopes.py b/pyanalyze/test_stacked_scopes.py index 57da5099..f69cf1b4 100644 --- a/pyanalyze/test_stacked_scopes.py +++ b/pyanalyze/test_stacked_scopes.py @@ -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 @@ -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() diff --git a/pyanalyze/test_value.py b/pyanalyze/test_value.py index 99856783..ddfd4ee0 100644 --- a/pyanalyze/test_value.py +++ b/pyanalyze/test_value.py @@ -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) diff --git a/pyanalyze/value.py b/pyanalyze/value.py index f0908fa3..46e457f5 100644 --- a/pyanalyze/value.py +++ b/pyanalyze/value.py @@ -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, @@ -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}]" @@ -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.""" diff --git a/requirements.txt b/requirements.txt index d199f6d4..bae03032 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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