diff --git a/src/ducktools/classbuilder/__init__.py b/src/ducktools/classbuilder/__init__.py index 420f49d..37f9c9b 100644 --- a/src/ducktools/classbuilder/__init__.py +++ b/src/ducktools/classbuilder/__init__.py @@ -742,12 +742,13 @@ def field_annotation_gatherer(cls_or_ns, *, cls_annotations=None): kw_flag = False for k, v in cls_annotations.items(): - _t = resolve_type(v, stringify_forwardrefs=False) - - # Ignore ClassVar - if is_classvar(_t): + # Ignore ClassVar - is_classvar will handle deferred annotations + if is_classvar(v): continue + # Resolve any deferred annotations for further logic + _t = resolve_type(v, stringify_forwardrefs=False) + if _t is KW_ONLY or (isinstance(_t, str) and _t == "KW_ONLY"): if kw_flag: raise SyntaxError("KW_ONLY sentinel may only appear once.") diff --git a/src/ducktools/classbuilder/annotations/__init__.py b/src/ducktools/classbuilder/annotations/__init__.py index 6b5c38d..6ef21d4 100644 --- a/src/ducktools/classbuilder/annotations/__init__.py +++ b/src/ducktools/classbuilder/annotations/__init__.py @@ -27,6 +27,7 @@ apply_annotations, get_func_annotations, get_ns_annotations, + is_classvar, resolve_type, ) else: # cover-req-lt3.14 @@ -34,6 +35,7 @@ apply_annotations, get_func_annotations, get_ns_annotations, + is_classvar, resolve_type, ) @@ -48,30 +50,6 @@ ] -def is_classvar(hint): - # This is a duplicate of `is_type` but for ClassVar to avoid - # importing ClassVar to check it - if isinstance(hint, str): - # String annotations, just check if the string 'ClassVar' is in there - # This is overly broad and could be smarter. - return "ClassVar" in hint - else: - _typing = sys.modules.get("typing") - if _typing: - _Annotated = _typing.Annotated - _get_origin = _typing.get_origin - - if _Annotated and _get_origin(hint) is _Annotated: - hint = getattr(hint, "__origin__", None) - - if ( - hint is _typing.ClassVar - or getattr(hint, "__origin__", None) is _typing.ClassVar - ): - return True - return False - - def is_type(hint, t): # Resolve types as forward references hint = resolve_type(hint) diff --git a/src/ducktools/classbuilder/annotations/annotations_314.py b/src/ducktools/classbuilder/annotations/annotations_314.py index 260ed44..111e38d 100644 --- a/src/ducktools/classbuilder/annotations/annotations_314.py +++ b/src/ducktools/classbuilder/annotations/annotations_314.py @@ -154,3 +154,53 @@ def apply_annotations(obj, annotations): obj.__annotate__ = _reannotate.ReAnnotate(annotations) else: obj.__annotations__ = annotations + + +def is_classvar(hint): + if isinstance(hint, str): + # String annotations, just check if the string 'ClassVar' is in there + # This is overly broad and could be smarter. + return "ClassVar" in hint + else: + _typing = sys.modules.get("typing") + has_reannotate = "reannotate" in sys.modules + if _typing: + _Annotated = _typing.Annotated + _get_origin = _typing.get_origin + + if _get_origin(hint) is _Annotated: + # Extract from `Annotated` in a resolved annotation + hint = getattr(hint, "__origin__", None) + + elif has_reannotate and isinstance(hint, _reannotate.DeferredAnnotation): + # Extract from a deferred annotation + # Include unwrapping from annotated if necessary + origin, args = _reannotate.get_origin(hint), _reannotate.get_args(hint) + if origin is None: + # Could be a plain classvar with no subscript + hint = hint.evaluate(format=_annotationlib.Format.FORWARDREF) + else: + origin = origin.evaluate(format=_annotationlib.Format.FORWARDREF) + if origin is _Annotated: + if args: + # Try to get the origin again + origin = _reannotate.get_origin(args[0]) + if origin is None: + # Potentially a plain `ClassVar` with no subscript + hint = args[0].evaluate(format=_annotationlib.Format.FORWARDREF) + else: + # Potentially a ClassVar with a subscript + # We're not going to recurse any further so try to evaluate + hint = origin.evaluate(format=_annotationlib.Format.FORWARDREF) + else: + # No argument Annotated? + # This is invalid, but also not a ClassVar + return False + else: + hint = origin + if ( + hint is _typing.ClassVar + or getattr(hint, "__origin__", None) is _typing.ClassVar + ): + return True + return False diff --git a/src/ducktools/classbuilder/annotations/annotations_pre_314.py b/src/ducktools/classbuilder/annotations/annotations_pre_314.py index 7b88269..6a7cfa4 100644 --- a/src/ducktools/classbuilder/annotations/annotations_pre_314.py +++ b/src/ducktools/classbuilder/annotations/annotations_pre_314.py @@ -19,6 +19,8 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import sys + def get_func_annotations(func): """ @@ -29,6 +31,7 @@ def get_func_annotations(func): """ return func.__annotations__ + # This is simplified under 3.13 or earlier def get_ns_annotations(ns, cls=None): annotations = ns.get("__annotations__") @@ -38,8 +41,34 @@ def get_ns_annotations(ns, cls=None): annotations = {} return annotations + def resolve_type(obj, stringify_forwardrefs=False): return obj + def apply_annotations(obj, annotations): obj.__annotations__ = annotations + + +def is_classvar(hint): + # This is a duplicate of `is_type` but for ClassVar to avoid + # importing ClassVar to check it + if isinstance(hint, str): + # String annotations, just check if the string 'ClassVar' is in there + # This is overly broad and could be smarter. + return "ClassVar" in hint + else: + _typing = sys.modules.get("typing") + if _typing: + _Annotated = _typing.Annotated + _get_origin = _typing.get_origin + + if _Annotated and _get_origin(hint) is _Annotated: + hint = getattr(hint, "__origin__", None) + + if ( + hint is _typing.ClassVar + or getattr(hint, "__origin__", None) is _typing.ClassVar + ): + return True + return False diff --git a/tests/conftest.py b/tests/conftest.py index 2299f68..e1d22e2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,7 @@ collect_ignore: list[str] = [] -if sys.version_info < (3, 16): +if sys.version_info < (3, 18): minor_ver = sys.version_info.minor collect_ignore.extend( diff --git a/tests/py314_tests/test_classvar.py b/tests/py314_tests/test_classvar.py new file mode 100644 index 0000000..758db6f --- /dev/null +++ b/tests/py314_tests/test_classvar.py @@ -0,0 +1,78 @@ +""" +Test that ClassVar works if DeferredAnnotations are used +""" + +import typing as t +from ducktools.classbuilder.prefab import prefab, get_attributes + +class TestClassVarRef: + def test_basic_ref(self): + @prefab + class Example: + a: t.ClassVar[unknown] + b: str + + attribs = get_attributes(Example) + + assert 'a' not in attribs + assert 'b' in attribs + + def test_plain_classvar(self): + # Test a plain ClassVar in a context where annotations will be + # DeferredAnnotations (ie: there's a forwardref) + @prefab + class Example: + a: t.ClassVar + b: unknown + + attribs = get_attributes(Example) + + assert 'a' not in attribs + assert 'b' in attribs + + def test_plain_classvar_annotated(self): + # Test again but wrapped in Annotated + @prefab + class Example: + a: t.Annotated[t.ClassVar, ''] + b: unknown + + attribs = get_attributes(Example) + + assert 'a' not in attribs + assert 'b' in attribs + + def test_unresolvable_ref(self): + # This tests that ClassVar still works even if the entire annotation is + # unresolvable as a forward reference + @prefab + class Example: + a: t.ClassVar[t.unresolvable] + b: str + + attribs = get_attributes(Example) + + assert 'a' not in attribs + assert 'b' in attribs + + def test_annotated_basic(self): + @prefab + class Example: + a: t.Annotated[t.ClassVar[unknown], ''] + b: str + + attribs = get_attributes(Example) + + assert 'a' not in attribs + assert 'b' in attribs + + def test_annotated_unresolvable(self): + @prefab + class Example: + a: t.Annotated[t.ClassVar[t.unresolvable], ''] + b: str + + attribs = get_attributes(Example) + + assert 'a' not in attribs + assert 'b' in attribs diff --git a/tests/py314_tests/test_ns_annotations.py b/tests/py314_tests/test_ns_annotations.py index 39352f7..85e85ab 100644 --- a/tests/py314_tests/test_ns_annotations.py +++ b/tests/py314_tests/test_ns_annotations.py @@ -50,6 +50,11 @@ class Inner: assert annos['b_val'].as_str == "global_type" assert annos['c_val'].as_str == "hyper_type" + # Check they evaluate to the correct values + assert annos['a_val'].evaluate() is str + assert annos['b_val'].evaluate() is int + assert annos['c_val'].evaluate() is float + def test_inner_outer_ref_resolved(): # If types can be resolved - they are resolved @@ -69,10 +74,9 @@ class Inner: annos = make_func() - assert annos['a_val'] == str - assert annos['b_val'] == int - assert annos['c_val'] == float - + assert annos['a_val'] is str + assert annos['b_val'] is int + assert annos['c_val'] is float def test_func_annotations(): @@ -80,42 +84,39 @@ def forwardref_func(x: unknown) -> str: return '' annos = get_func_annotations(forwardref_func) - expected = { - 'x': "unknown", - 'return': "str", - } + assert annos['x'].as_str == "unknown" assert annos['return'].as_str == "str" -def test_ns_annotations(): - # The 3.14 annotations version of test_ns_annotations - CV = ClassVar - - class AnnotatedClass: - a: str - b: "str" - c: list[str] - d: "list[str]" - e: ClassVar[str] - f: "ClassVar[str]" - g: "ClassVar[forwardref]" - h: "Annotated[ClassVar[str], '']" - i: "Annotated[ClassVar[forwardref], '']" - j: "CV[str]" - - annos = get_ns_annotations(vars(AnnotatedClass)) - - assert annos == { - 'a': str, - 'b': "str", - 'c': list[str], - 'd': "list[str]", - 'e': ClassVar[str], - 'f': "ClassVar[str]", - 'g': "ClassVar[forwardref]", - 'h': "Annotated[ClassVar[str], '']", - 'i': "Annotated[ClassVar[forwardref], '']", - 'j': "CV[str]", - } - +class TestClassVar: + def test_ns_annotations_no_refs(self): + # The 3.14 annotations version of test_ns_annotations + CV = ClassVar + + class AnnotatedClass: + a: str + b: "str" + c: list[str] + d: "list[str]" + e: ClassVar[str] + f: "ClassVar[str]" + g: "ClassVar[forwardref]" + h: "Annotated[ClassVar[str], '']" + i: "Annotated[ClassVar[forwardref], '']" + j: "CV[str]" + + annos = get_ns_annotations(vars(AnnotatedClass)) + + assert annos == { + 'a': str, + 'b': "str", + 'c': list[str], + 'd': "list[str]", + 'e': ClassVar[str], + 'f': "ClassVar[str]", + 'g': "ClassVar[forwardref]", + 'h': "Annotated[ClassVar[str], '']", + 'i': "Annotated[ClassVar[forwardref], '']", + 'j': "CV[str]", + }