Examples of extending builders
Here are some examples of adding specific features to classes using the tools provided
by the ducktools.classbuilder module.
How can I add <method> to the class
To do this you need to write a code generator that returns source code along with a ‘globals’ dictionary of any names the code needs to refer to, or an empty dictionary if none are needed. Many methods don’t require any globals values, but it is essential for some.
Frozen Classes
In order to make frozen classes you need to replace __setattr__ and __delattr__
The building blocks for this are actually already included as they’re used to prevent
Field subclass instances from being mutated when under testing.
These methods can be reused to make slotclasses ‘frozen’.
from ducktools.classbuilder import (
slotclass,
SlotFields,
default_methods,
)
from ducktools.classbuilder.methods import (
frozen_delattr_maker,
frozen_setattr_maker
)
new_methods = default_methods | {frozen_setattr_maker, frozen_delattr_maker}
def frozen(cls, /):
return slotclass(cls, methods=new_methods)
if __name__ == "__main__":
@frozen
class FrozenEx:
__slots__ = SlotFields(
x=6,
y=9,
product=42,
)
ex = FrozenEx()
print(ex)
try:
ex.y = 7
except TypeError as e:
print(e)
try:
ex.z = "new value"
except TypeError as e:
print(e)
try:
del ex.y
except TypeError as e:
print(e)
Iterable Classes
Say you want to make the class iterable, so you want to add __iter__.
from ducktools.classbuilder import (
default_methods,
slotclass,
SlotFields,
)
from ducktools.classbuilder.functions import get_fields
from ducktools.classbuilder.methods import GeneratedCode, MethodMaker
def iter_generator(cls, funcname="__iter__"):
field_names = get_fields(cls).keys()
field_yield = "\n".join(f" yield self.{f}" for f in field_names)
if not field_yield:
field_yield = " yield from ()"
code = f"def {funcname}(self):\n{field_yield}"
globs = {}
return GeneratedCode(code, globs)
iter_maker = MethodMaker("__iter__", iter_generator)
new_methods = frozenset(default_methods | {iter_maker})
def iterclass(cls=None, /):
return slotclass(cls, methods=new_methods)
if __name__ == "__main__":
@iterclass
class IterDemo:
__slots__ = SlotFields(
a=1,
b=2,
c=3,
d=4,
e=5,
)
ex = IterDemo()
print([item for item in ex])
@iterclass
class IterDemo:
__slots__ = SlotFields()
ex = IterDemo()
print([item for item in ex])
You could also choose to yield tuples of name, value pairs in your implementation.
Extending Field
The Field class can also be extended as if it is a slotclass, with annotations or
with Field declarations.
One notable caveat - if you want to use a default_factory in extending Field you
need to declare default=FIELD_NOTHING also in order for default to be ignored. This
is a special case for Field and is not needed in general.
from ducktools.classbuilder import Field, FIELD_NOTHING
class MetadataField(Field):
metadata: dict = Field(default=FIELD_NOTHING, default_factory=dict)
In regular classes the __init__ function generator considers NOTHING to be an
ignored value, but for Field subclasses it is a valid value so FIELD_NOTHING is
the ignored term. This is all because None is a valid value and can’t be used
as a sentinel for Fields (otherwise Field(default=None) couldn’t work).
Positional Only Arguments?
This is possible, but a little longer as we also need to modify multiple methods
along with adding a check to the builder to catch likely errors before the __init__
method is generated.
For simplicity this demonstration version will ignore the existence of the kw_only parameter for fields.
from ducktools.classbuilder import (
builder,
slot_gatherer,
Field,
SlotFields,
)
from ducktools.classbuilder.constants import NOTHING
from ducktools.classbuilder.functions import get_fields
from ducktools.classbuilder.methods import (
GeneratedCode,
MethodMaker,
eq_maker,
)
class PosOnlyField(Field):
__slots__ = SlotFields(pos_only=True)
def init_generator(cls, funcname="__init__"):
fields = get_fields(cls)
arglist = []
assignments = []
globs = {}
used_posonly = False
used_kw = False
for k, v in fields.items():
if getattr(v, "pos_only", False):
used_posonly = True
elif used_posonly and not used_kw:
used_kw = True
arglist.append("/")
if v.default is not NOTHING:
globs[f"_{k}_default"] = v.default
arg = f"{k}=_{k}_default"
assignment = f"self.{k} = {k}"
elif v.default_factory is not NOTHING:
globs[f"_{k}_factory"] = v.default_factory
arg = f"{k}=None"
assignment = f"self.{k} = _{k}_factory() if {k} is None else {k}"
else:
arg = f"{k}"
assignment = f"self.{k} = {k}"
arglist.append(arg)
assignments.append(assignment)
args = ", ".join(arglist)
assigns = "\n ".join(assignments)
code = f"def {funcname}(self, {args}):\n" f" {assigns}\n"
return GeneratedCode(code, globs)
def repr_generator(cls, funcname="__repr__"):
fields = get_fields(cls)
content_list = []
for name, field in fields.items():
if getattr(field, "pos_only", False):
assign = f"{{self.{name}!r}}"
else:
assign = f"{name}={{self.{name}!r}}"
content_list.append(assign)
content = ", ".join(content_list)
code = (
f"def {funcname}(self):\n"
f" return f'{{type(self).__qualname__}}({content})'\n"
)
globs = {}
return GeneratedCode(code, globs)
init_maker = MethodMaker("__init__", init_generator)
repr_maker = MethodMaker("__repr__", repr_generator)
new_methods = frozenset({init_maker, repr_maker, eq_maker})
def pos_slotclass(cls, /):
cls = builder(
cls,
gatherer=slot_gatherer,
methods=new_methods,
)
# Check no positional-only args after keyword args
flds = get_fields(cls)
used_kwarg = False
for k, v in flds.items():
if getattr(v, "pos_only", False):
if used_kwarg:
raise SyntaxError(
f"Positional only parameter {k!r}"
f" follows keyword parameters on {cls.__name__!r}"
)
else:
used_kwarg = True
return cls
if __name__ == "__main__":
@pos_slotclass
class WorkingEx:
__slots__ = SlotFields(
a=PosOnlyField(default=42),
x=6,
y=9,
)
ex = WorkingEx()
print(ex)
ex = WorkingEx(42, x=6, y=9)
print(ex)
try:
ex = WorkingEx(a=54)
except TypeError as e:
print(e)
try:
@pos_slotclass
class FailEx:
__slots__ = SlotFields(
a=42,
x=PosOnlyField(default=6),
y=PosOnlyField(default=9),
)
except SyntaxError as e:
print(e)
Frozen Attributes
Here’s an implementation that allows freezing of individual attributes.
import ducktools.classbuilder as dtbuild
import ducktools.classbuilder.functions as dtfuncs
import ducktools.classbuilder.methods as dtmethods
class FreezableField(dtbuild.Field):
frozen: bool = False
def setattr_generator(cls, funcname="__setattr__"):
globs = {}
flags = dtfuncs.get_flags(cls)
fields = dtfuncs.get_fields(cls)
frozen_fields = set(
name for name, field in fields.items()
if getattr(field, "frozen", False)
)
globs["__frozen_fields"] = frozen_fields
if flags.get("slotted", True):
globs["__setattr_func"] = object.__setattr__
setattr_method = "__setattr_func(self, name, value)"
attrib_check = "hasattr(self, name)"
else:
setattr_method = "self.__dict__[name] = value"
attrib_check = "name in self.__dict__"
code = (
f"def {funcname}(self, name, value):\n"
f" if name in __frozen_fields and {attrib_check}:\n"
f" raise AttributeError(\n"
f" f'Attribute {{name!r}} does not support assignment'\n"
f" )\n"
f" else:\n"
f" {setattr_method}\n"
)
return dtmethods.GeneratedCode(code, globs)
def delattr_generator(cls, funcname="__delattr__"):
globs = {}
flags = dtfuncs.get_flags(cls)
fields = dtfuncs.get_fields(cls)
frozen_fields = set(
name for name, field in fields.items()
if getattr(field, "frozen", False)
)
globs["__frozen_fields"] = frozen_fields
if flags.get("slotted", True):
globs["__delattr_func"] = object.__delattr__
delattr_method = "__delattr_func(self, name)"
else:
delattr_method = "del self.__dict__[name]"
code = (
f"def {funcname}(self, name):\n"
f" if name in __frozen_fields:"
f" raise AttributeError(\n"
f" f'Attribute {{name!r}} is frozen and can not be deleted'\n"
f" )\n"
f" else:\n"
f" {delattr_method}\n"
)
return dtmethods.GeneratedCode(code, globs)
frozen_setattr_field_maker = dtmethods.MethodMaker("__setattr__", setattr_generator)
frozen_delattr_field_maker = dtmethods.MethodMaker("__delattr__", delattr_generator)
gatherer = dtbuild.make_unified_gatherer(FreezableField)
def freezable(cls=None, /, *, frozen=False):
if cls is None:
return lambda cls_: freezable(cls_, frozen=frozen)
# To make a slotted class use a base class with metaclass
flags = {"frozen": frozen, "slotted": False}
cls = dtbuild.builder(
cls,
gatherer=gatherer,
methods=dtbuild.default_methods,
flags=flags,
)
# Frozen attributes need to be added afterwards
# Due to the need to know if frozen fields exist
if frozen:
setattr(cls, "__setattr__", dtmethods.frozen_setattr_maker)
setattr(cls, "__delattr__", dtmethods.frozen_delattr_maker)
else:
fields = dtfuncs.get_fields(cls)
has_frozen_fields = False
for f in fields.values():
if getattr(f, "frozen", False):
has_frozen_fields = True
break
if has_frozen_fields:
setattr(cls, "__setattr__", frozen_setattr_field_maker)
setattr(cls, "__delattr__", frozen_delattr_field_maker)
return cls
@freezable
class X:
a: int = 2
b: int = FreezableField(default=12, frozen=True)
x = X()
x.a = 21
try:
x.b = 43
except AttributeError as e:
print(repr(e))
Converters
Here’s an implementation of basic converters that always convert when their attribute is set.
from ducktools.classbuilder import make_unified_gatherer
from ducktools.classbuilder.functions import get_fields, get_generated_code
from ducktools.classbuilder.methods import add_methods, GeneratedCode, MethodMaker
from ducktools.classbuilder.prefab import attribute, Attribute, Prefab
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)
converters = {}
for k, v in fields.items():
if conv := getattr(v, "converter", None):
converters[k] = conv
globs = {
"_converters": converters,
"_object_setattr": object.__setattr__,
}
code = (
f"def {funcname}(self, name, value):\n"
f" if conv := _converters.get(name):\n"
f" _object_setattr(self, name, conv(value))\n"
f" else:\n"
f" _object_setattr(self, name, value)\n"
)
return GeneratedCode(code, globs)
setattr_maker = MethodMaker("__setattr__", setattr_generator)
extra_methods = {setattr_maker}
class ConverterClass(Prefab, gatherer=gatherer):
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
add_methods(cls, extra_methods)
if __name__ == "__main__":
class ConverterEx(ConverterClass):
unconverted: str
converted: int = ConverterAttribute(converter=int)
ex = ConverterEx("42", "42")
print(ex)
print()
code = get_generated_code(ConverterEx)
for k in sorted(code):
print(code[k].source_code)
Gatherers
What about using annotations instead of Field(init=False, ...)
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 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_type.from_field(…) to make a new instance.
# Don't use __future__ annotations with get_ns_annotations in this case
# as it doesn't evaluate string annotations.
import sys
import types
from functools import wraps
from typing import Annotated, Any, ClassVar, get_origin
from ducktools.classbuilder.constants import NOTHING
from ducktools.classbuilder.functions import get_methods
from ducktools.classbuilder.prefab import prefab, Prefab, Attribute, attribute, get_attributes
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
# and value they are intended to change.
# To this end we make a FieldModifier class that stores the keyword values given in a
# dictionary as 'modifiers'. This makes it easy to merge modifiers later.
class FieldModifier:
__slots__ = ("modifiers",)
modifiers: dict[str, Any]
def __init__(self, **modifiers):
self.modifiers = modifiers
def __repr__(self):
mod_args = ", ".join(f"{k}={v!r}" for k, v in self.modifiers.items())
return (
f"{type(self).__name__}({mod_args})"
)
def __eq__(self, other):
if self.__class__ == other.__class__:
return self.modifiers == other.modifiers
return NotImplemented
# Here we make the modifiers and give them the arguments to Field we
# wish to change with their usage.
KW_ONLY = FieldModifier(kw_only=True)
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)
# Analyse the class and create these new Fields based on the annotations
def annotated_gatherer(cls_or_ns):
if isinstance(cls_or_ns, (types.MappingProxyType, dict)):
cls_dict = cls_or_ns
else:
cls_dict = cls_or_ns.__dict__
cls_annotations = get_ns_annotations(cls_dict)
cls_fields = {}
# This gatherer doesn't make any class modifications but still needs
# To have a dict as a return value
cls_modifications = {}
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:
if isinstance(v, FieldModifier):
# Merge the modifier arguments to pass to AnnoField
modifiers.update(v.modifiers)
# Extract the actual annotation from the first argument
anno = anno.__origin__
if key in cls_dict:
val = cls_dict[key]
if isinstance(val, Attribute):
# Make a new field - DO NOT MODIFY FIELDS IN PLACE
fld = Attribute.from_field(val, type=anno, **modifiers)
cls_modifications[key] = NOTHING
elif not isinstance(val, types.MemberDescriptorType):
fld = Attribute(default=val, type=anno, **modifiers)
cls_modifications[key] = NOTHING
else:
fld = Attribute(type=anno, **modifiers)
else:
fld = Attribute(type=anno, **modifiers)
cls_fields[key] = fld
return cls_fields, cls_modifications
# As a decorator
@wraps(prefab)
def annotatedclass(cls=None, **kwargs):
return prefab(cls, gatherer=annotated_gatherer, **kwargs)
# As a base class with slots
class AnnotatedClass(Prefab, gatherer=annotated_gatherer):
pass
if __name__ == "__main__":
from pprint import pp
# Make classes, one via decorator one via subclass
@annotatedclass
class X:
x: str
y: ClassVar[str] = "This should be ignored"
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] = 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):
x: str
y: ClassVar[str] = "This should be ignored"
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] = 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_attributes(X))
print("\n")
# Slotted Demo
ex = Y("Value of x", e="Value of e") # type: ignore
print(ex, "\n")
print(f"Slots: {Y.__dict__.get('__slots__')}")
print("\nSource:")
# Obtain the methods set on the class X
methods = get_methods(X)
# Call the code generators to display the source code
for _, method in sorted(methods.items()):
# Both classes generate identical source code
genX = method.code_generator(X)
genY = method.code_generator(Y)
assert genX.source_code == genY.source_code
print(genX.source_code)
Note that this is unlikely ever to be a standard feature of prefab as I think this is worse.