Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Changed period into abstract class and moved docstrings #25

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
263 changes: 129 additions & 134 deletions financeager/period.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from dateutil import rrule
from datetime import datetime as dt
import re
from abc import ABC, abstractmethod

from tinydb import TinyDB, Query, storages
from tinydb.database import Element
Expand Down Expand Up @@ -41,7 +42,7 @@ class RecurrentEntryValidationModel(BaseValidationModel):
end = DateType(formats=("%Y-%m-%d", PERIOD_DATE_FORMAT))


class Period:
class Period(ABC):
def __init__(self, name=None):
"""Create Period object. Its name defaults to the current year if not
specified.
Expand All @@ -57,56 +58,92 @@ def year(self):
"""Return period year as integer."""
return int(self._name)


class PeriodException(Exception):
pass


class TinyDbPeriod(Period):
def __init__(self, name=None, data_dir=None, **kwargs):
"""Create a period with a TinyDB database backend, identified by 'name'.
If 'data_dir' is given, the database storage type is JSON (the storage
filepath is derived from the Period's name). Otherwise the data is
stored in memory.
Keyword args are passed to the TinyDB constructor. See the respective
docs for detailed information.
@abstractmethod
def add_entry(self, table_name=None, **kwargs):
"""Add an entry (standard or recurrent) to the database.
If 'table_name' is not specified, the kwargs name, value[, category,
date] are used to insert a unique entry in the standard table.
With 'table_name' as 'recurrent', the kwargs name, value, frequency
[, start, end, category] are used to insert a template entry in the
recurrent table.
Two kwargs are mandatory:
:param name: entry name
:type name: str
:param value: entry value
:type value: float, int or str
The following kwarg is optional:
:param category: entry category. If not specified, the program
attempts to derive it from previous, eponymous entries. If this
fails, ``_DEFAULT_CATEGORY`` is assigned
:type category: str or None
The following kwarg is optional for standard entries:
:param date: entry date. Defaults to current date
:type date: str of ``PERIOD_DATE_FORMAT``
The following kwarg is mandatory for recurrent entries:
:param frequency: 'yearly', 'half-yearly', 'quarter-yearly',
'bimonthly', 'monthly', 'weekly' or 'daily'
The following kwargs are optional for recurrent entries:
:param start: start date (defaults to current date)
:param end: end date (defaults to last day of the period's year)
:raise: PeriodException if validation failed or table name unknown
:return: ID of new entry (int)
"""

super().__init__(name=name)
@abstractmethod
def remove_entry(self, eid, table_name=None):
"""Remove an entry from the Period database given its ID. The category
cache is updated.
:param eid: ID of the entry to be deleted.
:type eid: int or str
:param table_name: name of the table that contains the entry.
Default: 'standard'
:type table_name: str
:raise: PeriodException if entry/ID not found.
:return: entry ID if removal was successful
"""

# evaluate args/kwargs for TinyDB constructor. This overwrites the
# 'storage' kwarg if explicitly passed
if data_dir is None:
args = []
kwargs["storage"] = storages.MemoryStorage
else:
args = [os.path.join(data_dir, "{}.json".format(self.name))]
kwargs["storage"] = storages.JSONStorage
@abstractmethod
def update_entry(self, eid, table_name=None, **kwargs):
"""Update one or more fields of a single entry of the Period.
:param eid: entry ID of the entry to be updated
:param table_name: table that the entry is stored in (default:
'standard')
:param kwargs: 'date' for standard entries; any of 'frequency', 'start',
'end' for recurrent entries; any of 'name', 'value', 'category' for
either entry type
:raise: PeriodException if entry not found
:return: ID of the updated entry
"""

self._db = TinyDB(*args, **kwargs)
self._create_category_cache()
@abstractmethod
def get_entry(self, eid, table_name=None):
"""Get entry specified by ``eid`` in the table ``table_name`` (defaults to
table 'standard').
:type eid: int or str
:raise: PeriodException if entry not found
:return: found entry
"""

def _create_category_cache(self):
"""The category cache assigns a counter for each element name in the
database (excluding recurrent elements), keeping track of the
categories the element was labeled with. This allows deriving the
category of an element if not explicitly given."""
self._category_cache = defaultdict(Counter)
for element in self._db.all():
self._category_cache[element["name"]].update([element["category"]])
@abstractmethod
def get_entries(self, filters=None):
"""Get dict of standard and recurrent entries that match the items of
the filters dict, if specified. Constructs a condition from the given
filters and uses it to query all tables.
:return: dict{
DEFAULT_TABLE: dict{ int: entry },
"recurrent": dict{ int: list[entry] }
}
"""

def _preprocess_entry(self, raw_data=None, table_name=None, partial=False):
"""Perform preprocessing steps (validation, conversion, substitution) of
raw entry fields prior to adding it to the database.

:param raw_data: dict containing raw entry fields
:param table_name: name of the table that the entry is passed to
:param partial: indicates whether preprocessing is performed before
adding (False) or updating (True) the database

:raise: PeriodException if validation failed or table name unknown
"""

table_name = table_name or DEFAULT_TABLE
if table_name not in ["recurrent", DEFAULT_TABLE]:
raise PeriodException("Unknown table name: {}".format(table_name))
Expand All @@ -130,7 +167,6 @@ def _remove_redundant_fields(table_name, raw_data):
schematics validation ('Rogue field' error). This method removes
redundant fields in `raw_data` in-place.
"""

if table_name == "recurrent":
redundant_fields = ["date"]
else:
Expand All @@ -142,11 +178,9 @@ def _remove_redundant_fields(table_name, raw_data):
@staticmethod
def _validate_entry(raw_data, table_name, **model_kwargs):
"""Validate raw entry data acc. to ValidationModel.

:return: primitive (type-correct) representation of fields
:raise: PeriodException if validation failed
"""

ValidationModel = RecurrentEntryValidationModel \
if table_name == "recurrent" else StandardEntryValidationModel

Expand All @@ -165,27 +199,8 @@ def _validate_entry(raw_data, table_name, **model_kwargs):
raise PeriodException("Invalid input data:\n{}".format(
"\n".join(infos)))

@staticmethod
def _convert_fields(**fields):
"""Convert string field values to lowercase for storage. Fields with
value None are discarded.
"""

converted_fields = {}

for k, v in fields.items():
if v is None:
continue
try:
converted_fields[k] = v.lower()
except AttributeError:
converted_fields[k] = v

return converted_fields

def _substitute_none_fields(self, table_name, **fields):
"""Substitute optional fields by defaults."""

substituted_fields = fields.copy()

# table_name is either of two values; verified in _preprocess_entry
Expand Down Expand Up @@ -222,19 +237,72 @@ def _substitute_none_fields(self, table_name, **fields):

return substituted_fields

@staticmethod
def _convert_fields(**fields):
"""Convert string field values to lowercase for storage. Fields with
value None are discarded.
"""
converted_fields = {}

for k, v in fields.items():
if v is None:
continue
try:
converted_fields[k] = v.lower()
except AttributeError:
converted_fields[k] = v

return converted_fields


class PeriodException(Exception):
pass


class TinyDbPeriod(Period):
def __init__(self, name=None, data_dir=None, **kwargs):
"""Create a period with a TinyDB database backend, identified by 'name'.
If 'data_dir' is given, the database storage type is JSON (the storage
filepath is derived from the Period's name). Otherwise the data is
stored in memory.
Keyword args are passed to the TinyDB constructor. See the respective
docs for detailed information.
"""

super().__init__(name=name)

# evaluate args/kwargs for TinyDB constructor. This overwrites the
# 'storage' kwarg if explicitly passed
if data_dir is None:
args = []
kwargs["storage"] = storages.MemoryStorage
else:
args = [os.path.join(data_dir, "{}.json".format(self.name))]
kwargs["storage"] = storages.JSONStorage

self._db = TinyDB(*args, **kwargs)
self._create_category_cache()

def _create_category_cache(self):
"""The category cache assigns a counter for each element name in the
database (excluding recurrent elements), keeping track of the
categories the element was labeled with. This allows deriving the
category of an element if not explicitly given."""
self._category_cache = defaultdict(Counter)
for element in self._db.all():
self._category_cache[element["name"]].update([element["category"]])

def _update_category_cache(self,
eid=None,
table_name=None,
removing=False,
**fields):
"""Update the category cache when adding or updating an entry. The `eid`
kwarg is used to distinguish the use cases.

:param eid: element ID when updating
:param table_name: table name when updating
:param removing: indicate updating cache after removing an entry
:param fields: preprossed entry fields to be inserted in the database

:raise: PeriodException if element not found when updating
"""

Expand All @@ -260,41 +328,6 @@ def _update_category_cache(self,
old_category] += 1

def add_entry(self, table_name=None, **kwargs):
"""
Add an entry (standard or recurrent) to the database.
If 'table_name' is not specified, the kwargs name, value[, category,
date] are used to insert a unique entry in the standard table.
With 'table_name' as 'recurrent', the kwargs name, value, frequency
[, start, end, category] are used to insert a template entry in the
recurrent table.

Two kwargs are mandatory:
:param name: entry name
:type name: str
:param value: entry value
:type value: float, int or str

The following kwarg is optional:
:param category: entry category. If not specified, the program
attempts to derive it from previous, eponymous entries. If this
fails, ``_DEFAULT_CATEGORY`` is assigned
:type category: str or None

The following kwarg is optional for standard entries:
:param date: entry date. Defaults to current date
:type date: str of ``PERIOD_DATE_FORMAT``

The following kwarg is mandatory for recurrent entries:
:param frequency: 'yearly', 'half-yearly', 'quarter-yearly',
'bimonthly', 'monthly', 'weekly' or 'daily'

The following kwargs are optional for recurrent entries:
:param start: start date (defaults to current date)
:param end: end date (defaults to last day of the period's year)

:raise: PeriodException if validation failed or table name unknown
:return: TinyDB ID of new entry (int)
"""

table_name = table_name or DEFAULT_TABLE
fields = self._preprocess_entry(raw_data=kwargs, table_name=table_name)
Expand All @@ -306,15 +339,6 @@ def add_entry(self, table_name=None, **kwargs):
return element_id

def get_entry(self, eid, table_name=None):
"""
Get entry specified by ``eid`` in the table ``table_name`` (defaults to
table 'standard').

:type eid: int or str

:raise: PeriodException if element not found
:return: found element (tinydb.Element)
"""

table_name = table_name or DEFAULT_TABLE
element = self._db.table(table_name).get(eid=int(eid))
Expand All @@ -324,17 +348,6 @@ def get_entry(self, eid, table_name=None):
return element

def update_entry(self, eid, table_name=None, **kwargs):
"""Update one or more fields of a single entry of the Period.

:param eid: entry ID of the entry to be updated
:param table_name: table that the entry is stored in (default:
'standard')
:param kwargs: 'date' for standard entries; any of 'frequency', 'start',
'end' for recurrent entries; any of 'name', 'value', 'category' for
either entry type
:raise: PeriodException if element not found
:return: ID of the updated entry
"""

table_name = table_name or DEFAULT_TABLE
fields = self._preprocess_entry(
Expand All @@ -350,17 +363,14 @@ def update_entry(self, eid, table_name=None, **kwargs):
def _search_all_tables(self, query_impl=None):
"""Search both the standard table and the recurrent table for elements
that satisfy the given condition.

The elements' `eid` attribute is used as key in the returned subdicts
because it is lost in the client-server communication protocol (on
`financeager print`, the server calls Period.get_entries, yet the
JSON response returned drops the Element.eid attribute s.t. it's not
available when calling prettify on the client side).

:param query_impl: condition for the search. If none (default), all
elements are returned.
:type query_impl: tinydb.queries.QueryImpl

:return: dict
"""

Expand Down Expand Up @@ -445,18 +455,6 @@ def _create_recurrent_elements(self, element):
date=date.strftime(PERIOD_DATE_FORMAT)))

def remove_entry(self, eid, table_name=None):
"""Remove an entry from the Period database given its ID. The category
cache is updated.

:param eid: ID of the element to be deleted.
:type eid: int or str
:param table_name: name of the table that contains the element.
Default: 'standard'
:type table_name: str

:raise: PeriodException if element/ID not found.
:return: element ID if removal was successful
"""

table_name = table_name or DEFAULT_TABLE
# might raise PeriodException if ID not existing
Expand Down Expand Up @@ -518,10 +516,7 @@ def test(e):
return condition

def get_entries(self, filters=None):
"""Get dict of standard and recurrent entries that match the items of
the filters dict, if specified. Constructs a condition from the given
filters and uses it to query all tables.

"""
:return: dict{
DEFAULT_TABLE: dict{ int: tinydb.Element },
"recurrent": dict{ int: list[tinydb.Element] }
Expand Down