From 9e03f126ff61ff01ee9780d017e91b956c616be0 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 4 Jun 2024 18:47:06 -0700 Subject: [PATCH 1/9] Direct copy of stdlib get_annotations --- src/_inspect_stock_annotations.py | 28 +++++ src/_inspect_stringized_annotations.py | 34 ++++++ src/_inspect_stringized_annotations_2.py | 3 + src/test_typing_extensions.py | 103 +++++++++++++++++ src/typing_extensions.py | 137 +++++++++++++++++++++++ 5 files changed, 305 insertions(+) create mode 100644 src/_inspect_stock_annotations.py create mode 100644 src/_inspect_stringized_annotations.py create mode 100644 src/_inspect_stringized_annotations_2.py diff --git a/src/_inspect_stock_annotations.py b/src/_inspect_stock_annotations.py new file mode 100644 index 00000000..d115a25b --- /dev/null +++ b/src/_inspect_stock_annotations.py @@ -0,0 +1,28 @@ +a:int=3 +b:str="foo" + +class MyClass: + a:int=4 + b:str="bar" + def __init__(self, a, b): + self.a = a + self.b = b + def __eq__(self, other): + return isinstance(other, MyClass) and self.a == other.a and self.b == other.b + +def function(a:int, b:str) -> MyClass: + return MyClass(a, b) + + +def function2(a:int, b:"str", c:MyClass) -> MyClass: + pass + + +def function3(a:"int", b:"str", c:"MyClass"): + pass + + +class UnannotatedClass: + pass + +def unannotated_function(a, b, c): pass diff --git a/src/_inspect_stringized_annotations.py b/src/_inspect_stringized_annotations.py new file mode 100644 index 00000000..a56fb050 --- /dev/null +++ b/src/_inspect_stringized_annotations.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +a:int=3 +b:str="foo" + +class MyClass: + a:int=4 + b:str="bar" + def __init__(self, a, b): + self.a = a + self.b = b + def __eq__(self, other): + return isinstance(other, MyClass) and self.a == other.a and self.b == other.b + +def function(a:int, b:str) -> MyClass: + return MyClass(a, b) + + +def function2(a:int, b:"str", c:MyClass) -> MyClass: + pass + + +def function3(a:"int", b:"str", c:"MyClass"): + pass + + +class UnannotatedClass: + pass + +def unannotated_function(a, b, c): pass + +class MyClassWithLocalAnnotations: + mytype = int + x: mytype diff --git a/src/_inspect_stringized_annotations_2.py b/src/_inspect_stringized_annotations_2.py new file mode 100644 index 00000000..87206d5a --- /dev/null +++ b/src/_inspect_stringized_annotations_2.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +def foo(a, b, c): pass diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index bf7600a1..7925646c 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3,6 +3,7 @@ import collections.abc import contextlib import copy +import functools import gc import importlib import inspect @@ -77,6 +78,7 @@ dataclass_transform, deprecated, final, + get_annotations, get_args, get_origin, get_original_bases, @@ -7030,5 +7032,106 @@ def test_capsule_type(self): self.assertIsInstance(_datetime.datetime_CAPI, typing_extensions.CapsuleType) +class GetAnnotationsTests(BaseTestCase): + def test_get_annotations_with_stock_annotations(self): + def foo(a:int, b:str): pass + self.assertEqual(get_annotations(foo), {'a': int, 'b': str}) + + foo.__annotations__ = {'a': 'foo', 'b':'str'} + self.assertEqual(get_annotations(foo), {'a': 'foo', 'b': 'str'}) + + self.assertEqual(get_annotations(foo, eval_str=True, locals=locals()), {'a': foo, 'b': str}) + self.assertEqual(get_annotations(foo, eval_str=True, globals=locals()), {'a': foo, 'b': str}) + + import _inspect_stock_annotations as isa + self.assertEqual(get_annotations(isa), {'a': int, 'b': str}) + self.assertEqual(get_annotations(isa.MyClass), {'a': int, 'b': str}) + self.assertEqual(get_annotations(isa.function), {'a': int, 'b': str, 'return': isa.MyClass}) + self.assertEqual(get_annotations(isa.function2), {'a': int, 'b': 'str', 'c': isa.MyClass, 'return': isa.MyClass}) + self.assertEqual(get_annotations(isa.function3), {'a': 'int', 'b': 'str', 'c': 'MyClass'}) + self.assertEqual(get_annotations(inspect), {}) # inspect module has no annotations + self.assertEqual(get_annotations(isa.UnannotatedClass), {}) + self.assertEqual(get_annotations(isa.unannotated_function), {}) + + self.assertEqual(get_annotations(isa, eval_str=True), {'a': int, 'b': str}) + self.assertEqual(get_annotations(isa.MyClass, eval_str=True), {'a': int, 'b': str}) + self.assertEqual(get_annotations(isa.function, eval_str=True), {'a': int, 'b': str, 'return': isa.MyClass}) + self.assertEqual(get_annotations(isa.function2, eval_str=True), {'a': int, 'b': str, 'c': isa.MyClass, 'return': isa.MyClass}) + self.assertEqual(get_annotations(isa.function3, eval_str=True), {'a': int, 'b': str, 'c': isa.MyClass}) + self.assertEqual(get_annotations(inspect, eval_str=True), {}) + self.assertEqual(get_annotations(isa.UnannotatedClass, eval_str=True), {}) + self.assertEqual(get_annotations(isa.unannotated_function, eval_str=True), {}) + + self.assertEqual(get_annotations(isa, eval_str=False), {'a': int, 'b': str}) + self.assertEqual(get_annotations(isa.MyClass, eval_str=False), {'a': int, 'b': str}) + self.assertEqual(get_annotations(isa.function, eval_str=False), {'a': int, 'b': str, 'return': isa.MyClass}) + self.assertEqual(get_annotations(isa.function2, eval_str=False), {'a': int, 'b': 'str', 'c': isa.MyClass, 'return': isa.MyClass}) + self.assertEqual(get_annotations(isa.function3, eval_str=False), {'a': 'int', 'b': 'str', 'c': 'MyClass'}) + self.assertEqual(get_annotations(inspect, eval_str=False), {}) + self.assertEqual(get_annotations(isa.UnannotatedClass, eval_str=False), {}) + self.assertEqual(get_annotations(isa.unannotated_function, eval_str=False), {}) + + def times_three(fn): + @functools.wraps(fn) + def wrapper(a, b): + return fn(a*3, b*3) + return wrapper + + wrapped = times_three(isa.function) + self.assertEqual(wrapped(1, 'x'), isa.MyClass(3, 'xxx')) + self.assertIsNot(wrapped.__globals__, isa.function.__globals__) + self.assertEqual(get_annotations(wrapped), {'a': int, 'b': str, 'return': isa.MyClass}) + self.assertEqual(get_annotations(wrapped, eval_str=True), {'a': int, 'b': str, 'return': isa.MyClass}) + self.assertEqual(get_annotations(wrapped, eval_str=False), {'a': int, 'b': str, 'return': isa.MyClass}) + + def test_get_annotations_with_stringized_annotations(self): + import _inspect_stringized_annotations as isa + self.assertEqual(get_annotations(isa), {'a': 'int', 'b': 'str'}) + self.assertEqual(get_annotations(isa.MyClass), {'a': 'int', 'b': 'str'}) + self.assertEqual(get_annotations(isa.function), {'a': 'int', 'b': 'str', 'return': 'MyClass'}) + self.assertEqual(get_annotations(isa.function2), {'a': 'int', 'b': "'str'", 'c': 'MyClass', 'return': 'MyClass'}) + self.assertEqual(get_annotations(isa.function3), {'a': "'int'", 'b': "'str'", 'c': "'MyClass'"}) + self.assertEqual(get_annotations(isa.UnannotatedClass), {}) + self.assertEqual(get_annotations(isa.unannotated_function), {}) + + self.assertEqual(get_annotations(isa, eval_str=True), {'a': int, 'b': str}) + self.assertEqual(get_annotations(isa.MyClass, eval_str=True), {'a': int, 'b': str}) + self.assertEqual(get_annotations(isa.function, eval_str=True), {'a': int, 'b': str, 'return': isa.MyClass}) + self.assertEqual(get_annotations(isa.function2, eval_str=True), {'a': int, 'b': 'str', 'c': isa.MyClass, 'return': isa.MyClass}) + self.assertEqual(get_annotations(isa.function3, eval_str=True), {'a': 'int', 'b': 'str', 'c': 'MyClass'}) + self.assertEqual(get_annotations(isa.UnannotatedClass, eval_str=True), {}) + self.assertEqual(get_annotations(isa.unannotated_function, eval_str=True), {}) + + self.assertEqual(get_annotations(isa, eval_str=False), {'a': 'int', 'b': 'str'}) + self.assertEqual(get_annotations(isa.MyClass, eval_str=False), {'a': 'int', 'b': 'str'}) + self.assertEqual(get_annotations(isa.function, eval_str=False), {'a': 'int', 'b': 'str', 'return': 'MyClass'}) + self.assertEqual(get_annotations(isa.function2, eval_str=False), {'a': 'int', 'b': "'str'", 'c': 'MyClass', 'return': 'MyClass'}) + self.assertEqual(get_annotations(isa.function3, eval_str=False), {'a': "'int'", 'b': "'str'", 'c': "'MyClass'"}) + self.assertEqual(get_annotations(isa.UnannotatedClass, eval_str=False), {}) + self.assertEqual(get_annotations(isa.unannotated_function, eval_str=False), {}) + + import _inspect_stringized_annotations_2 as isa2 + self.assertEqual(get_annotations(isa2), {}) + self.assertEqual(get_annotations(isa2, eval_str=True), {}) + self.assertEqual(get_annotations(isa2, eval_str=False), {}) + + def times_three(fn): + @functools.wraps(fn) + def wrapper(a, b): + return fn(a*3, b*3) + return wrapper + + wrapped = times_three(isa.function) + self.assertEqual(wrapped(1, 'x'), isa.MyClass(3, 'xxx')) + self.assertIsNot(wrapped.__globals__, isa.function.__globals__) + self.assertEqual(get_annotations(wrapped), {'a': 'int', 'b': 'str', 'return': 'MyClass'}) + self.assertEqual(get_annotations(wrapped, eval_str=True), {'a': int, 'b': str, 'return': isa.MyClass}) + self.assertEqual(get_annotations(wrapped, eval_str=False), {'a': 'int', 'b': 'str', 'return': 'MyClass'}) + + # test that local namespace lookups work + self.assertEqual(get_annotations(isa.MyClassWithLocalAnnotations), {'x': 'mytype'}) + self.assertEqual(get_annotations(isa.MyClassWithLocalAnnotations, eval_str=True), {'x': int}) + + if __name__ == '__main__': main() diff --git a/src/typing_extensions.py b/src/typing_extensions.py index dec429ca..9a862782 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2,6 +2,7 @@ import collections import collections.abc import contextlib +import enum import functools import inspect import operator @@ -64,6 +65,8 @@ 'Doc', 'get_overloads', 'final', + 'Format', + 'get_annotations', 'get_args', 'get_origin', 'get_original_bases', @@ -3599,6 +3602,140 @@ def __eq__(self, other: object) -> bool: __all__.append("CapsuleType") +# Using this convoluted approach so that this keeps working +# whether we end up using PEP 649 as written, PEP 749, or +# some other variation: in any case, inspect.get_annotations +# will continue to exist and will gain a `format` parameter. +_PEP_649_OR_749_IMPLEMENTED = ( + hasattr(inspect, 'get_annotations') + and inspect.get_annotations.__kwdefaults__ is not None + and "format" in inspect.get_annotations.__kwdefaults__ +) + + +class Format(int, enum.Enum): + VALUE = 1 + FORWARDREF = 2 + SOURCE = 3 + + +if _PEP_649_OR_749_IMPLEMENTED: + get_annotations = inspect.get_annotations +else: + def get_annotations(obj, *, globals=None, locals=None, eval_str=False): + """Compute the annotations dict for an object. + + obj may be a callable, class, or module. + Passing in an object of any other type raises TypeError. + + Returns a dict. get_annotations() returns a new dict every time + it's called; calling it twice on the same object will return two + different but equivalent dicts. + + This function handles several details for you: + + * If eval_str is true, values of type str will + be un-stringized using eval(). This is intended + for use with stringized annotations + ("from __future__ import annotations"). + * If obj doesn't have an annotations dict, returns an + empty dict. (Functions and methods always have an + annotations dict; classes, modules, and other types of + callables may not.) + * Ignores inherited annotations on classes. If a class + doesn't have its own annotations dict, returns an empty dict. + * All accesses to object members and dict values are done + using getattr() and dict.get() for safety. + * Always, always, always returns a freshly-created dict. + + eval_str controls whether or not values of type str are replaced + with the result of calling eval() on those values: + + * If eval_str is true, eval() is called on values of type str. + * If eval_str is false (the default), values of type str are unchanged. + + globals and locals are passed in to eval(); see the documentation + for eval() for more information. If either globals or locals is + None, this function may replace that value with a context-specific + default, contingent on type(obj): + + * If obj is a module, globals defaults to obj.__dict__. + * If obj is a class, globals defaults to + sys.modules[obj.__module__].__dict__ and locals + defaults to the obj class namespace. + * If obj is a callable, globals defaults to obj.__globals__, + although if obj is a wrapped function (using + functools.update_wrapper()) it is first unwrapped. + """ + if isinstance(obj, type): + # class + obj_dict = getattr(obj, '__dict__', None) + if obj_dict and hasattr(obj_dict, 'get'): + ann = obj_dict.get('__annotations__', None) + if isinstance(ann, _types.GetSetDescriptorType): + ann = None + else: + ann = None + + obj_globals = None + module_name = getattr(obj, '__module__', None) + if module_name: + module = sys.modules.get(module_name, None) + if module: + obj_globals = getattr(module, '__dict__', None) + obj_locals = dict(vars(obj)) + unwrap = obj + elif isinstance(obj, _types.ModuleType): + # module + ann = getattr(obj, '__annotations__', None) + obj_globals = getattr(obj, '__dict__') + obj_locals = None + unwrap = None + elif callable(obj): + # this includes types.Function, types.BuiltinFunctionType, + # types.BuiltinMethodType, functools.partial, functools.singledispatch, + # "class funclike" from Lib/test/test_inspect... on and on it goes. + ann = getattr(obj, '__annotations__', None) + obj_globals = getattr(obj, '__globals__', None) + obj_locals = None + unwrap = obj + else: + raise TypeError(f"{obj!r} is not a module, class, or callable.") + + if ann is None: + return {} + + if not isinstance(ann, dict): + raise ValueError(f"{obj!r}.__annotations__ is neither a dict nor None") + + if not ann: + return {} + + if not eval_str: + return dict(ann) + + if unwrap is not None: + while True: + if hasattr(unwrap, '__wrapped__'): + unwrap = unwrap.__wrapped__ + continue + if isinstance(unwrap, functools.partial): + unwrap = unwrap.func + continue + break + if hasattr(unwrap, "__globals__"): + obj_globals = unwrap.__globals__ + + if globals is None: + globals = obj_globals + if locals is None: + locals = obj_locals + + return_value = {key: + value if not isinstance(value, str) else eval(value, globals, locals) + for key, value in ann.items() } + return return_value + # Aliases for items that have always been in typing. # Explicitly assign these (rather than using `from typing import *` at the top), # so that we get a CI error if one of these is deleted from typing.py From a2a467b6ee638b0da8129571ee58cd5fa83ac4fc Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 4 Jun 2024 19:17:01 -0700 Subject: [PATCH 2/9] Backport current version of annotations.get_annotations() --- src/test_typing_extensions.py | 419 +++++++++++++++++++++++++++------- src/typing_extensions.py | 76 +++--- 2 files changed, 380 insertions(+), 115 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 7925646c..eb0d4984 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -39,6 +39,7 @@ Dict, Doc, Final, + Format, Generic, IntVar, Iterable, @@ -93,6 +94,7 @@ reveal_type, runtime, runtime_checkable, + _PEP_649_OR_749_IMPLEMENTED, ) NoneType = type(None) @@ -7032,105 +7034,362 @@ def test_capsule_type(self): self.assertIsInstance(_datetime.datetime_CAPI, typing_extensions.CapsuleType) -class GetAnnotationsTests(BaseTestCase): - def test_get_annotations_with_stock_annotations(self): - def foo(a:int, b:str): pass - self.assertEqual(get_annotations(foo), {'a': int, 'b': str}) +def times_three(fn): + @functools.wraps(fn) + def wrapper(a, b): + return fn(a * 3, b * 3) - foo.__annotations__ = {'a': 'foo', 'b':'str'} - self.assertEqual(get_annotations(foo), {'a': 'foo', 'b': 'str'}) + return wrapper + + +class TestGetAnnotations(BaseTestCase): + def test_builtin_type(self): + self.assertEqual(get_annotations(int), {}) + self.assertEqual(get_annotations(object), {}) + + def test_format(self): + def f1(a: int): + pass + + def f2(a: "undefined"): + pass + + self.assertEqual( + get_annotations(f1, format=Format.VALUE), {"a": int} + ) + self.assertEqual(get_annotations(f1, format=1), {"a": int}) + + self.assertEqual( + get_annotations(f2, format=Format.FORWARDREF), + {"a": "undefined"}, + ) + self.assertEqual(get_annotations(f2, format=2), {"a": "undefined"}) + + self.assertEqual( + get_annotations(f1, format=Format.SOURCE), + {"a": "int"}, + ) + self.assertEqual(get_annotations(f1, format=3), {"a": "int"}) + + with self.assertRaises(ValueError): + get_annotations(f1, format=0) + + with self.assertRaises(ValueError): + get_annotations(f1, format=4) - self.assertEqual(get_annotations(foo, eval_str=True, locals=locals()), {'a': foo, 'b': str}) - self.assertEqual(get_annotations(foo, eval_str=True, globals=locals()), {'a': foo, 'b': str}) + def test_custom_object_with_annotations(self): + class C: + def __init__(self, x: int = 0, y: str = ""): + self.__annotations__ = {"x": int, "y": str} + + self.assertEqual(get_annotations(C()), {"x": int, "y": str}) + + def test_custom_format_eval_str(self): + def foo(): + pass + + with self.assertRaises(ValueError): + get_annotations( + foo, format=Format.FORWARDREF, eval_str=True + ) + get_annotations( + foo, format=Format.SOURCE, eval_str=True + ) + + def test_stock_annotations(self): + def foo(a: int, b: str): + pass + + for format in (Format.VALUE, Format.FORWARDREF): + with self.subTest(format=format): + self.assertEqual( + get_annotations(foo, format=format), + {"a": int, "b": str}, + ) + self.assertEqual( + get_annotations(foo, format=Format.SOURCE), + {"a": "int", "b": "str"}, + ) + + foo.__annotations__ = {"a": "foo", "b": "str"} + for format in Format: + with self.subTest(format=format): + self.assertEqual( + get_annotations(foo, format=format), + {"a": "foo", "b": "str"}, + ) + self.assertEqual( + get_annotations(foo, eval_str=True, locals=locals()), + {"a": foo, "b": str}, + ) + self.assertEqual( + get_annotations(foo, eval_str=True, globals=locals()), + {"a": foo, "b": str}, + ) + + def test_stock_annotations_in_module(self): + import _inspect_stock_annotations as isa + + for kwargs in [ + {}, + {"eval_str": False}, + {"format": Format.VALUE}, + {"format": Format.FORWARDREF}, + {"format": Format.VALUE, "eval_str": False}, + {"format": Format.FORWARDREF, "eval_str": False}, + ]: + with self.subTest(**kwargs): + self.assertEqual( + get_annotations(isa, **kwargs), {"a": int, "b": str} + ) + self.assertEqual( + get_annotations(isa.MyClass, **kwargs), + {"a": int, "b": str}, + ) + self.assertEqual( + get_annotations(isa.function, **kwargs), + {"a": int, "b": str, "return": isa.MyClass}, + ) + self.assertEqual( + get_annotations(isa.function2, **kwargs), + {"a": int, "b": "str", "c": isa.MyClass, "return": isa.MyClass}, + ) + self.assertEqual( + get_annotations(isa.function3, **kwargs), + {"a": "int", "b": "str", "c": "MyClass"}, + ) + self.assertEqual( + get_annotations(inspect, **kwargs), {} + ) # inspect module has no annotations + self.assertEqual( + get_annotations(isa.UnannotatedClass, **kwargs), {} + ) + self.assertEqual( + get_annotations(isa.unannotated_function, **kwargs), {} + ) + + for kwargs in [ + {"eval_str": True}, + {"format": Format.VALUE, "eval_str": True}, + ]: + with self.subTest(**kwargs): + self.assertEqual( + get_annotations(isa, **kwargs), {"a": int, "b": str} + ) + self.assertEqual( + get_annotations(isa.MyClass, **kwargs), + {"a": int, "b": str}, + ) + self.assertEqual( + get_annotations(isa.function, **kwargs), + {"a": int, "b": str, "return": isa.MyClass}, + ) + self.assertEqual( + get_annotations(isa.function2, **kwargs), + {"a": int, "b": str, "c": isa.MyClass, "return": isa.MyClass}, + ) + self.assertEqual( + get_annotations(isa.function3, **kwargs), + {"a": int, "b": str, "c": isa.MyClass}, + ) + self.assertEqual(get_annotations(inspect, **kwargs), {}) + self.assertEqual( + get_annotations(isa.UnannotatedClass, **kwargs), {} + ) + self.assertEqual( + get_annotations(isa.unannotated_function, **kwargs), {} + ) + + self.assertEqual( + get_annotations(isa, format=Format.SOURCE), + {"a": "int", "b": "str"}, + ) + self.assertEqual( + get_annotations(isa.MyClass, format=Format.SOURCE), + {"a": "int", "b": "str"}, + ) + mycls = "MyClass" if _PEP_649_OR_749_IMPLEMENTED else "_inspect_stock_annotations.MyClass" + self.assertEqual( + get_annotations(isa.function, format=Format.SOURCE), + {"a": "int", "b": "str", "return": mycls}, + ) + self.assertEqual( + get_annotations( + isa.function2, format=Format.SOURCE + ), + {"a": "int", "b": "str", "c": mycls, "return": mycls}, + ) + self.assertEqual( + get_annotations( + isa.function3, format=Format.SOURCE + ), + {"a": "int", "b": "str", "c": "MyClass"}, + ) + self.assertEqual( + get_annotations(inspect, format=Format.SOURCE), + {}, + ) + self.assertEqual( + get_annotations( + isa.UnannotatedClass, format=Format.SOURCE + ), + {}, + ) + self.assertEqual( + get_annotations( + isa.unannotated_function, format=Format.SOURCE + ), + {}, + ) + + def test_stock_annotations_on_wrapper(self): import _inspect_stock_annotations as isa - self.assertEqual(get_annotations(isa), {'a': int, 'b': str}) - self.assertEqual(get_annotations(isa.MyClass), {'a': int, 'b': str}) - self.assertEqual(get_annotations(isa.function), {'a': int, 'b': str, 'return': isa.MyClass}) - self.assertEqual(get_annotations(isa.function2), {'a': int, 'b': 'str', 'c': isa.MyClass, 'return': isa.MyClass}) - self.assertEqual(get_annotations(isa.function3), {'a': 'int', 'b': 'str', 'c': 'MyClass'}) - self.assertEqual(get_annotations(inspect), {}) # inspect module has no annotations - self.assertEqual(get_annotations(isa.UnannotatedClass), {}) - self.assertEqual(get_annotations(isa.unannotated_function), {}) - - self.assertEqual(get_annotations(isa, eval_str=True), {'a': int, 'b': str}) - self.assertEqual(get_annotations(isa.MyClass, eval_str=True), {'a': int, 'b': str}) - self.assertEqual(get_annotations(isa.function, eval_str=True), {'a': int, 'b': str, 'return': isa.MyClass}) - self.assertEqual(get_annotations(isa.function2, eval_str=True), {'a': int, 'b': str, 'c': isa.MyClass, 'return': isa.MyClass}) - self.assertEqual(get_annotations(isa.function3, eval_str=True), {'a': int, 'b': str, 'c': isa.MyClass}) - self.assertEqual(get_annotations(inspect, eval_str=True), {}) - self.assertEqual(get_annotations(isa.UnannotatedClass, eval_str=True), {}) - self.assertEqual(get_annotations(isa.unannotated_function, eval_str=True), {}) - - self.assertEqual(get_annotations(isa, eval_str=False), {'a': int, 'b': str}) - self.assertEqual(get_annotations(isa.MyClass, eval_str=False), {'a': int, 'b': str}) - self.assertEqual(get_annotations(isa.function, eval_str=False), {'a': int, 'b': str, 'return': isa.MyClass}) - self.assertEqual(get_annotations(isa.function2, eval_str=False), {'a': int, 'b': 'str', 'c': isa.MyClass, 'return': isa.MyClass}) - self.assertEqual(get_annotations(isa.function3, eval_str=False), {'a': 'int', 'b': 'str', 'c': 'MyClass'}) - self.assertEqual(get_annotations(inspect, eval_str=False), {}) - self.assertEqual(get_annotations(isa.UnannotatedClass, eval_str=False), {}) - self.assertEqual(get_annotations(isa.unannotated_function, eval_str=False), {}) - - def times_three(fn): - @functools.wraps(fn) - def wrapper(a, b): - return fn(a*3, b*3) - return wrapper wrapped = times_three(isa.function) - self.assertEqual(wrapped(1, 'x'), isa.MyClass(3, 'xxx')) + self.assertEqual(wrapped(1, "x"), isa.MyClass(3, "xxx")) self.assertIsNot(wrapped.__globals__, isa.function.__globals__) - self.assertEqual(get_annotations(wrapped), {'a': int, 'b': str, 'return': isa.MyClass}) - self.assertEqual(get_annotations(wrapped, eval_str=True), {'a': int, 'b': str, 'return': isa.MyClass}) - self.assertEqual(get_annotations(wrapped, eval_str=False), {'a': int, 'b': str, 'return': isa.MyClass}) + self.assertEqual( + get_annotations(wrapped), + {"a": int, "b": str, "return": isa.MyClass}, + ) + self.assertEqual( + get_annotations(wrapped, format=Format.FORWARDREF), + {"a": int, "b": str, "return": isa.MyClass}, + ) + mycls = "MyClass" if _PEP_649_OR_749_IMPLEMENTED else "_inspect_stock_annotations.MyClass" + self.assertEqual( + get_annotations(wrapped, format=Format.SOURCE), + {"a": "int", "b": "str", "return": mycls}, + ) + self.assertEqual( + get_annotations(wrapped, eval_str=True), + {"a": int, "b": str, "return": isa.MyClass}, + ) + self.assertEqual( + get_annotations(wrapped, eval_str=False), + {"a": int, "b": str, "return": isa.MyClass}, + ) - def test_get_annotations_with_stringized_annotations(self): + def test_stringized_annotations_in_module(self): import _inspect_stringized_annotations as isa - self.assertEqual(get_annotations(isa), {'a': 'int', 'b': 'str'}) - self.assertEqual(get_annotations(isa.MyClass), {'a': 'int', 'b': 'str'}) - self.assertEqual(get_annotations(isa.function), {'a': 'int', 'b': 'str', 'return': 'MyClass'}) - self.assertEqual(get_annotations(isa.function2), {'a': 'int', 'b': "'str'", 'c': 'MyClass', 'return': 'MyClass'}) - self.assertEqual(get_annotations(isa.function3), {'a': "'int'", 'b': "'str'", 'c': "'MyClass'"}) - self.assertEqual(get_annotations(isa.UnannotatedClass), {}) - self.assertEqual(get_annotations(isa.unannotated_function), {}) - - self.assertEqual(get_annotations(isa, eval_str=True), {'a': int, 'b': str}) - self.assertEqual(get_annotations(isa.MyClass, eval_str=True), {'a': int, 'b': str}) - self.assertEqual(get_annotations(isa.function, eval_str=True), {'a': int, 'b': str, 'return': isa.MyClass}) - self.assertEqual(get_annotations(isa.function2, eval_str=True), {'a': int, 'b': 'str', 'c': isa.MyClass, 'return': isa.MyClass}) - self.assertEqual(get_annotations(isa.function3, eval_str=True), {'a': 'int', 'b': 'str', 'c': 'MyClass'}) - self.assertEqual(get_annotations(isa.UnannotatedClass, eval_str=True), {}) - self.assertEqual(get_annotations(isa.unannotated_function, eval_str=True), {}) - - self.assertEqual(get_annotations(isa, eval_str=False), {'a': 'int', 'b': 'str'}) - self.assertEqual(get_annotations(isa.MyClass, eval_str=False), {'a': 'int', 'b': 'str'}) - self.assertEqual(get_annotations(isa.function, eval_str=False), {'a': 'int', 'b': 'str', 'return': 'MyClass'}) - self.assertEqual(get_annotations(isa.function2, eval_str=False), {'a': 'int', 'b': "'str'", 'c': 'MyClass', 'return': 'MyClass'}) - self.assertEqual(get_annotations(isa.function3, eval_str=False), {'a': "'int'", 'b': "'str'", 'c': "'MyClass'"}) - self.assertEqual(get_annotations(isa.UnannotatedClass, eval_str=False), {}) - self.assertEqual(get_annotations(isa.unannotated_function, eval_str=False), {}) + for kwargs in [ + {}, + {"eval_str": False}, + {"format": Format.VALUE}, + {"format": Format.FORWARDREF}, + {"format": Format.SOURCE}, + {"format": Format.VALUE, "eval_str": False}, + {"format": Format.FORWARDREF, "eval_str": False}, + {"format": Format.SOURCE, "eval_str": False}, + ]: + with self.subTest(**kwargs): + self.assertEqual( + get_annotations(isa, **kwargs), {"a": "int", "b": "str"} + ) + self.assertEqual( + get_annotations(isa.MyClass, **kwargs), + {"a": "int", "b": "str"}, + ) + self.assertEqual( + get_annotations(isa.function, **kwargs), + {"a": "int", "b": "str", "return": "MyClass"}, + ) + self.assertEqual( + get_annotations(isa.function2, **kwargs), + {"a": "int", "b": "'str'", "c": "MyClass", "return": "MyClass"}, + ) + self.assertEqual( + get_annotations(isa.function3, **kwargs), + {"a": "'int'", "b": "'str'", "c": "'MyClass'"}, + ) + self.assertEqual( + get_annotations(isa.UnannotatedClass, **kwargs), {} + ) + self.assertEqual( + get_annotations(isa.unannotated_function, **kwargs), {} + ) + for kwargs in [ + {"eval_str": True}, + {"format": Format.VALUE, "eval_str": True}, + ]: + with self.subTest(**kwargs): + self.assertEqual( + get_annotations(isa, **kwargs), {"a": int, "b": str} + ) + self.assertEqual( + get_annotations(isa.MyClass, **kwargs), + {"a": int, "b": str}, + ) + self.assertEqual( + get_annotations(isa.function, **kwargs), + {"a": int, "b": str, "return": isa.MyClass}, + ) + self.assertEqual( + get_annotations(isa.function2, **kwargs), + {"a": int, "b": "str", "c": isa.MyClass, "return": isa.MyClass}, + ) + self.assertEqual( + get_annotations(isa.function3, **kwargs), + {"a": "int", "b": "str", "c": "MyClass"}, + ) + self.assertEqual( + get_annotations(isa.UnannotatedClass, **kwargs), {} + ) + self.assertEqual( + get_annotations(isa.unannotated_function, **kwargs), {} + ) + + def test_stringized_annotations_in_empty_module(self): import _inspect_stringized_annotations_2 as isa2 self.assertEqual(get_annotations(isa2), {}) self.assertEqual(get_annotations(isa2, eval_str=True), {}) self.assertEqual(get_annotations(isa2, eval_str=False), {}) - def times_three(fn): - @functools.wraps(fn) - def wrapper(a, b): - return fn(a*3, b*3) - return wrapper - + def test_stringized_annotations_on_wrapper(self): + import _inspect_stringized_annotations as isa wrapped = times_three(isa.function) - self.assertEqual(wrapped(1, 'x'), isa.MyClass(3, 'xxx')) + self.assertEqual(wrapped(1, "x"), isa.MyClass(3, "xxx")) self.assertIsNot(wrapped.__globals__, isa.function.__globals__) - self.assertEqual(get_annotations(wrapped), {'a': 'int', 'b': 'str', 'return': 'MyClass'}) - self.assertEqual(get_annotations(wrapped, eval_str=True), {'a': int, 'b': str, 'return': isa.MyClass}) - self.assertEqual(get_annotations(wrapped, eval_str=False), {'a': 'int', 'b': 'str', 'return': 'MyClass'}) + self.assertEqual( + get_annotations(wrapped), + {"a": "int", "b": "str", "return": "MyClass"}, + ) + self.assertEqual( + get_annotations(wrapped, eval_str=True), + {"a": int, "b": str, "return": isa.MyClass}, + ) + self.assertEqual( + get_annotations(wrapped, eval_str=False), + {"a": "int", "b": "str", "return": "MyClass"}, + ) + def test_stringized_annotations_on_class(self): + import _inspect_stringized_annotations as isa # test that local namespace lookups work - self.assertEqual(get_annotations(isa.MyClassWithLocalAnnotations), {'x': 'mytype'}) - self.assertEqual(get_annotations(isa.MyClassWithLocalAnnotations, eval_str=True), {'x': int}) + self.assertEqual( + get_annotations(isa.MyClassWithLocalAnnotations), + {"x": "mytype"}, + ) + self.assertEqual( + get_annotations(isa.MyClassWithLocalAnnotations, eval_str=True), + {"x": int}, + ) + + def test_modify_annotations(self): + def f(x: int): + pass + + self.assertEqual(get_annotations(f), {"x": int}) + self.assertEqual( + get_annotations(f, format=Format.FORWARDREF), + {"x": int}, + ) + + f.__annotations__["x"] = str + self.assertEqual(get_annotations(f), {"x": str}) + if __name__ == '__main__': diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 9a862782..e88cad0f 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -3622,7 +3622,8 @@ class Format(int, enum.Enum): if _PEP_649_OR_749_IMPLEMENTED: get_annotations = inspect.get_annotations else: - def get_annotations(obj, *, globals=None, locals=None, eval_str=False): + def get_annotations(obj, *, globals=None, locals=None, eval_str=False, + format=Format.VALUE): """Compute the annotations dict for an object. obj may be a callable, class, or module. @@ -3632,41 +3633,38 @@ def get_annotations(obj, *, globals=None, locals=None, eval_str=False): it's called; calling it twice on the same object will return two different but equivalent dicts. - This function handles several details for you: - - * If eval_str is true, values of type str will - be un-stringized using eval(). This is intended - for use with stringized annotations - ("from __future__ import annotations"). - * If obj doesn't have an annotations dict, returns an - empty dict. (Functions and methods always have an - annotations dict; classes, modules, and other types of - callables may not.) - * Ignores inherited annotations on classes. If a class - doesn't have its own annotations dict, returns an empty dict. - * All accesses to object members and dict values are done - using getattr() and dict.get() for safety. - * Always, always, always returns a freshly-created dict. - - eval_str controls whether or not values of type str are replaced - with the result of calling eval() on those values: - - * If eval_str is true, eval() is called on values of type str. - * If eval_str is false (the default), values of type str are unchanged. - - globals and locals are passed in to eval(); see the documentation - for eval() for more information. If either globals or locals is - None, this function may replace that value with a context-specific - default, contingent on type(obj): - - * If obj is a module, globals defaults to obj.__dict__. - * If obj is a class, globals defaults to - sys.modules[obj.__module__].__dict__ and locals - defaults to the obj class namespace. - * If obj is a callable, globals defaults to obj.__globals__, - although if obj is a wrapped function (using - functools.update_wrapper()) it is first unwrapped. + This is a backport of `inspect.get_annotations`, which has been + in the standard library since Python 3.10. See the standard library + documentation for more: + + https://docs.python.org/3/library/inspect.html#inspect.get_annotations + + This backport adds the *format* argument introduced by PEP 649. The + three formats supported are: + * VALUE: the annotations are returned as-is. This is the default and + it is compatible with the behavior on previous Python versions. + * FORWARDREF: return annotations as-is if possible, but replace any + undefined names with ForwardRef objects. The implementation proposed by + PEP 649 relies on language changes that cannot be backported; the + typing-extensions implementation simply returns the same result as VALUE. + * SOURCE: return annotations as strings, in a format close to the original + source. Again, this behavior cannot be replicated directly in a backport. + As an approximation, typing-extensions retrieves the annotations under + VALUE semantics and then stringifies them. + + The purpose of this backport is to allow users who would like to use + FORWARDREF or SOURCE semantics once PEP 649 is implemented, but who also + want to support earlier Python versions, to simply write: + + typing_extensions.get_annotations(obj, format=Format.FORWARDREF) + """ + if format not in (Format.VALUE, Format.FORWARDREF, Format.SOURCE): + raise ValueError(f"format must be one of {Format.VALUE}, {Format.FORWARDREF}, {Format.SOURCE}") + + if eval_str and format != Format.VALUE: + raise ValueError("eval_str=True is only supported with format=Format.VALUE") + if isinstance(obj, type): # class obj_dict = getattr(obj, '__dict__', None) @@ -3699,6 +3697,9 @@ def get_annotations(obj, *, globals=None, locals=None, eval_str=False): obj_globals = getattr(obj, '__globals__', None) obj_locals = None unwrap = obj + elif hasattr(obj, '__annotations__'): + ann = obj.__annotations__ + obj_globals = obj_locals = unwrap = None else: raise TypeError(f"{obj!r} is not a module, class, or callable.") @@ -3712,6 +3713,11 @@ def get_annotations(obj, *, globals=None, locals=None, eval_str=False): return {} if not eval_str: + if format == Format.SOURCE: + return { + key: value if isinstance(value, str) else typing._type_repr(value) + for key, value in ann.items() + } return dict(ann) if unwrap is not None: From ea971b58f3976c8e610b8ec519974265a1ae4f02 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 4 Jun 2024 19:35:29 -0700 Subject: [PATCH 3/9] docs --- CHANGELOG.md | 3 +++ doc/index.rst | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 776a101e..1e4fcf4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Unreleased +- Add `typing_extensions.get_annotations`, a backport of + `inspect.get_annotations` that adds features specified + by PEP 649. Patch by Jelle Zijlstra. - Fix regression in v4.12.0 where specialization of certain generics with an overridden `__eq__` method would raise errors. Patch by Jelle Zijlstra. diff --git a/doc/index.rst b/doc/index.rst index 3f0d2d44..15c9c8d5 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -747,6 +747,25 @@ Functions .. versionadded:: 4.2.0 +.. function:: get_annotations(obj, *, globals=None, locals=None, eval_str=False, format=Format.VALUE) + + See :py:func:`inspect.get_annotations`. In the standard library since Python 3.10. + + ``typing_extensions`` adds the keyword argument ``format``, as specified + by :pep:`649`. The supported formats are listed in the :class:`Format` enum. + The default format, :attr:`Format.VALUE`, behaves the same across all versions. + For the other two formats, ``typing_extensions`` provides a rough approximation + of the :pep:`649` behavior on versions of Python that do not support it. + + The purpose of this backport is to allow users who would like to use + :attr:`Format.FORWARDREF` or :attr:`Format.SOURCE` semantics once + :pep:`649` is implemented, but who also + want to support earlier Python versions, to simply write:: + + typing_extensions.get_annotations(obj, format=Format.FORWARDREF) + + .. versionadded:: 4.13.0 + .. function:: get_args(tp) See :py:func:`typing.get_args`. In ``typing`` since 3.8. @@ -857,6 +876,45 @@ Functions .. versionadded:: 4.1.0 +Enums +~~~~~ + +.. class:: Format + + The formats for evaluating annotations introduced by :pep:`649`. + Members of this enum can be passed as the *format* argument + to :func:`get_annotations`. + + The final place of this enum in the standard library has not yet + been determined (see :pep:`649` and :pep:`749`), but the names + and integer values are stable and will continue to work. + + .. attribute:: VALUE + + Equal to 1. The default value. The function will return the conventional Python values + for the annotations. This format is identical to the return value for + the function under earlier versions of Python. + + .. attribute:: FORWARDREF + + Equal to 2. When :pep:`649` is implemented, this format will attempt to return the + conventional Python values for the annotations. However, if it encounters + an undefined name, it dynamically creates a proxy object (a ForwardRef) + that substitutes for that value in the expression. + + ``typing_extensions`` emulates this value on versions of Python which do + not support :pep:`649` by returning the same value as for ``VALUE`` semantics. + + .. attribute:: SOURCE + + Equal to 3. When :pep:`649` is implemented, this format will produce an annotation + dictionary where the values have been replaced by strings containing + an approximation of the original source code for the annotation expressions. + + ``typing_extensions`` emulates this by evaluating the annotations using + ``VALUE`` semantics and then stringifying the results. + + .. versionadded:: 4.13.0 Annotation metadata ~~~~~~~~~~~~~~~~~~~ From 35f68cf760b9b7c382f5b80818734329ea2a011c Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 4 Jun 2024 19:44:47 -0700 Subject: [PATCH 4/9] lints --- pyproject.toml | 10 ++++++++-- src/_inspect_stringized_annotations_2.py | 1 + src/test_typing_extensions.py | 19 +++++++++++-------- src/typing_extensions.py | 5 +++-- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e15c923a..770fbd1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ select = [ # Ignore various "modernization" rules that tell you off for importing/using # deprecated things from the typing module, etc. -ignore = ["UP006", "UP007", "UP013", "UP014", "UP019", "UP035", "UP038"] +ignore = ["UP006", "UP007", "UP013", "UP014", "UP019", "UP035", "UP037", "UP038"] [tool.ruff.lint.per-file-ignores] "!src/typing_extensions.py" = [ @@ -98,4 +98,10 @@ ignore = ["UP006", "UP007", "UP013", "UP014", "UP019", "UP035", "UP038"] [tool.ruff.lint.isort] extra-standard-library = ["tomllib"] -known-first-party = ["typing_extensions", "_typed_dict_test_helper"] +known-first-party = [ + "typing_extensions", + "_typed_dict_test_helper", + "_inspect_stock_annotations", + "_inspect_stringized_annotations", + "_inspect_stringized_annotations_2", +] diff --git a/src/_inspect_stringized_annotations_2.py b/src/_inspect_stringized_annotations_2.py index 87206d5a..fb6b7d47 100644 --- a/src/_inspect_stringized_annotations_2.py +++ b/src/_inspect_stringized_annotations_2.py @@ -1,3 +1,4 @@ from __future__ import annotations + def foo(a, b, c): pass diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index eb0d4984..e524276f 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -23,9 +23,13 @@ from unittest import TestCase, main, skipIf, skipUnless from unittest.mock import patch +import _inspect_stock_annotations +import _inspect_stringized_annotations +import _inspect_stringized_annotations_2 import typing_extensions from _typed_dict_test_helper import Foo, FooGeneric, VeryAnnotated from typing_extensions import ( + _PEP_649_OR_749_IMPLEMENTED, Annotated, Any, AnyStr, @@ -94,7 +98,6 @@ reveal_type, runtime, runtime_checkable, - _PEP_649_OR_749_IMPLEMENTED, ) NoneType = type(None) @@ -7051,7 +7054,7 @@ def test_format(self): def f1(a: int): pass - def f2(a: "undefined"): + def f2(a: "undefined"): # noqa: F821 pass self.assertEqual( @@ -7129,7 +7132,7 @@ def foo(a: int, b: str): ) def test_stock_annotations_in_module(self): - import _inspect_stock_annotations as isa + isa = _inspect_stock_annotations for kwargs in [ {}, @@ -7244,7 +7247,7 @@ def test_stock_annotations_in_module(self): ) def test_stock_annotations_on_wrapper(self): - import _inspect_stock_annotations as isa + isa = _inspect_stock_annotations wrapped = times_three(isa.function) self.assertEqual(wrapped(1, "x"), isa.MyClass(3, "xxx")) @@ -7272,7 +7275,7 @@ def test_stock_annotations_on_wrapper(self): ) def test_stringized_annotations_in_module(self): - import _inspect_stringized_annotations as isa + isa = _inspect_stringized_annotations for kwargs in [ {}, {"eval_str": False}, @@ -7342,13 +7345,13 @@ def test_stringized_annotations_in_module(self): ) def test_stringized_annotations_in_empty_module(self): - import _inspect_stringized_annotations_2 as isa2 + isa2 = _inspect_stringized_annotations_2 self.assertEqual(get_annotations(isa2), {}) self.assertEqual(get_annotations(isa2, eval_str=True), {}) self.assertEqual(get_annotations(isa2, eval_str=False), {}) def test_stringized_annotations_on_wrapper(self): - import _inspect_stringized_annotations as isa + isa = _inspect_stringized_annotations wrapped = times_three(isa.function) self.assertEqual(wrapped(1, "x"), isa.MyClass(3, "xxx")) self.assertIsNot(wrapped.__globals__, isa.function.__globals__) @@ -7366,7 +7369,7 @@ def test_stringized_annotations_on_wrapper(self): ) def test_stringized_annotations_on_class(self): - import _inspect_stringized_annotations as isa + isa = _inspect_stringized_annotations # test that local namespace lookups work self.assertEqual( get_annotations(isa.MyClassWithLocalAnnotations), diff --git a/src/typing_extensions.py b/src/typing_extensions.py index e88cad0f..364d0bc9 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -3660,7 +3660,8 @@ def get_annotations(obj, *, globals=None, locals=None, eval_str=False, """ if format not in (Format.VALUE, Format.FORWARDREF, Format.SOURCE): - raise ValueError(f"format must be one of {Format.VALUE}, {Format.FORWARDREF}, {Format.SOURCE}") + raise ValueError(f"format must be one of {Format.VALUE}, " + f"{Format.FORWARDREF}, {Format.SOURCE}") if eval_str and format != Format.VALUE: raise ValueError("eval_str=True is only supported with format=Format.VALUE") @@ -3686,7 +3687,7 @@ def get_annotations(obj, *, globals=None, locals=None, eval_str=False, elif isinstance(obj, _types.ModuleType): # module ann = getattr(obj, '__annotations__', None) - obj_globals = getattr(obj, '__dict__') + obj_globals = obj.__dict__ obj_locals = None unwrap = None elif callable(obj): From 2f2c3a63b583aa09726a4f3f8dac143ddde08b35 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 4 Jun 2024 19:47:01 -0700 Subject: [PATCH 5/9] include inspect helpers in sdist --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 770fbd1f..c54c07fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ name = "Guido van Rossum, Jukka Lehtosalo, Łukasz Langa, Michael Lee" email = "levkivskyi@gmail.com" [tool.flit.sdist] -include = ["CHANGELOG.md", "README.md", "tox.ini", "*/*test*.py"] +include = ["CHANGELOG.md", "README.md", "tox.ini", "*/*test*.py", "*/*inspect*.py"] exclude = [] [tool.ruff] From f43e4b62847a463bd74d60b1f1734ffd0d9af8c3 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 5 Jun 2024 06:31:45 -0700 Subject: [PATCH 6/9] Update src/typing_extensions.py Co-authored-by: Alex Waygood --- src/typing_extensions.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 364d0bc9..55341253 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -3659,9 +3659,7 @@ def get_annotations(obj, *, globals=None, locals=None, eval_str=False, typing_extensions.get_annotations(obj, format=Format.FORWARDREF) """ - if format not in (Format.VALUE, Format.FORWARDREF, Format.SOURCE): - raise ValueError(f"format must be one of {Format.VALUE}, " - f"{Format.FORWARDREF}, {Format.SOURCE}") + format = Format(format) if eval_str and format != Format.VALUE: raise ValueError("eval_str=True is only supported with format=Format.VALUE") From 73e22630d676df6a47f414dc3ab0ce2907bf548e Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 5 Jun 2024 06:31:51 -0700 Subject: [PATCH 7/9] Update src/typing_extensions.py --- src/typing_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 55341253..8760e313 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -3613,7 +3613,7 @@ def __eq__(self, other: object) -> bool: ) -class Format(int, enum.Enum): +class Format(enum.IntEnum): VALUE = 1 FORWARDREF = 2 SOURCE = 3 From 9c503a9923062d5b4b964bf4dfe8cd5ef2540546 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 5 Jun 2024 17:29:52 -0700 Subject: [PATCH 8/9] Drop separate test files --- pyproject.toml | 12 +-- src/_inspect_stock_annotations.py | 28 ------ src/_inspect_stringized_annotations.py | 34 ------- src/_inspect_stringized_annotations_2.py | 4 - src/test_typing_extensions.py | 114 ++++++++++++++++++++--- 5 files changed, 106 insertions(+), 86 deletions(-) delete mode 100644 src/_inspect_stock_annotations.py delete mode 100644 src/_inspect_stringized_annotations.py delete mode 100644 src/_inspect_stringized_annotations_2.py diff --git a/pyproject.toml b/pyproject.toml index c54c07fb..e15c923a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ name = "Guido van Rossum, Jukka Lehtosalo, Łukasz Langa, Michael Lee" email = "levkivskyi@gmail.com" [tool.flit.sdist] -include = ["CHANGELOG.md", "README.md", "tox.ini", "*/*test*.py", "*/*inspect*.py"] +include = ["CHANGELOG.md", "README.md", "tox.ini", "*/*test*.py"] exclude = [] [tool.ruff] @@ -83,7 +83,7 @@ select = [ # Ignore various "modernization" rules that tell you off for importing/using # deprecated things from the typing module, etc. -ignore = ["UP006", "UP007", "UP013", "UP014", "UP019", "UP035", "UP037", "UP038"] +ignore = ["UP006", "UP007", "UP013", "UP014", "UP019", "UP035", "UP038"] [tool.ruff.lint.per-file-ignores] "!src/typing_extensions.py" = [ @@ -98,10 +98,4 @@ ignore = ["UP006", "UP007", "UP013", "UP014", "UP019", "UP035", "UP037", "UP038" [tool.ruff.lint.isort] extra-standard-library = ["tomllib"] -known-first-party = [ - "typing_extensions", - "_typed_dict_test_helper", - "_inspect_stock_annotations", - "_inspect_stringized_annotations", - "_inspect_stringized_annotations_2", -] +known-first-party = ["typing_extensions", "_typed_dict_test_helper"] diff --git a/src/_inspect_stock_annotations.py b/src/_inspect_stock_annotations.py deleted file mode 100644 index d115a25b..00000000 --- a/src/_inspect_stock_annotations.py +++ /dev/null @@ -1,28 +0,0 @@ -a:int=3 -b:str="foo" - -class MyClass: - a:int=4 - b:str="bar" - def __init__(self, a, b): - self.a = a - self.b = b - def __eq__(self, other): - return isinstance(other, MyClass) and self.a == other.a and self.b == other.b - -def function(a:int, b:str) -> MyClass: - return MyClass(a, b) - - -def function2(a:int, b:"str", c:MyClass) -> MyClass: - pass - - -def function3(a:"int", b:"str", c:"MyClass"): - pass - - -class UnannotatedClass: - pass - -def unannotated_function(a, b, c): pass diff --git a/src/_inspect_stringized_annotations.py b/src/_inspect_stringized_annotations.py deleted file mode 100644 index a56fb050..00000000 --- a/src/_inspect_stringized_annotations.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -a:int=3 -b:str="foo" - -class MyClass: - a:int=4 - b:str="bar" - def __init__(self, a, b): - self.a = a - self.b = b - def __eq__(self, other): - return isinstance(other, MyClass) and self.a == other.a and self.b == other.b - -def function(a:int, b:str) -> MyClass: - return MyClass(a, b) - - -def function2(a:int, b:"str", c:MyClass) -> MyClass: - pass - - -def function3(a:"int", b:"str", c:"MyClass"): - pass - - -class UnannotatedClass: - pass - -def unannotated_function(a, b, c): pass - -class MyClassWithLocalAnnotations: - mytype = int - x: mytype diff --git a/src/_inspect_stringized_annotations_2.py b/src/_inspect_stringized_annotations_2.py deleted file mode 100644 index fb6b7d47..00000000 --- a/src/_inspect_stringized_annotations_2.py +++ /dev/null @@ -1,4 +0,0 @@ -from __future__ import annotations - - -def foo(a, b, c): pass diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index e524276f..09ed16fc 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -23,9 +23,6 @@ from unittest import TestCase, main, skipIf, skipUnless from unittest.mock import patch -import _inspect_stock_annotations -import _inspect_stringized_annotations -import _inspect_stringized_annotations_2 import typing_extensions from _typed_dict_test_helper import Foo, FooGeneric, VeryAnnotated from typing_extensions import ( @@ -241,6 +238,79 @@ def g_bad_ann(): ''' +STOCK_ANNOTATIONS = """ +a:int=3 +b:str="foo" + +class MyClass: + a:int=4 + b:str="bar" + def __init__(self, a, b): + self.a = a + self.b = b + def __eq__(self, other): + return isinstance(other, MyClass) and self.a == other.a and self.b == other.b + +def function(a:int, b:str) -> MyClass: + return MyClass(a, b) + + +def function2(a:int, b:"str", c:MyClass) -> MyClass: + pass + + +def function3(a:"int", b:"str", c:"MyClass"): + pass + + +class UnannotatedClass: + pass + +def unannotated_function(a, b, c): pass +""" +STRINGIZED_ANNOTATIONS = """ +from __future__ import annotations + +a:int=3 +b:str="foo" + +class MyClass: + a:int=4 + b:str="bar" + def __init__(self, a, b): + self.a = a + self.b = b + def __eq__(self, other): + return isinstance(other, MyClass) and self.a == other.a and self.b == other.b + +def function(a:int, b:str) -> MyClass: + return MyClass(a, b) + + +def function2(a:int, b:"str", c:MyClass) -> MyClass: + pass + + +def function3(a:"int", b:"str", c:"MyClass"): + pass + + +class UnannotatedClass: + pass + +def unannotated_function(a, b, c): pass + +class MyClassWithLocalAnnotations: + mytype = int + x: mytype +""" +STRINGIZED_ANNOTATIONS_2 = """ +from __future__ import annotations + + +def foo(a, b, c): pass +""" + class BaseTestCase(TestCase): def assertIsSubclass(self, cls, class_or_tuple, msg=None): if not issubclass(cls, class_or_tuple): @@ -7046,6 +7116,28 @@ def wrapper(a, b): class TestGetAnnotations(BaseTestCase): + @classmethod + def setUpClass(cls): + with tempfile.TemporaryDirectory() as tempdir: + sys.path.append(tempdir) + Path(tempdir, "inspect_stock_annotations.py").write_text(STOCK_ANNOTATIONS) + Path(tempdir, "inspect_stringized_annotations.py").write_text(STRINGIZED_ANNOTATIONS) + Path(tempdir, "inspect_stringized_annotations_2.py").write_text(STRINGIZED_ANNOTATIONS_2) + cls.inspect_stock_annotations = importlib.import_module("inspect_stock_annotations") + cls.inspect_stringized_annotations = importlib.import_module("inspect_stringized_annotations") + cls.inspect_stringized_annotations_2 = importlib.import_module("inspect_stringized_annotations_2") + sys.path.pop() + + @classmethod + def tearDownClass(cls): + for modname in ( + "inspect_stock_annotations", + "inspect_stringized_annotations", + "inspect_stringized_annotations_2", + ): + delattr(cls, modname) + del sys.modules[modname] + def test_builtin_type(self): self.assertEqual(get_annotations(int), {}) self.assertEqual(get_annotations(object), {}) @@ -7132,7 +7224,7 @@ def foo(a: int, b: str): ) def test_stock_annotations_in_module(self): - isa = _inspect_stock_annotations + isa = self.inspect_stock_annotations for kwargs in [ {}, @@ -7212,7 +7304,7 @@ def test_stock_annotations_in_module(self): get_annotations(isa.MyClass, format=Format.SOURCE), {"a": "int", "b": "str"}, ) - mycls = "MyClass" if _PEP_649_OR_749_IMPLEMENTED else "_inspect_stock_annotations.MyClass" + mycls = "MyClass" if _PEP_649_OR_749_IMPLEMENTED else "inspect_stock_annotations.MyClass" self.assertEqual( get_annotations(isa.function, format=Format.SOURCE), {"a": "int", "b": "str", "return": mycls}, @@ -7247,7 +7339,7 @@ def test_stock_annotations_in_module(self): ) def test_stock_annotations_on_wrapper(self): - isa = _inspect_stock_annotations + isa = self.inspect_stock_annotations wrapped = times_three(isa.function) self.assertEqual(wrapped(1, "x"), isa.MyClass(3, "xxx")) @@ -7260,7 +7352,7 @@ def test_stock_annotations_on_wrapper(self): get_annotations(wrapped, format=Format.FORWARDREF), {"a": int, "b": str, "return": isa.MyClass}, ) - mycls = "MyClass" if _PEP_649_OR_749_IMPLEMENTED else "_inspect_stock_annotations.MyClass" + mycls = "MyClass" if _PEP_649_OR_749_IMPLEMENTED else "inspect_stock_annotations.MyClass" self.assertEqual( get_annotations(wrapped, format=Format.SOURCE), {"a": "int", "b": "str", "return": mycls}, @@ -7275,7 +7367,7 @@ def test_stock_annotations_on_wrapper(self): ) def test_stringized_annotations_in_module(self): - isa = _inspect_stringized_annotations + isa = self.inspect_stringized_annotations for kwargs in [ {}, {"eval_str": False}, @@ -7345,13 +7437,13 @@ def test_stringized_annotations_in_module(self): ) def test_stringized_annotations_in_empty_module(self): - isa2 = _inspect_stringized_annotations_2 + isa2 = self.inspect_stringized_annotations_2 self.assertEqual(get_annotations(isa2), {}) self.assertEqual(get_annotations(isa2, eval_str=True), {}) self.assertEqual(get_annotations(isa2, eval_str=False), {}) def test_stringized_annotations_on_wrapper(self): - isa = _inspect_stringized_annotations + isa = self.inspect_stringized_annotations wrapped = times_three(isa.function) self.assertEqual(wrapped(1, "x"), isa.MyClass(3, "xxx")) self.assertIsNot(wrapped.__globals__, isa.function.__globals__) @@ -7369,7 +7461,7 @@ def test_stringized_annotations_on_wrapper(self): ) def test_stringized_annotations_on_class(self): - isa = _inspect_stringized_annotations + isa = self.inspect_stringized_annotations # test that local namespace lookups work self.assertEqual( get_annotations(isa.MyClassWithLocalAnnotations), From f8d8e95f347d484968dc8906a2520ce1cc4f62be Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 5 Jun 2024 17:33:05 -0700 Subject: [PATCH 9/9] Apply suggestions from code review Co-authored-by: Alex Waygood --- src/typing_extensions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 8760e313..342a7492 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -3661,7 +3661,7 @@ def get_annotations(obj, *, globals=None, locals=None, eval_str=False, """ format = Format(format) - if eval_str and format != Format.VALUE: + if eval_str and format is not Format.VALUE: raise ValueError("eval_str=True is only supported with format=Format.VALUE") if isinstance(obj, type): @@ -3712,7 +3712,7 @@ def get_annotations(obj, *, globals=None, locals=None, eval_str=False, return {} if not eval_str: - if format == Format.SOURCE: + if format is Format.SOURCE: return { key: value if isinstance(value, str) else typing._type_repr(value) for key, value in ann.items()