Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/ducktools/classbuilder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
26 changes: 2 additions & 24 deletions src/ducktools/classbuilder/annotations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@
apply_annotations,
get_func_annotations,
get_ns_annotations,
is_classvar,
resolve_type,
)
else: # cover-req-lt3.14
from .annotations_pre_314 import (
apply_annotations,
get_func_annotations,
get_ns_annotations,
is_classvar,
resolve_type,
)

Expand All @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions src/ducktools/classbuilder/annotations/annotations_314.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions src/ducktools/classbuilder/annotations/annotations_pre_314.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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__")
Expand All @@ -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
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
78 changes: 78 additions & 0 deletions tests/py314_tests/test_classvar.py
Original file line number Diff line number Diff line change
@@ -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
79 changes: 40 additions & 39 deletions tests/py314_tests/test_ns_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -69,53 +74,49 @@ 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():
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]",
}