Skip to content

Special Types

Spec classes do not require you to use any special types: you can use standard types from the standard library or any custom type you choose. However, the spec_classes library does come with some useful types that you can use to enrich your spec classes (or indeed any class). These are detailed below, and are all available by importing from spec_classes.types.

Attribute specification

In most cases, you can simply set spec-class attribute defaults (and or lack thereof) directly in the class definition. This attribute will then be "managed", and included in the constructor, equality comparisons, representations, etc. However, in some cases you need to customize this behavior... and that's where field and Attr come in.

field

Spec-classes is compatible with the standard-libraries dataclass field specification, so you can use that directly as the attribute values in spec-classes just as you would in a dataclass. However, in most cases you will want to use Attr below.

Attr

Attr is to spec_class what field is to dataclass, and it offers a superset of the API of field, making it a drop in replacement. The signature of Attr is:

Attr(
    *,
    default: Any = MISSING,
    default_factory: Callable[[], Any] = MISSING,
    init: bool = True,
    repr: bool = True,
    compare: bool = True,
    hash: Optional[bool] = None,
    metadata: Optional[Any] = None,
    desc: Optional[str] = None,
    do_not_copy: bool = False,
    invalidated_by: Optional[Iterable[str]] = None,
)

The default value for the attribute is set by either default or default_factory, and init, repr, compare and hash control whether the attribute is considered by __init__, __repr__, __eq__ and __hash__ respectively. Metadata is an arbitrary object that is not looked at by spec-classes. desc is a docstring for the attribute. do_not_copy is an instruction to spec-classes not to copy the attribute, and so when the parent spec-class is copied the new spec-class instance will have a reference to the same attribute. invalidated_by is an instruction to spec-classes to reset this attribute whenever the attributes in this list are mutated.

Note

Hashing is not yet implemented by spec-classes, but is provided here for API compatibility. Ordering of spec-classes is also not yet implemented.

For more information, refer to help(Attr).

Validated Types

Spec-classes type-checks all attributes by default. Sometimes, however, simple type-checks are insufficient to guarantee a valid value. For example, the age field might require a non-negative integer, and so type-checking against int would not be sufficient. To help with these cases, spec_classes offers several validation types (all of which are implemented in spec_classes.types.validated). These are detailed below.

ValidatedType

This is the base class that powers all validated type checking offered by spec_classes. It translates isinstance(obj, ValidatedType) into ValidatedType.validate(obj), allowing for arbitrary validation logic. To leverage this you can either subclass this class, or use the validated wrapper below.

An example subclass of ValidatedType might look something like:

from abc import classmethod
from spec_classes.types import ValidatedType

class PositiveInt(ValidatedType):

    @classmethod
    def validate(self, obj):
        return isinstance(obj, int) and obj > 0

This object could then be used just like any other type annotation:

@spec_class
class MySpec:
    value: PositiveInt

validated

validated is a wrapper around ValidatedType that bypasses the need to manually subclass. Instead, you can just call this function to generate a type and use it inline or via variable. This signature of this function is:

def validated(
    validator: Callable[[Any], bool], name: str = "validated"
): ...

An example use-case is:

@spec_class
class MySpec:
    proper_noun: validated(lambda x: x[0] == x[0].upper(), name='ProperNoun')

MySpec(proper_noun="hi")  # TypeError: Attempt to set `MySpec.proper_noun` with an invalid type [got `'hi'`; expecting `ProperNoun`].

bounded

bounded is a pre-rolled special case of validated which allows you to put upper and lower bounds on numerical types. The signature of bounded is:

def bounded(
    numeric_type: Type,
    *,
    ge: numbers.Number = None,
    gt: numbers.Number = None,
    le: numbers.Number = None,
    lt: numbers.Number = None,
): ...

and you can refer to help(bounded) for more details.

An example use-case might be:

@spec_class
class Person:
    age: bounded(int, ge=0)

Person(age=-1)  # TypeError: Attempt to set `Person.age` with an invalid type [got `-1`; expecting `int∊[0,∞)`].

Keyed Containers

Spec classes are able to optionally set a key attribute that is intended to uniquely identify instances within some context. It is useful to be able to use these keys to lookup items when they are stored in collections without having to iterate over all items in the collection to find them. For this purpose, spec_classes offers two special collection types (implemented in spec_classes.types.keyed): KeyedList and KeyedSet that act respectively like standard lists and sets, but also allow looking up and mutating elements by key. These are described in more detail below.

Key lookups

The Keyed* containers in spec_classes both subclass from KeyedBase, which implements the default key lookup strategy (among several other things). The default key lookup strategy is:

  1. Check to see if item type is a spec-class, and if so if it has a key attribute. If so, that is the key.
  2. Attempt to hash the object. If the item is hashable, the object itself is the key.

This can be overridden as detailed below.

KeyedList

KeyedList is a generic container that behave exactly like a list, but also layers on the ability to look up items by key. The computational complexity for list-like operations is the same as the base list class, with additional dict-like operations with complexity as follows:

  • O(1) lookups by key
  • O(n) replacement by key
  • O(n) deletes by key

Under the hood these functionalities are achieved by storing both a list and a dict representation of the items stored in the container, and adding dict-like methods .keys(), .items(), .get() and a special .index_for_key() method that finds the list index for a nominated key.

The constructor to KeyedList takes two optional arguments: sequence which takes a sequence of values to pre-fill the list, and key a method that takes an item and returns a key (used to override the default key lookup algorithm). You can also explicitly indicate the types that the container will store using regular python typing notation; that is: KeyedList[<item type>, <key type>].

When an object with the same key already exists in side a KeyedList a ValueError is raised.

Examples:

l = KeyedList(['a', 'b', 'c'])
l[0]  # 'a'
l['a']  # 'a' ('a' is hashable, and so is its own key)
set(l.keys())  # {'a', 'b', 'c'}
l.append('a')  # ValueError: Item with key `'a'` already in `KeyedList`.

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

l = KeyedList[KeyedSpec, str]()
l.append(KeyedSpec('object_1'))
str(l)  # KeyedList[KeyedSpec, str]([KeyedSpec(key='object_1')])
l[0]  # KeyedSpec(key='object_1')
l['object_1']  # KeyedSpec(key='object_1')

KeyedSet

KeyedSet behaves almost identically to KeyedList except that order is not preserved, and key collisions are by default acceptable (resulting in existing objects with the same key being overridden). It behaves exactly like a set in that the keys (which are supposed to uniquely identify the objects) are stored by hash, and mapped to the values of the set. Internally this is just using a dictionary for storage.

Warning

Care should be taken when using KeyedSet where the key of the item is of the same type as the item itself. In such cases there is the risk of ambiguity about whether an item being referenced in the collection is being referenced by key or by value. For example, when .discard(<item>) is called, KeyedSet allows item or keys to be passed. Note that if an item is identical to its key there is no ambiguity.

The computational complexity for set-like operations is the same as the base set class, with additional dict-like operations with complexity as follows:

  • O(1) lookups by key
  • O(1) deletes by key

If it is important to you that attempts to override objects with the same key fail unless the objects are equivalent, you can set enforce_item_equivalence to True in the constructor.

Examples:

l = KeyedSet(['a', 'b', 'c'])
'a' in l
l['a']  # 'a' ('a' is hashable, and so is its own key)
set(l.keys())  # {'a', 'b', 'c'}
l.add('a')  # This is fine.

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

l = KeyedSet[KeyedSpec, str](enforce_item_equivalence=True)
l.add(KeyedSpec('object_1'))
str(l)  # KeyedSet[KeyedSpec, str]([KeyedSpec(key='object_1')])
l['object_1']  # KeyedSpec(key='object_1')
l.add(KeyedSpec('object_1', value=10))  # ValueError: Item for `'object_1'` already exists, and is not equal to the incoming item.

Descriptors

The standard Python library offers various implementations of the descriptor protocol, which allows you to modify the way in which attributes are looked up on object instances. One of the most common of these is property. You can continue to use property with spec-classes, but the down-side of property is that it (unless you also manually supply a setter) makes the attribute read-only. Since spec-classes are often used to store configuration it is often useful to be able treat propertys as "default" values, and still override them by default. For this reason we provide spec_property, detailed below, which behaves like a property but has a setter by default, as well as providing support for caching. We also provide AttrProxy which is for even more esoteric cases where you want two attributes to be linked not only for read operations, but also write operations. Both are detailed below, and can be used inside of spec-classes or non-spec-classes alike.

spec_property

spec_property is implemented in spec_classes.types.spec_property, and behaves exactly like property except that it provides a setter by default allowing it be overwritten (and reset using deletion), and provides caching. You can use spec_property as follows:

class MyClass:
    @spec_property
    def value(self):
        return 1

m = MyClass()
m.value  # 1
m.value = 10
m.value  # 10

The behavior of spec_property can also be customized as follows (with defaults as shown):

class MyClass:
    @spec_property(
        overridable=True,  # Whether to allow overriding by default.
        warn_on_override=False,  # Whether to warn the user when the property getter
            # is overridden. Can be a boolean, string, or `Warning` instance.
            # If non-boolean, then it is treated as the message to present to
            # the user using `warnings.warn`.
        cache=False,  # Whether to cache the result after first evaluation.
        invalidated_by=None,  # An iterable of attributes which when mutated
            # invalidate the cache (only supported when used with spec-classes)
        allow_attribute_error=True,  # Whether to allow properties to raise
            # `AttributeErrors` which are often masked during attribute lookup.
    )
    def method(self):
        ...

    # And of course you can override the setters as you would with regular properties
    @method.setter
    def method(self, value):
        ...

As always you can refer to the inline help(spec_property) for more details.

classproperty

classproperty is implemented in spec_classes.types.classproperty, and behaves exactly like spec_property except that it acts on classmethods, and does not offer inbuilt access to spec-class state. You can use classproperty as follows:

class MyClass:
    @classproperty(
        overridable=False,  # Whether to allow overriding by default.
        warn_on_override=False,  # Whether to warn the user when the property getter
            # is overridden. Can be a boolean, string, or `Warning` instance.
            # If non-boolean, then it is treated as the message to present to
            # the user using `warnings.warn`.
        cache=False,  # Whether to cache the result after first evaluation.
        cache_per_subclass=False,  # Whether cache should be stored per subclass
            # rather than once for all classes.
        allow_attribute_error=True,  # Whether to allow properties to raise
            # `AttributeErrors` which are often masked during attribute lookup.
    )
    def method(cls):
        ...

    # And of course you can override the setters as you would with regular properties
    @method.setter
    def method(cls, value):
        ...

AttrProxy

AttrProxy is implemented in spec_classes.types.attr_proxy, and allows one attribute to proxy another attribute. This is especially useful if you have changed the name of an attribute and need to provide backwards compatibility for an indefinite period; or if one attribute is supposed to mirror another attribute unless overwritten (e.g. the label of a class might be the "key" unless overwritten). This functionality could obviously be implemented directly using property, which may be advisable if readability is more important than concision.

You can construct an AttrProxy instance using:

AttrProxy(
    attr="<attribute to proxy on same instance>",  # Required!
    transform=lambda x: x**2,  # Optional transform on attribute value.
    passthrough=False,  # Defaults to `False`, whether to pass on mutations back through to the proxied attribute
    fallback=MISSING,  # Defaults to `MISSING`; the value to return if the proxied attribute is not set.
)

Example:

@spec_class(key="key")
class MyObject:
    key: str
    label: str = AttrProxy("key", transform=lambda key: key.replace('_', ' ').title())

m = MyObject("object_1")
m.label  # 'Object 1'
m.label = "My Object"
m.label  # 'My Object'

Sentinels

It is often useful to have at least one value reserved for use as a sentinel. Depending on the context they can be interpreted as an failure state or trigger some fallback behavior. In spec_classes, we use MISSING.

MISSING

In spec_classes, spec_classes.MISSING is the sentinel used to indicate that an attribute or argument value has not (yet) been provided. If you are using spec-classes in your project, you can feel free to use this sentinel also.

MISSING is implemented in spec_classes.types.missing, and is an instance of the singleton class _MissingType (every instantiation will return the same instance). MISSING is falsey (bool(MISSING) is False), but otherwise cannot be compared to anything else. Its string representation is 'MISSING'.

Back to top