plugin.core: Document base classes.

Since so much other code is built on these, I wanted to make sure I
wrote this while it was all fresh in my head.
This commit is contained in:
Brett Smith 2020-03-06 10:37:54 -05:00
parent 6e0c31f0ab
commit c3c19b0ad2

View file

@ -19,10 +19,22 @@ import re
from . import errors as errormod from . import errors as errormod
# I expect these will become configurable in the future, which is why I'm
# keeping them outside of a class, but for now constants will do.
DEFAULT_START_DATE = datetime.date(2020, 3, 1) DEFAULT_START_DATE = datetime.date(2020, 3, 1)
# The default stop date leaves a little room after so it's easy to test
# dates past the far end of the range.
DEFAULT_STOP_DATE = datetime.date(datetime.MAXYEAR, 1, 1) DEFAULT_STOP_DATE = datetime.date(datetime.MAXYEAR, 1, 1)
class _GenericRange: class _GenericRange:
"""Convenience class to check whether a value is within a range.
`foo in generic_range` is equivalent to `start <= foo < stop`.
Since we have multiple user-configurable ranges, having the check
encapsulated in an object helps implement the check consistently, and
makes it easier for subclasses to override.
"""
def __init__(self, start, stop): def __init__(self, start, stop):
self.start = start self.start = start
self.stop = stop self.stop = stop
@ -38,7 +50,26 @@ class _GenericRange:
class MetadataEnum: class MetadataEnum:
"""Map acceptable metadata values to their normalized forms.
When a piece of metadata uses a set of allowed values, use this class to
define them. You can also specify aliases that hooks will normalize to
the primary values.
"""
def __init__(self, key, standard_values, aliases_map): def __init__(self, key, standard_values, aliases_map):
"""Specify allowed values and aliases for this metadata.
Arguments:
* key: The name of the metadata key that uses this enum.
* standard_values: A sequence of strings that enumerate the standard
values for this metadata.
* aliases_map: A mapping of strings to strings. The keys are
additional allowed metadata values. The values are standard values
that each key will evaluate to. The code asserts that all values are
in standard_values.
"""
self.key = key self.key = key
self._stdvalues = frozenset(standard_values) self._stdvalues = frozenset(standard_values)
self._aliases = dict(aliases_map) self._aliases = dict(aliases_map)
@ -49,15 +80,26 @@ class MetadataEnum:
return "{}<{}>".format(type(self).__name__, self.key) return "{}<{}>".format(type(self).__name__, self.key)
def __contains__(self, key): def __contains__(self, key):
"""Returns true if `key` is a standard value or alias."""
return key in self._aliases return key in self._aliases
def __getitem__(self, key): def __getitem__(self, key):
"""Return the standard value for `key`.
Raises KeyError if `key` is not a known value or alias.
"""
return self._aliases[key] return self._aliases[key]
def __iter__(self): def __iter__(self):
"""Iterate over standard values."""
return iter(self._stdvalues) return iter(self._stdvalues)
def get(self, key, default_key=None): def get(self, key, default_key=None):
"""Return self[key], or a default fallback if that doesn't exist.
default_key is another key to look up, *not* a default value to return.
This helps ensure you always get a standard value.
"""
try: try:
return self[key] return self[key]
except KeyError: except KeyError:
@ -68,6 +110,17 @@ class MetadataEnum:
class PostingChecker: class PostingChecker:
"""Base class to normalize posting metadata from an enum."""
# This class provides basic functionality to filter postings, normalize
# metadata values, and set default values.
# Subclasses should set:
# * METADATA_KEY: A string with the name of the metadata key to normalize.
# * ACCOUNTS: Only check postings that match these account names.
# Can be a tuple of account prefix strings, or a regexp.
# * VALUES_ENUM: A MetadataEnum with allowed values and aliases.
# Subclasses may wish to override _default_value and _should_check.
# See below.
HOOK_GROUPS = frozenset(['Posting', 'metadata']) HOOK_GROUPS = frozenset(['Posting', 'metadata'])
ACCOUNTS = ('',) ACCOUNTS = ('',)
TXN_DATE_RANGE = _GenericRange(DEFAULT_START_DATE, DEFAULT_STOP_DATE) TXN_DATE_RANGE = _GenericRange(DEFAULT_START_DATE, DEFAULT_STOP_DATE)
@ -84,9 +137,16 @@ class PostingChecker:
post.meta = {} post.meta = {}
post.meta[key] = value post.meta[key] = value
# If the posting does not specify METADATA_KEY, the hook calls
# _default_value to get a default. This method should either return
# a value string from METADATA_ENUM, or else raise InvalidMetadataError.
# This base implementation does the latter.
def _default_value(self, txn, post): def _default_value(self, txn, post):
raise errormod.InvalidMetadataError(txn, post, self.METADATA_KEY) raise errormod.InvalidMetadataError(txn, post, self.METADATA_KEY)
# The hook calls _should_check on every posting and only checks postings
# when the method returns true. This base method checks the transaction
# date is in TXN_DATE_RANGE, and the posting account name matches ACCOUNTS.
def _should_check(self, txn, post): def _should_check(self, txn, post):
ok = txn.date in self.TXN_DATE_RANGE ok = txn.date in self.TXN_DATE_RANGE
if isinstance(self.ACCOUNTS, tuple): if isinstance(self.ACCOUNTS, tuple):