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
4 changes: 2 additions & 2 deletions .github/workflows/auto_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ jobs:

steps:
- uses: actions/checkout@v6
- name: Set up Python 3.10
- name: Set up Python 3.14
uses: actions/setup-python@v6
with:
python-version: "3.10"
python-version: "3.14"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
Expand Down
57 changes: 0 additions & 57 deletions .github/workflows/publish_to_testpypi.yml

This file was deleted.

18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ class Slotted(Prefab):
default="What do you get if you multiply six by nine?",
doc="Life the universe and everything",
)
python_path: Path("/usr/bin/python4")
python_path: Path = Path("/usr/bin/python4")

ex = Slotted()
print(ex)
Expand Down Expand Up @@ -262,8 +262,8 @@ def __init__(self, app_name, app_path):
self.__prefab_post_init__(app_path=app_path)
```

Note: annotations are attached as `__annotations__` and so do not appear in generated
source code.
Note: annotations are attached as `__annotations__` or `__annotate__` and so do not appear
in generated source code.

</details>

Expand Down Expand Up @@ -314,10 +314,14 @@ There are also some intentionally missing features:
* `dataclasses` uses hashability as a proxy for mutability, but technically this is
inaccurate as you can be unhashable but immutable and mutable but hashable
* This may change in a future version, but I haven't felt the need to add this check so far
* In Python 3.14 Annotations are gathered as `VALUE` if possible and `STRING` if this fails
* `VALUE` annotations are used as they are faster in most cases
* As the `__init__` method gets `__annotations__` these need to be either values or strings
to match the behaviour of previous Python versions
* In Python 3.14 Annotations are gathered as `VALUE` if possible and `DeferredAnnotation` if this fails
* `VALUE` annotations are used as they are faster
* Forward references cause up to a 60% performance penalty on construction time
* This means in most cases, `STRING` annotations from `__init__` will be based on the `type_repr` of `VALUE` annotations
* If `VALUE` fails, `reannotate` is used to get deferred annotations that are evaluated on retrieval
* This means the annotations for the generated `__init__` should work as expected
* The `.type` attribute on `Field` or `Attribute` instances will attempt to resolve forward references
on retrieval.
* There is currently no equivalent to `InitVar`
* I'm not sure *how* I would want to implement this other than I don't _really_ want to use
annotations to decide behaviour (this is messy enough with `ClassVar` and `KW_ONLY`).
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ classifiers = [
"Operating System :: OS Independent",
]
dynamic = ['version']
dependencies = [
"reannotate>=0.1.0 ; python_full_version >= '3.14'",
]

[project.optional-dependencies]
# Needed for the current readthedocs.yaml
Expand Down
29 changes: 24 additions & 5 deletions src/ducktools/classbuilder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
MappingProxyType as _MappingProxyType,
)

from .annotations import get_ns_annotations, is_classvar
from .annotations import apply_annotations, get_ns_annotations, is_classvar, resolve_type
from ._version import __version__, __version_tuple__ # noqa: F401

# Change this name if you make heavy modifications
Expand Down Expand Up @@ -284,7 +284,7 @@ def __get__(self, inst, cls):

# Apply annotations
if gen.annotations is not None:
method.__annotations__ = gen.annotations
apply_annotations(method, gen.annotations)

# Replace this descriptor on the class with the generated function
setattr(gen_cls, self.funcname, method)
Expand Down Expand Up @@ -987,7 +987,7 @@ class Field(metaclass=SlotMakerMeta):
__slots__ = (
"default",
"default_factory",
"type",
"_type",
"doc",
"init",
"repr",
Expand Down Expand Up @@ -1070,6 +1070,22 @@ def from_field(cls, fld, /, **kwargs):

return cls(**argument_dict)

@property
def type(self):
return resolve_type(self._type)

@type.setter
def type(self, value):
try:
self._type = value
except TypeError:
# Under testing, frozen logic will prevent writing to _test
object.__setattr__(self, "_type", value)

@type.deleter
def type(self):
del self._type


def _build_field():
# Complete the construction of the Field class
Expand Down Expand Up @@ -1218,11 +1234,14 @@ def field_annotation_gatherer(cls_or_ns, *, cls_annotations=None):
kw_flag = False

for k, v in cls_annotations.items():
# Use strings instead of forwardrefs
_t = resolve_type(v, stringify_forwardrefs=False)

# Ignore ClassVar
if is_classvar(v):
if is_classvar(_t):
continue

if v is KW_ONLY or (isinstance(v, str) and "KW_ONLY" in v):
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.")
kw_flag = True
Expand Down
10 changes: 6 additions & 4 deletions src/ducktools/classbuilder/annotations.pyi
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
from collections.abc import Callable
import typing
import types
import sys

_CopiableMappings = dict[str, typing.Any] | types.MappingProxyType[str, typing.Any]

_T = typing.TypeVar("_T")

def get_func_annotations(
func: types.FunctionType,
use_forwardref: bool = ...,
) -> dict[str, typing.Any]: ...

def get_ns_annotations(
ns: _CopiableMappings,
cls: type | None = ...,
use_forwardref: bool = ...,
) -> dict[str, typing.Any]: ...

def is_classvar(
hint: object,
) -> bool: ...

def resolve_type(object, deferred_as_str: bool = ...) -> object: ...

def apply_annotations(obj: typing.Any, annotations: dict[str, typing.Any]) -> None: ...
6 changes: 6 additions & 0 deletions src/ducktools/classbuilder/annotations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,26 @@

if sys.version_info >= (3, 14):
from .annotations_314 import (
apply_annotations,
get_func_annotations,
get_ns_annotations,
resolve_type,
)
else:
from .annotations_pre_314 import (
apply_annotations,
get_func_annotations,
get_ns_annotations,
resolve_type,
)


__all__ = [
"apply_annotations",
"get_func_annotations",
"get_ns_annotations",
"is_classvar",
"resolve_type",
]


Expand Down
88 changes: 64 additions & 24 deletions src/ducktools/classbuilder/annotations/annotations_314.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,30 @@
so we can use a more standard format.
"""

class _LazyAnnotationLib:
def __getattr__(self, item):
global _lazy_annotationlib
import annotationlib # type: ignore
_lazy_annotationlib = annotationlib
return getattr(annotationlib, item)
__lazy_modules__ = ["annotationlib", "reannotate"]

import sys

_lazy_annotationlib = _LazyAnnotationLib()
if sys.version_info >= (3, 15):
import annotationlib as _annotationlib
import reannotate as _reannotate
else:
class _LazyAnnotationLib:
def __getattr__(self, item):
global _annotationlib
import annotationlib # type: ignore
_annotationlib = annotationlib
return getattr(annotationlib, item)

class _LazyReannotate:
def __getattr__(self, item):
global _reannotate
import reannotate
_reannotate = reannotate
return getattr(reannotate, item)

_annotationlib = _LazyAnnotationLib()
_reannotate = _LazyReannotate()


def _get_annotate_from_class_namespace(ns):
Expand All @@ -50,7 +65,7 @@ def _get_annotate_from_class_namespace(ns):
return ns.get("__annotate_func__", None)


def get_func_annotations(func, use_forwardref=False):
def get_func_annotations(func):
"""
Given a function, return the annotations dictionary

Expand All @@ -61,26 +76,20 @@ def get_func_annotations(func, use_forwardref=False):
try:
raw_annotations = func.__annotations__
except Exception:
fmt = (
_lazy_annotationlib.Format.FORWARDREF
if use_forwardref
else _lazy_annotationlib.Format.STRING
)
annotations = _lazy_annotationlib.get_annotations(func, format=fmt)
annotations = _reannotate.get_deferred_annotations(func)
else:
annotations = raw_annotations.copy()

return annotations


def get_ns_annotations(ns, cls=None, use_forwardref=False):
def get_ns_annotations(ns, cls=None):
"""
Given a class namespace, attempt to retrieve the
annotations dictionary.

:param ns: Class namespace (eg cls.__dict__)
:param cls: Class if available
:param use_forwardref: Use FORWARDREF instead of STRING if VALUE fails
:return: dictionary of annotations
"""

Expand All @@ -94,19 +103,50 @@ def get_ns_annotations(ns, cls=None, use_forwardref=False):
try:
annotations = annotate(1) # Format.VALUE is 1
except Exception:
fmt = (
_lazy_annotationlib.Format.FORWARDREF
if use_forwardref
else _lazy_annotationlib.Format.STRING
)

annotations = _lazy_annotationlib.call_annotate_function(
annotations = _reannotate.call_annotate_deferred(
annotate,
format=fmt,
owner=cls
)

if annotations is None:
annotations = {}

return annotations


def resolve_type(obj, stringify_forwardrefs=False):
"""
Resolve a DeferredAnnotation or return the original object

:param obj: object or deferred annotation
:param stringify_forwardrefs: return a string instead of a forwardref if
evaluation fails, defaults to False
:return: Evaluated reference
"""
if "reannotate" in sys.modules and isinstance(obj, _reannotate.DeferredAnnotation):
if stringify_forwardrefs:
try:
return obj.evaluate(format=_annotationlib.Format.VALUE)
except Exception:
return obj.evaluate(format=_annotationlib.Format.STRING)
else:
return obj.evaluate(format=_annotationlib.Format.FORWARDREF)

return obj


def apply_annotations(obj, annotations):
"""
Apply annotations to an object

If ``reannotate`` has been imported, a new __annotate__ function will
be created to handle deferred annotations. Otherwise the annotations
will be attached to `__annotations__` as there are no forward references.

:param obj: object to annotate
:param annotations: annotations dictionary
"""
if "reannotate" in sys.modules:
obj.__annotate__ = _reannotate.ReAnnotate(annotations)
else:
obj.__annotations__ = annotations
Loading