co3/co3/co3.py

275 lines
11 KiB
Python

'''
CO3 is an abstract base class for scaffolding object hierarchies and managing operations
with associated database schemas. It facilitates something like a "lightweight ORM" for
classes/tables/states with fixed transformations of interest. The canonical use case is
managing hierarchical document relations, format conversions, and syntactical components.
Generic collation syntax:
.. code-block:: python
class Type(CO3):
@collate
def group(self, key):
# disambiguate key
...
@collate('key', groups=['group1', 'group2'])
def key(self):
# key-specific logic
...
'''
import inspect
import logging
from collections import defaultdict
from functools import wraps, partial
logger = logging.getLogger(__name__)
def collate(key, groups=None):
'''
Collation decorator for CO3 subtype action registry.
Dynamic decorator; can be used as ``collate`` without any arguments, or with all. In
the former case, ``key`` will be a function, so we check for this.
.. admonition:: Usage
Collation registration is the process of exposing various actions for use in
**hierarchical collection** (see ``Mapper.collect``). Collation *keys* are unique
identifiers of a particular action that emits data. Keys can belong to an arbitrary
number of *groups*, which serve as semantically meaningful collections of similar
actions. Group assignment also determines the associated *collation component*
to be used as a storage target; the results of actions $K_G$ belonging to group
$G$ will all be stored in the attached $G$-component. Specification of key-group
relations can be done in a few ways:
- Explicit key-group specification: a specific key and associated groups can be
provided as arguments to the decorator:
.. code-block:: python
@collate('key', groups=['group1', 'group2'])
def _key(self):
# key-specific logic
...
The registry dictionaries will then have the following items:
.. code-block:: python
key_registry = {
...,
'key': (_key, ['group1', 'group2']),
...
}
group_registry = {
...,
'group1': [..., 'key', ...],
'group2': [..., 'key', ...],
...
}
If ``groups`` is left unspecified, the key will be attached to the default
``None`` group.
- Implicit key-group association: in some cases, you may want to support an entire
"action class," and associate any operations under the class to the same storage
component. Here we still use the notion of connecting groups to components, but
allow the key to be dynamically specified and passed through to the collation
method:
.. code-block:: python
@collate
def group(self, key):
# disambiguate key
...
and in the registries:
.. code-block:: python
key_registry = {
...,
None: {..., 'group': group, ...},
...
}
group_registry = {
...,
'group': [..., None, ...],
...
}
A few important notes:
- Implicit key-group specifications attach the *group* to a single method,
whereas in the explicit case, groups can be affiliated with many keys. When
explicitly provided, only those exact key values are supported. But in the
implicit case, *any* key is allowed; the group still remains a proxy for the
entire action class, but without needing to map from specifically stored key
values. That is, the utility of the group remains consistent across implicit
and explicit cases, but stores the associations differently.
- The ``None`` key, rather than point to a ``(<method>, <group-list>)`` tuple,
instead points to a dictionary of ``group``-``method`` pairs. When attempting
execute a key under a particular group, the group registry indicates
whether the key is explicitly supported. If ``None`` is present for the group,
then ``key_registry[None][<group-name>]`` can be used to recover the method
implicitly affiliated with the key (along with any other key under the group).
- When any method has been implicitly registered, *any* key (even when
attempting to specify an explicit key) will match that group. This can
effectively mean keys are not unique when an implicit group has been
registered. There is a protection in place here, however; in methods like
``CO3.collate`` and ``Mapper.collect``, an implicit group must be directly
named in order for a given key to be considered. That is, when attempting
collation outside specific group context, provided keys will only be
considered against explicitly registered keys.
'''
func = None
if inspect.isfunction(key):
func = key
key = None
groups = [func.__name__]
if groups is None:
groups = [None]
def decorator(f):
f._collation_data = (key, groups)
return f
if func is not None:
return decorator(func)
return decorator
class FormatRegistryMeta(type):
'''
Metaclass handling collation registry at the class level.
'''
def __new__(cls, name, bases, attrs):
key_registry = defaultdict(dict)
group_registry = defaultdict(set)
def register_action(method):
nonlocal key_registry, group_registry
if hasattr(method, '_collation_data'):
key, groups = method._collation_data
for group in groups:
key_registry[key][group] = method
group_registry[group].add(key)
# add registered superclass methods; iterate over bases (usually just one), then
# that base's chain down (reversed), then methods from each subclass
for base in bases:
for _class in reversed(base.mro()):
methods = inspect.getmembers(_class, predicate=inspect.isfunction)
for _, method in methods:
register_action(method)
# add final registered formats for the current class, overwriting any found in
# superclass chain
for attr_name, attr_value in attrs.items():
register_action(attr_value)
attrs['key_registry'] = key_registry
attrs['group_registry'] = group_registry
return super().__new__(cls, name, bases, attrs)
class CO3(metaclass=FormatRegistryMeta):
'''
Conversion & DB insertion base class
CO3: COllate, COllect, COmpose
- Collate: organize and transform conversion outputs, possibly across class components
- Collect: gather core attributes, conversion data, and subcomponents for DB insertion
- Compose: construct object-associated DB table references through the class hierarchy
.. admonition:: on action groups
Group keys are simply named collections to make it easy for storage components to
be attached to action subsets. They do _not_ augment the action registration
namespace, meaning the action key should still be unique; the group key is purely
auxiliary.
Action methods can also be attached to several groups, in case there is
overlapping utility within or across schemas or storage media. In this case, it
becomes particularly critical to ensure registered ``collate`` methods really are
just "gathering results" from possibly heavy-duty operations, rather than
performing them when called, so as to reduce wasted computation.
'''
@property
def attributes(self):
'''
Method to define how a subtype's inserts should be handled under ``collect`` for
canonical attributes, i.e., inserts to the type's table.
'''
return vars(self)
@property
def components(self):
'''
Method to define how a subtype's inserts should be handled under ``collect`` for
constituent components that need handling.
'''
return []
def collation_attributes(self, key, group):
'''
Return "connective" collation component data, possibly dependent on
instance-specific attributes and the action arguments. This is typically the
auxiliary structure that may be needed to attach to responses from registered
``collate`` calls to complete inserts.
Note: this method is primarily used by ``Mapper.collect()``, and is called just
prior to collector send-off for collation inserts and injected alongside collation
data. Common structure in collation components can make this function easy to
define, independent of action group for instance.
'''
return {}
def collate(self, key, group=None, *args, **kwargs):
'''
Note:
This method is sensitive to group specification. By default, the provided key
will be checked against the default ``None`` group, even if that key is only
attached to non-default groups. Collation actions are unique on key-group
pairs, so more specificity is generally required to correctly execute desired
actions (otherwise, rely more heavily on the default group).
'''
if key is None:
return None
if key not in self.key_registry:
# keys can't match implicit group if that group isn't explicitly provided
if group is None:
logger.debug(
f'Collation for "{key}" not supported, or implicit group not specified'
)
return None
method = self.key_registry[None].get(group)
if method is None:
logger.debug(
f'Collation key "{key}" not registered and group "{group}" not implicit'
)
return None
return method(self, key, *args, **kwargs)
else:
method = self.key_registry[key].get(group)
if method is None:
logger.debug(
f'Collation key "{key}" registered, but group "{group}" is not available'
)
return None
return method(self, *args, **kwargs)