Skip to content

Advanced Usage

Post init or copy hooks

If you want to run some code immediately after instantiation or after you spec-class is (deep-)copied, you can implement the __post_init__() and __post_copy()__ methods respectively.

@spec_class
class MySpec:

    def __post_init__(self):
        self.__copy_count = 0

    def __post_copy__(self):
        self.__copy_count += 1

Typecasting/preparation

While preparation of attribute values can still be done using custom constructors or property overrides, spec-classes provides a simpler mechanism for typecasting/preparing attribute values (as shown in one of the examples). The Attr object has two optional attributes: prepare and prepare_item. Both of these should be Callable objects if provided, and are respectively used to prepare the attribute value and items within an attribute collection. When both are present, prepare is called first. There are two ways to populate these attributes: by providing a _prepare_<attr> and/or a _prepare_<attr_singular> method; or by setting the attribute to an Attr instance and using the Attr.preparer and Attr.item_preparer decorators. Both are demonstrated in the aforementioned example.

Init overflow attributes

If you want your spec-class to accept arbitrary arguments in its constructor, including those that are not registered attributes, you can pass an arbitrary attribute name to init_overflow_attr in the spec_class decorator. During instance construction, spec-classes will then collect all additional keyword arguments and place them as a dictionary in nominated attribute.

@spec_class(init_overflow_attr='kwargs')
class Spec:
    pass

Spec(a=1, b=2)
# Spec(kwargs={'a': 1, 'b': 2})

Frozen spec classes

By default, instances of spec-classes behave much like any other instance in that you can mutate attributes in-place. If you would like to prevent in-place mutation, you can use the frozen keyword argument to the constructor. For example:

@spec_class(frozen=True)
class MyClass:
    my_str: str

MyClass().my_str = "hi"  # FrozenInstanceError: Cannot mutate attribute `my_str` of frozen spec class `MySpec`.

Note

Frozen spec class instances can still be updated using the copy-on-write helper methods (introduced below):

MySpec().with_my_str("hi")  # MyClass(my_str="hi")

Avoiding copies of large attributes

Spec-classes adopt a copy-on-write approach when mutating classes via the helper methods (e.g. .with_<attr>()). In some instances, however, that is undesirable, for example when one or more attributes consumes a lot of memory. To help with this, spec-classes allows entire spec-classes and/or attributes thereof to opt-out of being copied. When decorating a spec-class you can pass do_not_copy=True to spec_class to disable all copying (effectively making all mutations in-place), or pass do_not_copy=['attributes', 'to', 'avoid', 'copying'], which will populate the Attr attribute do_not_copy for the nominated attributes, and pass these attributes by reference (rather than value) when copying spec-classes. You can also directly specify attributes to using Attr, as documented here.

@spec_class(do_not_copy=['data'])
class DataAnalyzer:
    data: Any # Potentially LARGE data object
    another_obj: Any = Attr(do_not_copy=True)

Immediate bootstrapping

Spec-classes is typically lazy in its "bootstrapping" of classes (it doesn't actually mutate the class straight away with all of the helper methods). This is because it is often the case that type-annotated code becomes cyclic very quickly, and since spec-classes needs type information during the generation of methods, immediately bootstrapping would cause cyclic import issues.

Instead, spec-classes adds a __new__ method and a placeholder __spec_class__ metadata, and lazily bootstraps until the first instantiation or the first lookup of the __spec_class__ attribute. In the vast majority of cases, this works well, but it is possible that more advanced class introspections require the class to be bootstrapped immediately. You can achieve this by passing bootstrap=True to the spec_class decorator.

@spec_class(bootstrap=True)
class Spec:
    ...

Avoiding cyclic import issues

As mentioned above, cyclic import issues are common in type-annotated code, especially if the type annotations are required at run-time (as is the case for spec-classes). Sometimes there is just no way to import the types necessary at a module level without having all classes in a single file. To avoid this, spec-classes allows you to lazily import types that are used to annotate attributes by importing them in a method that is only evaluated immediately before the types are used. You can also use this mechanism to alias types.

from __future__ import annotations

@spec_class
class Spec:

    data: DataFrame
    value: my_alias

    @classmethod
    def ANNOTATION_TYPES(cls):
        import pandas
        return {
            'DataFrame': pandas.DataFrame,
            'my_alias': str,
        }

Overriding dunder methods

The dunder methods that spec-classes introduces are: __init__, __repr__, __eq__, __getattr__, __setattr__, __delattr__ and __deepcopy__. Overriding __getattr__, __setattr__, __delattr__ and __deepcopy__ is not supported (they are essential for the behavior of spec-classes), and you will be responsible for making things work properly if you do. __init__, __repr__ and __eq__ can be overridden safely, however.

In the rare circumstances that it becomes necessary for you to overwrite these methods, you can just implement these on the class. Since spec-classes never overwrites methods that a user has written, these methods will not be overridden by spec-classes. If you want to completely remove these methods, you can pass init=False, repr=False and/or eq=False to the spec_class decorator. For your convenience, spec-classes will always register spec-classes' implementation of these methods as __spec_class_init__, __spec_class_repr__ and __spec_class_eq__ so that you can leverage them in your overrides (this is necessary because spec-classes does not touch your classes' MRO, and so no super() calls to spec-classes' methods are possible).

Subclassing

Spec-classes fully supports subclassing, including the honoring of overridden constructors in super-classes. Spec-classes remembers where spec-class attributes were defined, and calls the appropriate constructor to initialize them. For example:

@spec_class
class Base:
    x: int
    y: int

    def __init__(self, x=10, y=10):
        self.x = x + 1
        self.y = y + 1
        self.a = x * y

@spec_class
class Sub(Base):
    x = 100 # Sub provides a new default value but is not the owner of `x`
    y: int = 100  # Sub becomes the owner of `y`
    z: int = 300

Sub()
# Sub(x=101, y=100, z=300)
Sub().a
# 1000  # (x = 100) * (y = 10), since parent constructors are only passed
        # attributes they own.

Back to top