Prefab - A prebuilt classbuilder implementation

This is a more full featured dataclass replacement with some different design decisions and features.

Including:

  • Declaration by type hints, slots or attribute(...) assignment on the class

  • __prefab_pre_init__ and __prefab_post_init__ detection to allow for validation/conversion

  • Optional as_dict method generation to convert to a dictionary

Usage

Define the class using plain assignment and attribute function calls:

from ducktools.classbuilder.prefab import prefab, attribute

@prefab
class Settings:
    hostname = attribute(default="localhost")
    template_folder = attribute(default='base/path')
    template_name = attribute(default='index')

Or with type hints:

from ducktools.classbuilder.prefab import prefab

@prefab
class Settings:
    hostname: str = "localhost"
    template_folder: str = 'base/path'
    template_name: str = 'index'

In either case the result behaves the same.

>>> s = Settings()
>>> print(s)
Settings(hostname='localhost', template_folder='base/path', template_name='index')

Slots

Pre-slotted classes can be created by using the Prefab base class

from ducktools.classbuilder.prefab import Prefab


class Settings(Prefab):
    hostname: str = "localhost"
    template_folder: str = 'base/path'
    template_name: str = 'index'

Why not just use attrs or dataclasses?

If attrs or dataclasses solves your problem then you should use them. They are thoroughly tested, well supported packages. This is a new project and has not had the rigorous real world testing of either of those.

This module has been created for situations where startup time is important, such as for CLI tools and for handling conversion of inputs in a way that was more useful to me than attrs converters (__prefab_post_init__).

Pre and Post Init Methods

Alongside the standard method generation @prefab decorated classes have special behaviour if __prefab_pre_init__ or __prefab_post_init__ methods are defined.

For both methods if they have additional arguments with names that match defined attributes, the matching arguments to __init__ will be passed through to the method.

If an argument is passed to __prefab_post_init__it will not be initialized in __init__. It is expected that initialization will occur in the method defined by the user.

Other than this, arguments provided to pre/post init do not modify the behaviour of their corresponding attributes (they will still appear in the other magic methods).

Examples have had repr and eq removed for brevity.

Examples

__prefab_pre_init__

Input code:

from ducktools.classbuilder.prefab import prefab

@prefab(repr=False, eq=False)
class ExampleValidate:
    x: int

    @staticmethod
    def __prefab_pre_init__(x):
        if x <= 0:
            raise ValueError("x must be a positive integer")

Equivalent code:

class ExampleValidate:
    PREFAB_FIELDS = ['x']
    __match_args__ = ('x',)

    def __init__(self, x: int):
        self.__prefab_pre_init__(x=x)
        self.x = x

    @staticmethod
    def __prefab_pre_init__(x):
        if x <= 0:
            raise ValueError('x must be a positive integer')

__prefab_post_init__

Input code:

from ducktools.classbuilder.prefab import prefab, attribute
from pathlib import Path

@prefab(repr=False, eq=False)
class ExampleConvert:
    x: Path = attribute(default='path/to/source')

    def __prefab_post_init__(self, x: Path | str):
        self.x = Path(x)

Equivalent code:

from pathlib import Path
class ExampleConvert:
    PREFAB_FIELDS = ['x']
    __match_args__ = ('x',)

    x: Path

    def __init__(self, x: Path | str = 'path/to/source'):
        self.__prefab_post_init__(x=x)

    def __prefab_post_init__(self, x: Path | str):
        self.x = Path(x)

Differences with dataclasses

While this project doesn’t intend to exactly replicate other similar modules it’s worth noting where they differ in case users get tripped up.

Prefabs don’t behave quite the same (externally) as dataclasses. They are very different internally.

This doesn’t include things that haven’t been implemented, and only focuses on intentional differences. Unintentional differences may be patched or will be added to this list.

Functional differences

  1. the as_dict method in prefab_classes does not behave the same as dataclasses’ asdict.

    • as_dict does not deepcopy the included fields, modification of mutable fields in the dictionary will modify them in the original object.

    • as_dict does not recurse

      • Recursion would require knowing how other objects should be serialized.

      • dataclasses asdict’s recursion appears to be for handling json serialization prefab expects the json serializer to handle recursion.

  2. dataclasses provides a fields function to access the underlying fields.

    • prefab uses a get_attributes function to return the attributes as a dict.

  3. Plain attribute(...) declarations can be used without the use of type hints.

    • If a plain assignment is used, all assignments must use attribute.

  4. Post init processing uses __prefab_post_init__ instead of __post_init__

    • This is just a case of not wanting any confusion between the two.

    • attrs similarly does __attrs_post_init__.

    • __prefab_pre_init__ can also be used to define something to run before the body of __init__.

    • If an attribute name is provided as an argument to either the pre_init or post_init functions the value will be passed through.

  5. Unlike dataclasses, prefab classes will let you use unhashable default values.

    • This isn’t to say that mutable defaults are a good idea in general but prefabs are supposed to behave like regular classes and regular classes let you make this mistake.

    • Usually you should use attribute(default_factory=list) or similar.

  6. If init is False in @prefab(init=False) the method is still generated but renamed to __prefab_init__.

  7. Slots are supported but not from annotations using the decorator @prefab

    • use the Prefab base class if you wish your classes to be automatically slotted.

    • @prefab can be used if the slots are provided with a __slots__ = SlotFields(...) attribute set.

    • The support for slots in attrs and dataclasses involves recreating the class as it is not possible to effectively define __slots__ after class creation. This can cause bugs where decorators or caches hold references to the original class.

  8. InitVar annotations are not supported.

    • So far I haven’t needed this yet so it hasn’t been implemented.

  9. The __repr__ method for prefabs will have a different output if it will not eval correctly.

    • This isn’t a guarantee that the regular __repr__ will eval, but if it is known that the output would not eval then an alternative repr is used which does not look like it would eval.

  10. default_factory functions will be called if None is passed as an argument

    • This makes it easier to wrap the function.

  11. The Prefab base class will automatically create the __dict__ slot if cached_property is used in the class.

    • This means that cached properties will work as expected in slotted classes but that you will also be able to set any attribute in non-frozen classes