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.