Skip to content
Merged
12 changes: 5 additions & 7 deletions docs/code_examples/docs_ex02_register.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from dataclasses import dataclass
from ducktools.classbuilder import slotclass, SlotFields
from ducktools.classbuilder.prefab import Prefab

class_register = {}

Expand All @@ -9,20 +9,18 @@ def register(cls):
return cls


# Order is important here
@dataclass(slots=True)
@register
class DataCoords:
x: float = 0.0
y: float = 0.0


@slotclass
@register
class SlotCoords:
__slots__ = SlotFields(x=0.0, y=0.0)
# Type hints don't affect class construction, these are optional.
x: float
y: float
class SlotCoords(Prefab):
x: float = 0.0
y: float = 0.0


print(DataCoords())
Expand Down
34 changes: 18 additions & 16 deletions docs/code_examples/docs_ex08_converters.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
from ducktools.classbuilder import (
builder,
default_methods,
add_methods,
get_fields,
slot_gatherer,
Field,
make_unified_gatherer,
print_generated_code,
GeneratedCode,
SlotFields,
MethodMaker,
)
from ducktools.classbuilder.prefab import attribute, Attribute, Prefab


class ConverterField(Field):
converter = Field(default=None)
class ConverterAttribute(Attribute):
converter = attribute(default=None)

# This makes the internal field instances into `ConverterAttribute` instead of `Attribute`
# which would be the default for `prefab`
gatherer = make_unified_gatherer(field_type=ConverterAttribute)

def setattr_generator(cls, funcname="__setattr__"):
fields = get_fields(cls)
Expand All @@ -38,20 +40,20 @@ def setattr_generator(cls, funcname="__setattr__"):


setattr_maker = MethodMaker("__setattr__", setattr_generator)
methods = frozenset(default_methods | {setattr_maker})
extra_methods = {setattr_maker}


def converterclass(cls, /):
return builder(cls, gatherer=slot_gatherer, methods=methods)
class ConverterClass(Prefab, gatherer=gatherer):
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
add_methods(cls, extra_methods)


if __name__ == "__main__":
@converterclass
class ConverterEx:
__slots__ = SlotFields(
unconverted=ConverterField(),
converted=ConverterField(converter=int),
)
class ConverterEx(ConverterClass):
unconverted: str
converted: int = ConverterAttribute(converter=int)

ex = ConverterEx("42", "42")
print(ex)
print_generated_code(ConverterEx)
78 changes: 30 additions & 48 deletions docs/code_examples/docs_ex09_annotated.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
# Don't use __future__ annotations with get_ns_annotations in this case
# as it doesn't evaluate string annotations.

# NOTE: In Python 3.14 this will currently only work if there are *no* forward references.

import sys
import types
from functools import wraps
from typing import Annotated, Any, ClassVar, get_origin

from ducktools.classbuilder import (
builder,
default_methods,
get_fields,
get_methods,
Field,
SlotMakerMeta,
NOTHING,
)
from ducktools.classbuilder.prefab import prefab, Prefab, Attribute, attribute, get_attributes

from ducktools.classbuilder.annotations import get_ns_annotations
from ducktools.classbuilder.annotations import get_ns_annotations, is_classvar, resolve_type


# Our 'Annotated' tools need to be combinable and need to contain the keyword argument
Expand Down Expand Up @@ -48,6 +44,7 @@ def __eq__(self, other):
NO_INIT = FieldModifier(init=False)
NO_REPR = FieldModifier(repr=False)
NO_COMPARE = FieldModifier(compare=False)
NO_SERIALIZE = FieldModifier(serialize=False)
IGNORE_ALL = FieldModifier(init=False, repr=False, compare=False)


Expand All @@ -68,6 +65,12 @@ def annotated_gatherer(cls_or_ns):
for key, anno in cls_annotations.items():
modifiers = {}

# Under Python 3.14 these may be `DeferredAnnotations`
# Resolve them to ForwardRefs
anno = resolve_type(anno)
if is_classvar(anno):
continue

if get_origin(anno) is Annotated:
meta = anno.__metadata__
for v in meta:
Expand All @@ -78,59 +81,34 @@ def annotated_gatherer(cls_or_ns):
# Extract the actual annotation from the first argument
anno = anno.__origin__

if anno is ClassVar or get_origin(anno) is ClassVar:
continue

if key in cls_dict:
val = cls_dict[key]
if isinstance(val, Field):
if isinstance(val, Attribute):
# Make a new field - DO NOT MODIFY FIELDS IN PLACE
fld = Field.from_field(val, type=anno, **modifiers)
fld = Attribute.from_field(val, type=anno, **modifiers)
cls_modifications[key] = NOTHING
elif not isinstance(val, types.MemberDescriptorType):
fld = Field(default=val, type=anno, **modifiers)
fld = Attribute(default=val, type=anno, **modifiers)
cls_modifications[key] = NOTHING
else:
fld = Field(type=anno, **modifiers)
fld = Attribute(type=anno, **modifiers)
else:
fld = Field(type=anno, **modifiers)
fld = Attribute(type=anno, **modifiers)

cls_fields[key] = fld

return cls_fields, cls_modifications


# As a decorator
def annotatedclass(cls=None, *, kw_only=False):
if not cls:
return lambda cls_: annotatedclass(cls_, kw_only=kw_only)

return builder(
cls,
gatherer=annotated_gatherer,
methods=default_methods,
flags={"slotted": False, "kw_only": kw_only}
)
@wraps(prefab)
def annotatedclass(cls=None, **kwargs):
return prefab(cls, gatherer=annotated_gatherer, **kwargs)


# As a base class with slots
class AnnotatedClass(metaclass=SlotMakerMeta, gatherer=annotated_gatherer):

def __init_subclass__(cls, kw_only=False, **kwargs):
slots = "__slots__" in cls.__dict__

# if slots is True then fields will already be present in __slots__
# Use the slot_gatherer for this case
gatherer = annotated_gatherer

builder(
cls,
gatherer=gatherer,
methods=default_methods,
flags={"slotted": slots, "kw_only": kw_only}
)

super().__init_subclass__(**kwargs)
class AnnotatedClass(Prefab, gatherer=annotated_gatherer):
pass


if __name__ == "__main__":
Expand All @@ -144,9 +122,12 @@ class X:
z: Annotated[ClassVar[str], "Should be ignored"] = "This should also be ignored" # type: ignore
a: Annotated[int, NO_INIT] = "Not In __init__ signature" # type: ignore
b: Annotated[str, NO_REPR] = "Not In Repr"
c: Annotated[list[str], NO_COMPARE] = Field(default_factory=list) # type: ignore
c: Annotated[list[str], NO_COMPARE] = attribute(default_factory=list) # type: ignore
d: Annotated[str, IGNORE_ALL] = "Not Anywhere"
e: Annotated[str, KW_ONLY, NO_COMPARE]
if sys.version_info >= (3, 14):
# Forward references work in 3.14
f: Annotated[unknown, NO_COMPARE, NO_SERIALIZE] = 42


class Y(AnnotatedClass):
Expand All @@ -155,16 +136,17 @@ class Y(AnnotatedClass):
z: Annotated[ClassVar[str], "Should be ignored"] = "This should also be ignored" # type: ignore
a: Annotated[int, NO_INIT] = "Not In __init__ signature" # type: ignore
b: Annotated[str, NO_REPR] = "Not In Repr"
c: Annotated[list[str], NO_COMPARE] = Field(default_factory=list) # type: ignore
c: Annotated[list[str], NO_COMPARE] = attribute(default_factory=list) # type: ignore
d: Annotated[str, IGNORE_ALL] = "Not Anywhere"
e: Annotated[str, KW_ONLY, NO_COMPARE]

if sys.version_info >= (3, 14):
f: Annotated[unknown, NO_COMPARE, NO_SERIALIZE] = 42

# Unslotted Demo
ex = X("Value of x", e="Value of e") # type: ignore
print(ex, "\n")

pp(get_fields(X))
pp(get_attributes(X))
print("\n")

# Slotted Demo
Expand All @@ -183,6 +165,6 @@ class Y(AnnotatedClass):
# Both classes generate identical source code
genX = method.code_generator(X)
genY = method.code_generator(Y)
assert genX == genY
assert genX.source_code == genY.source_code

print(genX.source_code)
3 changes: 3 additions & 0 deletions docs/code_examples/outputs/docs_ex01_basic.output
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ class Slotted(ducktools.classbuilder.prefab.Prefab)
|
| Methods defined here:
|
| __classbuilder_meta_gatherer__ = field_unified_gatherer(cls_or_ns) from ducktools.classbuilder.make_unified_gatherer.<locals>
|
| __eq__(self, other) from Slotted
|
| __init__(
Expand Down Expand Up @@ -72,4 +74,5 @@ class Slotted(ducktools.classbuilder.prefab.Prefab)
| :param ignore_annotations: Ignore type annotations when gathering fields, only look for
| slots or attribute(...) values
| :param slots: automatically generate slots for this class's attributes
| :param gatherer: A gatherer to use for collecting `Attribute`s

31 changes: 31 additions & 0 deletions docs/code_examples/outputs/docs_ex08_converters.output
Original file line number Diff line number Diff line change
@@ -1 +1,32 @@
ConverterEx(unconverted='42', converted=42)
Source:
def __eq__(self, other):
return (
self.unconverted == other.unconverted
and self.converted == other.converted
) if self.__class__ is other.__class__ else NotImplemented

def __init__(self, unconverted, converted):
self.unconverted = unconverted
self.converted = converted

def __replace__(self, /, **changes):
new_kwargs = {'unconverted': self.unconverted, 'converted': self.converted}
new_kwargs |= changes
return self.__class__(**new_kwargs)

def __repr__(self):
return f'{type(self).__qualname__}(unconverted={self.unconverted!r}, converted={self.converted!r})'

def __setattr__(self, name, value):
if conv := _converters.get(name):
_object_setattr(self, name, conv(value))
else:
_object_setattr(self, name, value)


Globals:
__setattr__: {'_converters': {'converted': <class 'int'>}, '_object_setattr': <slot wrapper '__setattr__' of 'object' objects>}

Annotations:
__init__: {'unconverted': <class 'str'>, 'converted': <class 'int'>, 'return': None}
35 changes: 21 additions & 14 deletions docs/code_examples/outputs/docs_ex09_annotated.output
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
X(x='Value of x', a='Not In __init__ signature', c=[], e='Value of e')
<generated class X; x='Value of x', a='Not In __init__ signature', c=[], e='Value of e', f=42>

{'x': Field(default=<NOTHING OBJECT>, default_factory=<NOTHING OBJECT>, type=<class 'str'>, doc=None, init=True, repr=True, compare=True, kw_only=False),
'a': Field(default='Not In __init__ signature', default_factory=<NOTHING OBJECT>, type=<class 'int'>, doc=None, init=False, repr=True, compare=True, kw_only=False),
'b': Field(default='Not In Repr', default_factory=<NOTHING OBJECT>, type=<class 'str'>, doc=None, init=True, repr=False, compare=True, kw_only=False),
'c': Field(default=<NOTHING OBJECT>, default_factory=<class 'list'>, type=list[str], doc=None, init=True, repr=True, compare=False, kw_only=False),
'd': Field(default='Not Anywhere', default_factory=<NOTHING OBJECT>, type=<class 'str'>, doc=None, init=False, repr=False, compare=False, kw_only=False),
'e': Field(default=<NOTHING OBJECT>, default_factory=<NOTHING OBJECT>, type=<class 'str'>, doc=None, init=True, repr=True, compare=False, kw_only=True)}
{'x': Attribute(default=<NOTHING OBJECT>, default_factory=<NOTHING OBJECT>, type=<class 'str'>, doc=None, init=True, repr=True, compare=True, kw_only=False, iter=True, serialize=True, metadata={}),
'a': Attribute(default='Not In __init__ signature', default_factory=<NOTHING OBJECT>, type=<class 'int'>, doc=None, init=False, repr=True, compare=True, kw_only=False, iter=True, serialize=True, metadata={}),
'b': Attribute(default='Not In Repr', default_factory=<NOTHING OBJECT>, type=<class 'str'>, doc=None, init=True, repr=False, compare=True, kw_only=False, iter=True, serialize=True, metadata={}),
'c': Attribute(default=<NOTHING OBJECT>, default_factory=<class 'list'>, type=list[str], doc=None, init=True, repr=True, compare=False, kw_only=False, iter=True, serialize=True, metadata={}),
'd': Attribute(default='Not Anywhere', default_factory=<NOTHING OBJECT>, type=<class 'str'>, doc=None, init=False, repr=False, compare=False, kw_only=False, iter=True, serialize=True, metadata={}),
'e': Attribute(default=<NOTHING OBJECT>, default_factory=<NOTHING OBJECT>, type=<class 'str'>, doc=None, init=True, repr=True, compare=False, kw_only=True, iter=True, serialize=True, metadata={}),
'f': Attribute(default=42, default_factory=<NOTHING OBJECT>, type=ForwardRef('unknown'), doc=None, init=True, repr=True, compare=False, kw_only=False, iter=True, serialize=False, metadata={})}


Y(x='Value of x', a='Not In __init__ signature', c=[], e='Value of e')
<generated class Y; x='Value of x', a='Not In __init__ signature', c=[], e='Value of e', f=42>

Slots: {'x': None, 'a': None, 'b': None, 'c': None, 'd': None, 'e': None}
Slots: {'x': None, 'a': None, 'b': None, 'c': None, 'd': None, 'e': None, 'f': None}

Source:
def __eq__(self, other):
Expand All @@ -20,14 +21,20 @@ def __eq__(self, other):
and self.b == other.b
) if self.__class__ is other.__class__ else NotImplemented

def __init__(self, x, b=_b_default, c=None, *, e):
def __init__(self, x, b='Not In Repr', c=None, f=42, *, e):
self.x = x
self.a = _a_default
self.a = 'Not In __init__ signature'
self.b = b
self.c = _c_factory() if c is None else c
self.d = _d_default
self.c = c if c is not None else []
self.d = 'Not Anywhere'
self.e = e
self.f = f

def __replace__(self, /, **changes):
new_kwargs = {'x': self.x, 'b': self.b, 'c': self.c, 'e': self.e, 'f': self.f}
new_kwargs |= changes
return self.__class__(**new_kwargs)

def __repr__(self):
return f'{type(self).__qualname__}(x={self.x!r}, a={self.a!r}, c={self.c!r}, e={self.e!r})'
return f'<generated class {type(self).__qualname__}; x={self.x!r}, a={self.a!r}, c={self.c!r}, e={self.e!r}, f={self.f!r}>'

8 changes: 5 additions & 3 deletions docs/extension_examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,15 @@ their attribute is set.
## Gatherers ##
### What about using annotations instead of `Field(init=False, ...)` ###

This seems to be a feature people keep requesting for `dataclasses`.
This seems to be a feature people keep requesting for `dataclasses`. This implements it on top of `prefab`.

To implement this you need to create a new annotated_gatherer function.

> Note: Field classes will be frozen when running under pytest.
> Note: Field class instances will be frozen when running under pytest.
> They should not be mutated by gatherers.
> If you need to change the value of a field use Field.from_field(...) to make a new instance.
> If you need to change the value of a field use field_type.from_field(...) to make a new instance.

```{literalinclude} code_examples/docs_ex09_annotated.py
```

Note that this is unlikely ever to be a standard feature of `prefab` as I think this is worse.
Loading