Skip to content

Basic Usage

The spec_class decorator

The primary entry-point into spec_classes is the spec_class decorator, which takes your standard class and converts it into a "spec-class" (🌟 ooh... shiny! 🌟). In practice, this just means that it adds some dunder magic methods like __init__ and __setattr__, along with a few helper methods... and nothing else. This is intentionally very similar to the standard library's dataclass, and indeed you can largely consider spec-classes to be a generalization of it.

Using spec-classes is as simple as decorating your annotated class with spec_class. For example:

from spec_classes import spec_class

@spec_class
class MySpec:
    my_str: str

The result is a class that:

  • Thoroughly type-checks class attributes whenever and however they are mutated.
  • Has helper methods that assist with the mutation of annotated attributes, allowing one to adopt copy-on-write workflows (see below for more details).
  • Knows how to output a human-friendly representation of the spec class when printed.
  • Knows how to compare itself with other instances of the spec class.

As such, and with a huge amount of simplification, the above spec-class declaration would be roughly similar to writing something like:

import copy
from spec_classes import MISSING

class MySpec:

    def __init__(self, my_str=MISSING)
        if my_str is not MISSING:
            self.my_str = my_str

    def __repr__(self):
        return f"MySpec(my_str={getattr(self, 'my_str', MISSING)}")

    def __eq__(self, other):
        return isinstance(other, MySpec) and getattr(self, 'my_str') == getattr(other, 'my_str')

    def __setattr__(self, attr, value):
        if attr == 'my_str' and not isinstance(my_str, str):
            raise TypeError("`MySpec.my_str` should be a string.")
        super().__setattr__(attr, value)

    def update(self, my_str=MISSING):
        obj = copy.deepcopy(self)
        obj.my_str = my_str
        return obj

    def transform(self, transform=MISSING, *, my_str_transform=MISSING):
        obj = copy.deepcopy(obj)
        obj = transform(self)
        obj.my_str = my_str_transform(obj.my_str)
        return obj

    def with_my_str(self, value):
        obj = copy.deepcopy(self)
        obj.my_str = value
        return obj

    def transform_my_str(self, transform):
        obj = copy.deepcopy(self)
        obj.my_str = transform(self.my_str)
        return obj

    def reset_my_str(self):
        obj = copy.deepcopy(self)
        del obj.my_str
        return obj

The remainder of this documentation is dedicated to exploring exactly which attributes get managed by spec-classes, which methods get generated when, and how it all fits together.

Managed attributes

By default, all annotated attributes in the class decorated with @spec_class are managed by spec-classes. This means that the constructor and representation of the spec class will consider all annotated attributes, and nothing else. For example:

@spec_class
class MySpec:
    my_str: str = "Hello"
    my_int = 1

MySpec(my_str="Hi")  # All good.
MySpec(my_int=2)  # Raises a TypeError (`my_int` is not annotated, and therefore not managed)

You can override, if necessary, the attributes that are considered by spec-classes using the keyword arguments to the @spec_class decorator:

  • attrs: An iterable of strings indicating the names of attributes to be included. If not already annotated on the class, these will be given an annotation of typing.Any.
  • attrs_typed: A mapping from the string name of the attribute to the type of the attribute to use (can override class annotations).
  • attrs_skip: An iterable of attributes names to skip during determination of which fields to manage.

Note that unless attrs_skip is provided, if attrs and/or attrs_typed are provided, then spec-classes will not automatically manage other annotated attributes on the class.

Extending our above example, you could do:

@spec_class(attrs_typed={'my_int': int}, attrs_skip=[])
class MySpec:
    my_str: str = "Hello"
    my_int = 1

MySpec(my_str="Hi")  # All good.
MySpec(my_int=2)  # All good.

Constructor

Using the @spec_class decorator will by default add a constructor to the class (unless one is already defined on the class). You can disable the addition of a constructor by passing init=False to the decorator.

All arguments to the generated constructor must be passed by name (except for the key attribute; see below). Also, instances of spec-classes are permitted to have missing values. If the class does not provide a default value for an attribute, instances will not have the attribute present, and representations of the class will render it as MISSING. For example:

@spec_class
class MySpec:
    my_str: str

MySpec()  # MySpec(my_str=MISSING)
MySpec().my_str  # AttributeError: `MySpec.my_str` has not yet been assigned a value.

Tip

It is always safe to use mutable default values when using the default constructor with your managed attributes. They will be deep-copied in the constructor before being assigned to instances of your class. For example:

@spec_class
class MySpec:
    my_list: ['a']

assert MySpec().my_list is not MySpec.my_list

Keyed Spec Classes

Most attributes on a spec-class are treated identically and without privilege. The one exception to that is an optional key attribute. Semantically, a key is intended to uniquely identify an instance of a spec-class within some context, and if configured must be assigned a value at instantiation time. To indicate that a spec-class should be "keyed", pass the key argument to the spec_class constructor. For example:

@spec_class(key='key')
class KeyedSpec:
    key: str
    value: str

KeyedSpec('my_key')  # KeyedSpec(key='my_key', value=MISSING)
KeyedSpec()  # TypeError: __init__() missing 1 required positional argument: 'key'

Note

The key attribute is the only attribute that does not need to be passed in by name to the constructor. Also: if the class has a default for the key attribute, it will be lifted up as the value in the instance (just like other attributes).

Type checking

All attributes managed by spec-classes are type-checked during initialization and any mutation. Attempts to set attributes to an invalid type will result in a TypeError. For example, from the above MySpec:

MySpec(my_str=1)  # TypeError: Attempt to set `MySpec.my_str` with an invalid type [got `1`; expecting `str`].

Helper methods

To simplify the adoption of copy-on-write workflows, and to make mutation of instances more convenient and chainable, spec_class generates helper methods for the base class and every managed attribute. The number and types of methods added depends on type annotations, but in every case mutations performed by these methods are (by default) done on copies of the original instance, and so can be used safely on instances that are shared between multiple objects. Refer to the Helper Methods documentation for more details.

Back to top