7.9 KiB
Class registry
Background
I initially developed the class registry for stdqs, a Python package
aiming to add Django QuerySet-like features to any Python iterable.
Django has a notion of Transforms and Lookups, classes
which allow to transform data (using for example an SQL function like
LOWER()
) perform boolean operations on them, respectively.
One may create custom transforms and lookups on a given field using the
@TheField.register_lookup
decorator. See the Django
docs to learn more.
My issue with this system is that it requires registering into a Field, which is a notion that does not exist inside stdqs as I do not need to handle persistence or really know much about the type of an attribute myself; dealing with types is left to the users as most of the standard lookups and transforms in stdqs will simply apply standard Python operators and methods.
Simply using a dict
linking classes to lookup/transform
names would require using global
variables, which is a
pattern I really wanted to avoid, so using a module-level decorator
alone would not be enough. After some thinking, I had build a
stdqs.lookups.LookupRegistry
class, which included a
register
method which could be used as a decorator on
classes. I wanted to cleanly separate lookups and transforms, so I also
made a stdqs.transforms.TransformRegistry
, which was way
too similar to the first registry to not make them use a common class,
stdqs.helpers.ClassRegistry
. Both registries included ways
to perform various checks on keys and values of the dictionary, for
example to ensure that a lookup has a valid name so it can be used
inside of stdqs QuerySets.
Since both lookups and transforms in my project had abstract base classes, I thought it would be possible to use a custom metaclass to allow for automatic registration simply by subclassing. I had done something similar in a very complex way using a metaclass in PseudoScience for the unit system a while ago.
This, and other common patterns explained elsewhere, led me to create this Python library.
Usage
The ClassRegistry <objtools.registry.ClassRegistry>
provides a generic answer to the problem of automatic registration of
classes with names in a way that allows easy extension by third parties:
merely importing a module which uses the registry's metaclass will
auto-register everything in this module. The registry itself subclasses
dict
, therefore has all of its usual methods.
The most basic usage of the registry is as follows:
>>> from objtools.registry import ClassRegistry
>>> registry = ClassRegistry()
>>> class RegisteredClass(metaclass=registry.metaclass): ...
>>> dict(registry)
'RegisteredClass': __main__.RegisteredClass} {
The metaclass <objtools.registry.ClassRegistry.metaclass>
property provides a metaclass which performs auto-registration on the
current registry instance. Two keyword arguments may optionally be sent
to this metaclass:
- register (bool)
-
When True, will perform auto-registration. Defaults to True.
- key (str or None)
-
If defined, will override the default name when registering. If left unspecified, will use the class'
__name__
.
>>> class NotRegisteredClass(
=registry.metaclass,
... metaclass=False): ...
... register>>> class RenamedClass(metaclass=registry.metaclass, key='foo'): ...
>>> dict(registry)
'RegisteredClass': __main__.RegisteredClass, 'foo': __main__.RenamedClass} {
Manual registration and unregistration can also be performed using
the register <objtools.registry.ClassRegistry.register>
and unregister <objtools.registry.ClassRegistry.unregister>
methods. These are aliases to the standard __setitem__
and
__delitem__
dict methods.
Custom checks
The default implementation allows registering any class with any
name, but provides overridable methods to perform custom checks upon
registration: check_key <objtools.registry.ClassRegistry.check_key>
and check_value <objtools.registry.ClassRegistry.check_value>
.
Any exception raised in those methods will be turned into KeyError
or ValueError
, respectively.
This allows, for example, to check for specific attributes, or
subclassing an abstract class
>>> class MyClass(object): ...
>>> class CustomRegistry(ClassRegistry):
def check_key(self, key):
... assert key.lower(), 'Key must be lowercase'
...
...def check_value(self, value):
... assert issubclass(value, MyClass), 'Must subclass MyClass'
...
...>>> registry = CustomRegistry()
>>> class UppercasedKeyClass(metaclass=registry.metaclass, key='FOO'): ...
Traceback (most recent call last):
...KeyError: Key must be lowercase
>>> class NotMyClass(metaclass=registry.metaclass): ...
Traceback (most recent call last):
...ValueError: Must subclass MyClass
Cleaner code
I suggest using the following pattern to make using auto-registration cleaner in other parts of your code:
from objtools.registry import ClassRegistry
class CustomRegistry(ClassRegistry):
pass
= CustomRegistry()
registry = registry.register
register = registry.unregister
unregister
class BaseClass(metaclass=registry.metaclass, register=False):
pass
This will then allow other modules to import
yourpkg.yourmodule.BaseClass
and use it in a perfectly
normal way, making users who do not have to use the registry directly
not have to deal with anything relating to it. Note that you may need to
explain in a documentation of some sort the register
and
key
keyword arguments.
Working with ABCs or another metaclass
Python's abc
module uses the abc.ABCMeta
metaclass to perform checks on subclasses and prevent creating instances
of subclasses which do not implement all abstract methods or properties,
and has an abc.ABC
class which does not do much more than
having ABCMeta
as its metaclass.
Using the above suggested pattern, a class which uses the metaclass but also needs to be an abstract base class, combined with a custom check on the registry itself to ensure all registered classes subclass it, will help ensure all registered classes provde a common set of methods and properties.
from abc import ABC
from objtools.registry import ClassRegistry
class CustomRegistry(ClassRegistry):
# ... custom checks etc. ...
pass
= CustomRegistry()
registry
class BaseClass(ABC, metaclass=registry.metaclass, register=False):
pass
However, since ABC
uses a custom metaclass itself,
creating such an abstract class will result in a cryptic exception:
TypeError: metaclass conflict: the metaclass of a derived class must be
a (non-strict) subclass of the metaclasses of all its bases
The root cause of this issue is that ClassRegistry.metaclass <objtools.registry.ClassRegistry.metaclass>
does not inherit from abc.ABCMeta
. One could override this
property to provide a custom metaclass which inherits from it, but
having to rewrite the whole metaclass property — which is probably the
strangest code of the ClassRegistry <objtools.registry.ClassRegistry>
— is not easy or future-proof. Instead, creating a class which
subclasses both metaclasses solves this issue easily:
from abc import ABCMeta
# Registry code here
class BaseClassMetaclass(registry.metaclass, ABCMeta):
# Note that we do not use metaclass=... here
pass
class BaseClass(metaclass=BaseClassMetaclass, register=False):
pass
Reference
objtools.registry