#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Created by pat on 4/4/18
"""
.. currentmodule:: modlit.meta
.. moduleauthor:: Pat Daburu <pat@daburu.net>
This module contains metadata objects to help with inline documentation of the
model.
"""
from abc import ABC
from enum import IntFlag
import re
from functools import reduce
from typing import Any, Type, Union
from typing import cast, Iterable
from orderedset import OrderedSet
from sqlalchemy import Column
COLUMN_META_ATTR = '__meta__' #: the property that contains column metadata
TABLE_META_ATTR = '__meta__' #: the property that contains table metadata
class _MetaDescription(ABC):
"""
This is base class for objects that provide meta-data descriptions.
"""
def __eq__(self, other):
try:
# Compare the values in all the slots.
for slot in self.__slots__:
# If one of the values isn't equal...
if getattr(self, slot) != getattr(other, slot):
# ...the objects aren't equal.
return False
# It looks like everything was the same. Great.
return True
except AttributeError:
# This may happen if the object's aren't of the same type (or
# at least they don't quack alike). If it does, let the parent
# class take over.
return False
def __ne__(self, other):
return not self.__eq__(other)
def __repr__(self):
# We will establish the parameter names by removing the underscore from
# the names we find in the __slots__, but we'll get the values according
# to (of course) the original names we find. This is in accordance
# with a convention shared by the classes in this module that inherit
# from this object to gain common behavior.
params = [
f'{slot}={repr(getattr(self, slot))[1:]}'
for slot in getattr(self, '__slots__')
]
# Now put it all together with the class name to produce a
# pseudo-constructor string.
return f"{self.__class__.__name__}({', '.join(params)})"
class _Synonyms(object):
"""
This is a helper object that keeps track of synonyms for meta-info objects.
"""
__slots__ = ['_synonyms', '_synonyms_re']
def __init__(self, synonyms: Iterable[str] = None):
"""
:param synonyms: the synonyms
"""
self._synonyms: OrderedSet[str] = (
OrderedSet(synonyms) if synonyms is not None
else set()
)
# Create a set of regular-expression objects we can use to determine
# if a given string is a synonym for this source column's name.
self._synonyms_re: OrderedSet = OrderedSet(
[re.compile(s, re.IGNORECASE) for s in self._synonyms]
)
def is_synonym(self, name: str):
"""
Is a given name a synonym for an item in the set?
:param name: the name to test
:return: `True` if the name appears to be a synonym, otherwise `False`
"""
# Evaluate each of the synonym regular expressions.
for synonym_re in self._synonyms_re:
# If we find that this one matches the name...
if synonym_re.match(name):
# ...the name is a synonym.
return True
# If we didn't return before it means we didn't find any matches, so...
return False
def __eq__(self, other):
try:
return self._synonyms == getattr(other, '_synonyms')
except AttributeError:
return False
def __ne__(self, other):
return not self.__eq__(other)
[docs]class Requirement(IntFlag):
"""
This enumeration describes contracts with source data providers.
"""
NONE = 0 #: data for the column is neither requested nor required
REQUESTED = 1 #: data for the column is requested
REQUIRED = 3 #: data for the column is required
[docs]class Source(_MetaDescription):
"""
'Source' information defines contracts with data providers.
"""
__slots__ = ['_requirement', '_synonyms']
def __init__(self,
requirement: Requirement = Requirement.NONE,
synonyms: Iterable[str] = None):
"""
:param requirement: the source contract
:param synonyms: name patterns that may indicate
"""
self._requirement: Requirement = requirement
self._synonyms = _Synonyms(synonyms)
@property
def requirement(self) -> Requirement:
"""
Get the source data contract.
:return: the source data contract
"""
return self._requirement
[docs] def is_synonym(self, name: str):
"""
Is a given name a synonym for this source column?
:param name: the name to test
:return: `True` if the name appears to be a synonym, otherwise `False`
"""
return self._synonyms.is_synonym(name)
[docs]class Usage(IntFlag):
"""
This enumeration describes how data may be used.
"""
NONE = 0 #: The data is not used.
SEARCH = 1 #: The data is used for searching.
DISPLAY = 2 #: The data is displayed to users.
[docs]class Target(_MetaDescription):
"""
'Target' information describes contracts with data consumers.
"""
__slots__ = ['_guaranteed', '_calculated', '_usage']
def __init__(self,
guaranteed: bool = False,
calculated: bool = False,
usage: Usage or Iterable[Usage] = Usage.NONE):
self._guaranteed = guaranteed
self._calculated = calculated
# Let's start by assuming we were passed a simple value for `usage`.
_usage = usage
# But we may have been provided an iteration of values that we need
# to combine, so...
try:
# ...we need to see if we can iterate the argument. If we can,
# we'll get a logical OR of all the values.
_usage = reduce(lambda a, b: a | b, cast(Iterable, usage))
except TypeError:
pass # The argument wasn't iterable.
self._usage = _usage
@property
def guaranteed(self) -> bool:
"""
Is the column guaranteed to contain a non-empty value?
:return: `True` if the column is guaranteed to contain a non-empty
value, otherwise `False`
"""
return self._guaranteed
@property
def calculated(self) -> bool:
"""
May the column's value be generated or modified by a calculation?
:return: `True` if the column may be generated or modified by a
calculation, otherwise `False`
"""
return self._calculated
@property
def usage(self) -> Usage:
"""
Get the :py:class:`Usage` flag for the column.
:return: a single flag value that indicates the ways in which the
data in this column is expected to be used
"""
return self._usage
[docs]def column(dtype: Any, meta: ColumnMeta, *args, **kwargs) -> Column:
"""
Create a GeoAlchemy :py:class:`Column` annotated with metadata.
:param dtype: the SQLAlchemy/GeoAlchemy column type
:param meta: the meta data
:return: a GeoAlchemy :py:class:`Column`
"""
col = Column(dtype, *args, **kwargs)
col.__dict__[COLUMN_META_ATTR] = meta
return col