ducktools-classbuilder is the Python package that will bring you the joy
of writing... functions... that will bring back the joy of writing classes.
Maybe.
While attrs and dataclasses are class boilerplate generators,
ducktools.classbuilder is intended to provide the tools to help make a customized
version of the same concept.
Install from PyPI with:
python -m pip install ducktools-classbuilder
The classbuilder tools make up the core of this module and there is an implementation
using these tools in the prefab submodule.
There is also a minimal @slotclass example that can construct classes from a special
mapping used in __slots__.
from ducktools.classbuilder import Field, SlotFields, slotclass
@slotclass
class SlottedDC:
__slots__ = SlotFields(
the_answer=42,
the_question=Field(
default="What do you get if you multiply six by nine?",
doc="Life, the Universe, and Everything",
),
)
ex = SlottedDC()
print(ex)The core of the module provides tools for creating a customized version of the dataclass concept.
MethodMaker- This tool takes a function that generates source code and converts it into a descriptor that will execute the source code and attach the gemerated method to a class on demand.
Field- This defines a basic dataclass-like field with some basic arguments
- This class itself is a dataclass-like of sorts
- Additional arguments can be added by subclassing and using annotations
- See
ducktools.classbuilder.prefab.Attributefor an example of this
- See
- Gatherers
- These collect field information and return both the gathered fields and any modifications that will need to be made to the class when built to support them.
builder- This is the main tool used for constructing decorators and base classes to provide generated methods.
- Other than the required changes to a class for
__slots__that are done bySlotMakerMetathis is where all class mutations should be applied.
SlotMakerMeta- When given a gatherer, this metaclass will create
__slots__automatically.
- When given a gatherer, this metaclass will create
Tip
For more information on using these tools to create your own implementations using the builder see the tutorial for a full tutorial and extension_examples for other customizations.
This prebuilt implementation is available from the ducktools.classbuilder.prefab submodule.
This includes more customization including __prefab_pre_init__ and __prefab_post_init__
functions for subclass customization.
A @prefab decorator and Prefab base class are provided.
Prefab will generate __slots__ by default.
decorated classes with @prefab that do not declare fields using __slots__
will not be slotted and there is no slots argument to apply this.
Here is an example of applying a conversion in __post_init__:
from pathlib import Path
from ducktools.classbuilder.prefab import Prefab
class AppDetails(Prefab, frozen=True):
app_name: str
app_path: Path
def __prefab_post_init__(self, app_path: str | Path):
# frozen in `Prefab` is implemented as a 'set-once' __setattr__ function.
# So we do not need to use `object.__setattr__` here
self.app_path = Path(app_path)
steam = AppDetails(
"Steam",
r"C:\Program Files (x86)\Steam\steam.exe"
)
print(steam)Prefab and @prefab support many standard dataclass features along with
a few extras.
- All standard methods are generated on-demand
- This makes the construction of classes much faster in general
- Generation is done and then cached on first access
- Standard
__init__,__eq__and__repr__methods are generated by default- The
__repr__implementation does not automatically protect against recursion, but there is arecursive_reprargument that will do so if needed
- The
repr,eqandkw_onlyarguments work as they do indataclasses- There is an optional
iterargument that will make the class iterable __prefab_post_init__will take any field name as an argument and can be used to write a 'partial'__init__function for only non-standard attributes- The
frozenargument will make the dataclass a 'write once' object- This is to make the partial
__prefab_post_init__function more natural to write for frozen classes
- This is to make the partial
dict_method=Truewill generate anas_dictmethod that gives a dictionary of attributes that haveserialize=True(the default)ignore_annotationscan be used to only use the presence ofattributevalues to decide how the class is constructed- This is intended for cases where evaluating the annotations may trigger imports which could be slow and unnecessary for the function of class generation
replace=Falsecan be used to avoid defining the__replace__methodattributehas additional options over dataclasses'Fielditer=Truewill include the attribute in the iterable if__iter__is generatedserialize=Truedecides if the attribute is include inas_dictexclude_fieldis short forrepr=False,compare=False,iter=False,serialize=Falseprivateis short forexclude_field=Trueandinit=Falseand requires a default/factorydocwill add this string as the value in slotted classes, which appears inhelp()
build_prefabcan be used to dynamically create classes and does support a slots argument- Unlike dataclasses, this does not create the class twice in order to provide slots
There are also some intentionally missing features:
- The
@prefabdecorator does not and will not support aslotsargument- Use
Prefabfor slots.
- Use
as_dictand the generated.as_dictmethod do not recurse or deep copyunsafe_hashis not providedweakref_slotis not available as an argument__weakref__can be added to slots by declaring it as if it were an attribute
- There is no check for mutable defaults
- You should still use
default_factoryas you would for dataclasses, not doing so is still incorrect dataclassesuses 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
- You should still use
- In Python 3.14 Annotations are gathered as
VALUEif possible andSTRINGif this failsVALUEannotations 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
If you want to use __slots__ in order to save memory you have to declare
them when the class is originally created as you can't add them later.
When you use @dataclass(slots=True)1 with dataclasses, the function
has to make a new class and attempt to copy over everything from the original.
This is because decorators operate on classes after they have been created
while slots need to be declared beforehand.
While you can change the value of __slots__ after a class has been created,
this will have no effect on the internal structure of the class.
By using a metaclass or by declaring fields using __slots__ however,
the fields can be set before the class is constructed, so the class
will work correctly without needing to be rebuilt.
For example these two classes would be roughly equivalent, except that
@dataclass has had to recreate the class from scratch while Prefab
has created __slots__ and added the methods on to the original class.
This means that any references stored to the original class before
@dataclass has rebuilt the class will not be pointing towards the
correct class.
Here's a demonstration of the issue using a registry for serialization functions.
This example requires Python 3.10 or later as earlier versions of
dataclassesdid not support theslotsargument.
import json
from dataclasses import dataclass
from ducktools.classbuilder.prefab import Prefab, attribute
class _RegisterDescriptor:
def __init__(self, func, registry):
self.func = func
self.registry = registry
def __set_name__(self, owner, name):
self.registry.register(owner, self.func)
setattr(owner, name, self.func)
class SerializeRegister:
def __init__(self):
self.serializers = {}
def register(self, cls, func):
self.serializers[cls] = func
def register_method(self, method):
return _RegisterDescriptor(method, self)
def default(self, o):
try:
return self.serializers[type(o)](o)
except KeyError:
raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable")
register = SerializeRegister()
@dataclass(slots=True)
class DataCoords:
x: float = 0.0
y: float = 0.0
@register.register_method
def to_json(self):
return {"x": self.x, "y": self.y}
# slots=True is the default for Prefab
class BuilderCoords(Prefab):
x: float = 0.0
y: float = attribute(default=0.0, doc="y coordinate")
@register.register_method
def to_json(self):
return {"x": self.x, "y": self.y}
# In both cases __slots__ have been defined
print(f"{DataCoords.__slots__ = }")
print(f"{BuilderCoords.__slots__ = }\n")
data_ex = DataCoords()
builder_ex = BuilderCoords()
objs = [data_ex, builder_ex]
print(data_ex)
print(builder_ex)
print()
# Demonstrate you can not set values not defined in slots
for obj in objs:
try:
obj.z = 1.0
except AttributeError as e:
print(e)
print()
print("Attempt to serialize:")
for obj in objs:
try:
print(f"{type(obj).__name__}: {json.dumps(obj, default=register.default)}")
except TypeError as e:
print(f"{type(obj).__name__}: {e!r}")Output (Python 3.12):
DataCoords.__slots__ = ('x', 'y')
BuilderCoords.__slots__ = {'x': None, 'y': 'y coordinate'}
DataCoords(x=0.0, y=0.0)
BuilderCoords(x=0.0, y=0.0)
'DataCoords' object has no attribute 'z'
'BuilderCoords' object has no attribute 'z'
Attempt to serialize:
DataCoords: TypeError('Object of type DataCoords is not JSON serializable')
BuilderCoords: {"x": 0.0, "y": 0.0}
No. Not unless it's something I need or find interesting.
The original version of prefab_classes was intended to have every feature
anybody could possibly require, but this is no longer the case with this
rebuilt version.
I will fix bugs (assuming they're not actually intended behaviour).
However the whole goal of this module is if you want to have a class generator with a specific feature, you can create or add it yourself.
Heavily inspired by David Beazley's Cluegen
Footnotes
-
or
@attrs.define. ↩