import2ledger: First version.
This is a pretty feature-complete version 1. I don't know why I waited this long to commit anything.
This commit is contained in:
commit
5c73c40bcc
29 changed files with 1099 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
build/
|
||||||
|
.cache/
|
||||||
|
*.egg
|
||||||
|
*.egg-info/
|
||||||
|
.eggs
|
||||||
|
__pycache__/
|
0
import2ledger/__init__.py
Normal file
0
import2ledger/__init__.py
Normal file
84
import2ledger/__main__.py
Normal file
84
import2ledger/__main__.py
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import config, errors, hooks, importers
|
||||||
|
|
||||||
|
logger = logging.getLogger('import2ledger')
|
||||||
|
|
||||||
|
class FileImporter:
|
||||||
|
def __init__(self, config, stdout):
|
||||||
|
self.config = config
|
||||||
|
self.importers = list(importers.load_all())
|
||||||
|
self.hooks = [hook(config) for hook in hooks.load_all()]
|
||||||
|
self.stdout = stdout
|
||||||
|
|
||||||
|
def import_file(self, in_file):
|
||||||
|
importers = []
|
||||||
|
for importer in self.importers:
|
||||||
|
in_file.seek(0)
|
||||||
|
if importer.can_import(in_file):
|
||||||
|
importers.append(importer)
|
||||||
|
if not importers:
|
||||||
|
raise errors.UserInputFileError("no importers available", in_file.name)
|
||||||
|
with contextlib.ExitStack() as exit_stack:
|
||||||
|
output_path = self.config.get_output_path()
|
||||||
|
if output_path is None:
|
||||||
|
out_file = self.stdout
|
||||||
|
else:
|
||||||
|
out_file = exit_stack.enter_context(output_path.open('a'))
|
||||||
|
for importer in importers:
|
||||||
|
template = self.config.get_template(importer.TEMPLATE_KEY)
|
||||||
|
default_date = self.config.get_default_date()
|
||||||
|
in_file.seek(0)
|
||||||
|
for entry_data in importer(in_file):
|
||||||
|
for hook in self.hooks:
|
||||||
|
hook.run(entry_data)
|
||||||
|
print(template.render(**entry_data), file=out_file, end='')
|
||||||
|
|
||||||
|
def import_path(self, in_path):
|
||||||
|
if in_path is None:
|
||||||
|
raise errors.UserInputFileError("only seekable files are supported", '<stdin>')
|
||||||
|
with in_path.open() as in_file:
|
||||||
|
if not in_file.seekable():
|
||||||
|
raise errors.UserInputFileError("only seekable files are supported", in_path)
|
||||||
|
return self.import_file(in_file)
|
||||||
|
|
||||||
|
def import_paths(self, path_seq):
|
||||||
|
for in_path in path_seq:
|
||||||
|
try:
|
||||||
|
retval = self.import_path(in_path)
|
||||||
|
except (OSError, errors.UserInputError) as error:
|
||||||
|
yield in_path, error
|
||||||
|
else:
|
||||||
|
yield in_path, retval
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logger(logger, main_config, stream):
|
||||||
|
formatter = logging.Formatter('%(name)s: %(levelname)s: %(message)s')
|
||||||
|
handler = logging.StreamHandler(stream)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
def main(arglist=None, stdout=sys.stdout, stderr=sys.stderr):
|
||||||
|
try:
|
||||||
|
my_config = config.Configuration(arglist)
|
||||||
|
except errors.UserInputError as error:
|
||||||
|
my_config.error("{}: {!r}".format(error.strerror, error.user_input))
|
||||||
|
return 3
|
||||||
|
setup_logger(logger, my_config, stderr)
|
||||||
|
importer = FileImporter(my_config, stdout)
|
||||||
|
failures = 0
|
||||||
|
for input_path, error in importer.import_paths(my_config.args.input_paths):
|
||||||
|
if error is None:
|
||||||
|
logger.info("%s: imported", input_path)
|
||||||
|
else:
|
||||||
|
logger.warning("%s: failed to import: %s", input_path, error)
|
||||||
|
failures += 1
|
||||||
|
if failures == 0:
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
return min(10 + failures, 99)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
exit(main())
|
23
import2ledger/builder.py
Normal file
23
import2ledger/builder.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import decimal
|
||||||
|
|
||||||
|
import ledgerlib
|
||||||
|
|
||||||
|
class TransactionBuilder:
|
||||||
|
def __init__(self, config):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def _build_one_side(self, entry, amount, tx_provider, tx_type, section):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def build(self, date, payee, amount,
|
||||||
|
from_prov, from_type,
|
||||||
|
to_prov_or_type, to_type=None,
|
||||||
|
section=None):
|
||||||
|
if to_type is None:
|
||||||
|
to_prov = from_prov
|
||||||
|
to_type = to_prov_or_type
|
||||||
|
else:
|
||||||
|
to_prov = to_prov_or_type
|
||||||
|
entry = ledgerlib.Entry(date, payee, date_fmt=self.config.args.date_format)
|
||||||
|
self._build_one_side(entry, -amount, from_prov, from_type, section)
|
||||||
|
self._build_one_side(entry, amount, to_prov, to_type, section)
|
210
import2ledger/config.py
Normal file
210
import2ledger/config.py
Normal file
|
@ -0,0 +1,210 @@
|
||||||
|
import argparse
|
||||||
|
import configparser
|
||||||
|
import contextlib
|
||||||
|
import datetime
|
||||||
|
import locale
|
||||||
|
import logging
|
||||||
|
import os.path
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import babel
|
||||||
|
import babel.numbers
|
||||||
|
from . import errors, template, util
|
||||||
|
|
||||||
|
class Configuration:
|
||||||
|
HOME_PATH = pathlib.Path(os.path.expanduser('~'))
|
||||||
|
DEFAULT_CONFIG_PATH = pathlib.Path(HOME_PATH, '.config', 'import2ledger.ini')
|
||||||
|
DEFAULT_ENCODING = locale.getpreferredencoding()
|
||||||
|
LOCALE = babel.core.Locale.default()
|
||||||
|
TODAY = datetime.date.today()
|
||||||
|
CONFIG_DEFAULTS = {
|
||||||
|
'date_format': '%%Y/%%m/%%d',
|
||||||
|
'loglevel': 'WARNING',
|
||||||
|
'output_path': '-',
|
||||||
|
'signed_currencies': ','.join(babel.numbers.get_territory_currencies(
|
||||||
|
LOCALE.territory, start_date=TODAY)),
|
||||||
|
'signed_currency_format': '¤#,##0.###;¤-#,##0.###',
|
||||||
|
'unsigned_currency_format': '#,##0.### ¤¤',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, arglist):
|
||||||
|
argparser = self._build_argparser()
|
||||||
|
self.error = argparser.error
|
||||||
|
self.args = argparser.parse_args(arglist)
|
||||||
|
|
||||||
|
if self.args.config_file is None:
|
||||||
|
self.args.config_file = []
|
||||||
|
if self.DEFAULT_CONFIG_PATH.exists():
|
||||||
|
self.args.config_file.append(self.DEFAULT_CONFIG_PATH)
|
||||||
|
self.conffile = self._build_conffile()
|
||||||
|
conffile_paths = [path.as_posix() for path in self.args.config_file]
|
||||||
|
read_files = self.conffile.read(conffile_paths)
|
||||||
|
for expected_path, read_path in zip(conffile_paths, read_files):
|
||||||
|
if read_path != expected_path:
|
||||||
|
self.error("failed to read configuration file {!r}".format(expected_path))
|
||||||
|
|
||||||
|
self.finalize()
|
||||||
|
|
||||||
|
def _build_argparser(self):
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
'--config-file', '-C', metavar='PATH', type=pathlib.Path,
|
||||||
|
action='append',
|
||||||
|
help="Path of a configuration file to read",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--use-config', '-c', metavar='SECTION',
|
||||||
|
help="Read settings from this section of the configuration file",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'input_paths', metavar='PATH',
|
||||||
|
nargs='+',
|
||||||
|
help="Path to generate Ledger entries from.",
|
||||||
|
)
|
||||||
|
|
||||||
|
out_args = parser.add_argument_group(
|
||||||
|
"default overrides",
|
||||||
|
description="These options take priority over settings in the "
|
||||||
|
"[DEFAULT] section of your config file, but not other sections.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--loglevel', '-L', metavar='LEVEL',
|
||||||
|
choices=['debug', 'info', 'warning', 'error', 'critical'],
|
||||||
|
help="Log messages at this level and above. Default WARNING.",
|
||||||
|
)
|
||||||
|
out_args.add_argument(
|
||||||
|
'--date', '-d', metavar='DATE',
|
||||||
|
help="Date to use in Ledger entries when the source doesn't "
|
||||||
|
"provide one. Write this in your configured date format. "
|
||||||
|
"Default today.",
|
||||||
|
)
|
||||||
|
out_args.add_argument(
|
||||||
|
'--date-format', '-D', metavar='FORMAT',
|
||||||
|
help="Date format to use in Ledger entries",
|
||||||
|
)
|
||||||
|
out_args.add_argument(
|
||||||
|
'--output-path', '-O', metavar='PATH',
|
||||||
|
help="Path of file to append entries to, or '-' for stdout (default).",
|
||||||
|
)
|
||||||
|
out_args.add_argument(
|
||||||
|
'--signed-currency', '--sign', metavar='CODE',
|
||||||
|
action='append', dest='signed_currencies',
|
||||||
|
help="Currency code to use currency sign for in Ledger entry amounts. "
|
||||||
|
"Can be specified multiple times.",
|
||||||
|
)
|
||||||
|
out_args.add_argument(
|
||||||
|
'--signed-currency-format', '--sign-format', '-S', metavar='FORMAT',
|
||||||
|
help="Unicode number pattern to use for signed currencies in Ledger entry amounts",
|
||||||
|
)
|
||||||
|
out_args.add_argument(
|
||||||
|
'--unsigned-currency-format', '--unsign-format', '-U', metavar='FORMAT',
|
||||||
|
help="Unicode number pattern to use for unsigned currencies in Ledger entry amounts",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
def _build_conffile(self):
|
||||||
|
return configparser.ConfigParser(
|
||||||
|
comment_prefixes='#',
|
||||||
|
defaults=self.CONFIG_DEFAULTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _s_to_path(self, s):
|
||||||
|
return None if s == '-' else pathlib.Path(s)
|
||||||
|
|
||||||
|
def _strpdate(self, date_s, date_fmt):
|
||||||
|
try:
|
||||||
|
return util.strpdate(date_s, date_fmt)
|
||||||
|
except ValueError as error:
|
||||||
|
raise errors.UserInputConfigurationError(error.args[0], date_s)
|
||||||
|
|
||||||
|
def _parse_section_date(self, section_name, default=TODAY):
|
||||||
|
section = self.conffile[section_name]
|
||||||
|
try:
|
||||||
|
return self._strpdate(section['date'], section['date_format'])
|
||||||
|
except KeyError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
def finalize(self):
|
||||||
|
if self.args.use_config is None:
|
||||||
|
self.args.use_config = self.conffile.default_section
|
||||||
|
elif not self.conffile.has_section(self.args.use_config):
|
||||||
|
self.error("section {!r} not found in config file".format(self.args.use_config))
|
||||||
|
self.args.input_paths = [self._s_to_path(s) for s in self.args.input_paths]
|
||||||
|
|
||||||
|
defaults = self.conffile[self.conffile.default_section]
|
||||||
|
for key in self.CONFIG_DEFAULTS:
|
||||||
|
value = getattr(self.args, key)
|
||||||
|
if value is None:
|
||||||
|
pass
|
||||||
|
elif key == 'signed_currencies':
|
||||||
|
defaults[key] = ','.join(value)
|
||||||
|
else:
|
||||||
|
defaults[key] = value
|
||||||
|
|
||||||
|
# We parse all the dates now to make sure they're valid.
|
||||||
|
if self.args.date is not None:
|
||||||
|
default_date = self._strpdate(self.args.date, defaults['date_format'])
|
||||||
|
elif 'date' in defaults:
|
||||||
|
default_date = self._strpdate(defaults['date'], defaults['date_format'])
|
||||||
|
else:
|
||||||
|
default_date = self.TODAY
|
||||||
|
|
||||||
|
self.dates = {secname: self._parse_section_date(secname, default_date)
|
||||||
|
for secname in self.conffile}
|
||||||
|
self.dates[self.conffile.default_section] = default_date
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def from_section(self, section_name):
|
||||||
|
prev_section = self.args.use_config
|
||||||
|
self.args.use_config = section_name
|
||||||
|
try:
|
||||||
|
yield self
|
||||||
|
finally:
|
||||||
|
self.args.use_config = prev_section
|
||||||
|
|
||||||
|
def _get_section(self, section_name):
|
||||||
|
if section_name is None:
|
||||||
|
section_name = self.args.use_config
|
||||||
|
return self.conffile[section_name]
|
||||||
|
|
||||||
|
def get_default_date(self, section_name=None):
|
||||||
|
if section_name is None:
|
||||||
|
section_name = self.args.use_config
|
||||||
|
try:
|
||||||
|
return self.dates[section_name]
|
||||||
|
except KeyError:
|
||||||
|
return self.dates[self.conffile.default_section]
|
||||||
|
|
||||||
|
def get_loglevel(self, section_name=None):
|
||||||
|
section_config = self._get_section(section_name)
|
||||||
|
level_name = section_config['loglevel']
|
||||||
|
try:
|
||||||
|
return getattr(logging, level_name.upper())
|
||||||
|
except AttributeError:
|
||||||
|
raise errors.UserInputConfigurationError("not a valid loglevel", level_name)
|
||||||
|
|
||||||
|
def get_output_path(self, section_name=None):
|
||||||
|
section_config = self._get_section(section_name)
|
||||||
|
return self._s_to_path(section_config['output_path'])
|
||||||
|
|
||||||
|
def get_template(self, config_key, section_name=None, factory=template.Template):
|
||||||
|
section_config = self._get_section(section_name)
|
||||||
|
try:
|
||||||
|
template_s = section_config[config_key]
|
||||||
|
except KeyError:
|
||||||
|
raise errors.UserInputConfigurationError(
|
||||||
|
"template not defined in [{}]".format(section_name or self.args.use_config),
|
||||||
|
config_key,
|
||||||
|
)
|
||||||
|
return factory(
|
||||||
|
template_s,
|
||||||
|
date_fmt=section_config['date_format'],
|
||||||
|
signed_currencies=[code.strip().upper() for code in section_config['signed_currencies'].split(',')],
|
||||||
|
signed_currency_fmt=section_config['signed_currency_format'],
|
||||||
|
unsigned_currency_fmt=section_config['unsigned_currency_format'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def setup_logger(self, logger, section_name=None):
|
||||||
|
logger.setLevel(self.get_loglevel(section_name))
|
||||||
|
|
15
import2ledger/errors.py
Normal file
15
import2ledger/errors.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
class UserInputError(Exception):
|
||||||
|
def __init__(self, strerror, user_input):
|
||||||
|
super().__init__(strerror, user_input)
|
||||||
|
self.strerror = strerror
|
||||||
|
self.user_input = user_input
|
||||||
|
|
||||||
|
|
||||||
|
class UserInputConfigurationError(UserInputError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UserInputFileError(UserInputError):
|
||||||
|
def __init__(self, strerror, path):
|
||||||
|
super().__init__(strerror, path)
|
||||||
|
self.path = path
|
6
import2ledger/hooks/__init__.py
Normal file
6
import2ledger/hooks/__init__.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import operator
|
||||||
|
|
||||||
|
from .. import util
|
||||||
|
|
||||||
|
def load_all():
|
||||||
|
return util.submodule_items_named(__file__, operator.methodcaller('endswith', 'Hook'))
|
42
import2ledger/hooks/add_entity.py
Normal file
42
import2ledger/hooks/add_entity.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
class AddEntityHook:
|
||||||
|
NONASCII_RE = re.compile(r'[^-A-Za-z0-9]')
|
||||||
|
NONALNUM_RE = re.compile(r'[^-\w]')
|
||||||
|
OPEN_PARENS = ['\\(', '\\[', '\\{']
|
||||||
|
CLOSE_PARENS = ['\\)', '\\]', '\\}']
|
||||||
|
NO_PARENS = '[^{}]*'.format(''.join(OPEN_PARENS + CLOSE_PARENS))
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _remove_parens(self, s):
|
||||||
|
last_s = None
|
||||||
|
while s != last_s:
|
||||||
|
last_s = s
|
||||||
|
for open_c, close_c in zip(self.OPEN_PARENS, self.CLOSE_PARENS):
|
||||||
|
s = re.sub(open_c + self.NO_PARENS + close_c, '', s)
|
||||||
|
return s if s else last_s
|
||||||
|
|
||||||
|
def _entity_parts(self, s, trim_re):
|
||||||
|
for word in s.split():
|
||||||
|
word = unicodedata.normalize('NFKD', word)
|
||||||
|
word = trim_re.sub('', word)
|
||||||
|
if word:
|
||||||
|
yield word
|
||||||
|
|
||||||
|
def _str2entity(self, s, trim_re):
|
||||||
|
parts = list(self._entity_parts(s, trim_re))
|
||||||
|
if not parts:
|
||||||
|
return ''
|
||||||
|
parts.insert(0, parts.pop())
|
||||||
|
return '-'.join(parts)
|
||||||
|
|
||||||
|
def run(self, data):
|
||||||
|
if ('payee' in data) and ('entity' not in data):
|
||||||
|
payee = self._remove_parens(data['payee'])
|
||||||
|
entity = self._str2entity(payee, self.NONASCII_RE)
|
||||||
|
if not entity:
|
||||||
|
entity = self._str2entity(payee, self.NONALNUM_RE)
|
||||||
|
data['entity'] = entity
|
7
import2ledger/hooks/default_date.py
Normal file
7
import2ledger/hooks/default_date.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
class DefaultDateHook:
|
||||||
|
def __init__(self, config):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def run(self, entry_data):
|
||||||
|
if 'date' not in entry_data:
|
||||||
|
entry_data['date'] = self.config.get_default_date()
|
6
import2ledger/importers/__init__.py
Normal file
6
import2ledger/importers/__init__.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import operator
|
||||||
|
|
||||||
|
from .. import util
|
||||||
|
|
||||||
|
def load_all():
|
||||||
|
return util.submodule_items_named(__file__, operator.methodcaller('endswith', 'Importer'))
|
72
import2ledger/importers/patreon.py
Normal file
72
import2ledger/importers/patreon.py
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import csv
|
||||||
|
import datetime
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .. import util
|
||||||
|
|
||||||
|
class ImporterBase:
|
||||||
|
@classmethod
|
||||||
|
def can_import(cls, input_file):
|
||||||
|
in_csv = csv.reader(input_file)
|
||||||
|
fields = next(iter(in_csv), [])
|
||||||
|
return cls.NEEDED_FIELDS.issubset(fields)
|
||||||
|
|
||||||
|
def __init__(self, input_file):
|
||||||
|
self.in_csv = csv.DictReader(input_file)
|
||||||
|
self.start_data = {'currency': 'USD'}
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
for row in self.in_csv:
|
||||||
|
row_data = self._read_row(row)
|
||||||
|
if row_data is not None:
|
||||||
|
retval = self.start_data.copy()
|
||||||
|
retval.update(row_data)
|
||||||
|
yield retval
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeImporter(ImporterBase):
|
||||||
|
NEEDED_FIELDS = frozenset([
|
||||||
|
'FirstName',
|
||||||
|
'LastName',
|
||||||
|
'Pledge',
|
||||||
|
'Status',
|
||||||
|
])
|
||||||
|
TEMPLATE_KEY = 'template patreon income'
|
||||||
|
|
||||||
|
def __init__(self, input_file):
|
||||||
|
super().__init__(input_file)
|
||||||
|
match = re.search(r'(?:\b|_)(\d{4}-\d{2}-\d{2})(?:\b|_)',
|
||||||
|
pathlib.Path(input_file.name).name)
|
||||||
|
if match:
|
||||||
|
self.start_data['date'] = util.strpdate(match.group(1), '%Y-%m-%d')
|
||||||
|
|
||||||
|
def _read_row(self, row):
|
||||||
|
if row['Status'] != 'Processed':
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'amount': row['Pledge'],
|
||||||
|
'payee': '{0[FirstName]} {0[LastName]}'.format(row),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FeeImporterBase(ImporterBase):
|
||||||
|
def _read_row(self, row):
|
||||||
|
return {
|
||||||
|
'amount': row[self.AMOUNT_FIELD].lstrip('$'),
|
||||||
|
'date': util.strpdate(row['Month'], '%Y-%m'),
|
||||||
|
'payee': "Patreon",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PatreonFeeImporter(FeeImporterBase):
|
||||||
|
AMOUNT_FIELD = 'Patreon Fee'
|
||||||
|
NEEDED_FIELDS = frozenset(['Month', AMOUNT_FIELD])
|
||||||
|
TEMPLATE_KEY = 'template patreon svcfees'
|
||||||
|
|
||||||
|
|
||||||
|
class CardFeeImporter(FeeImporterBase):
|
||||||
|
AMOUNT_FIELD = 'Processing Fees'
|
||||||
|
NEEDED_FIELDS = frozenset(['Month', AMOUNT_FIELD])
|
||||||
|
TEMPLATE_KEY = 'template patreon cardfees'
|
98
import2ledger/template.py
Normal file
98
import2ledger/template.py
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
import collections
|
||||||
|
import datetime
|
||||||
|
import decimal
|
||||||
|
|
||||||
|
import babel.numbers
|
||||||
|
|
||||||
|
class AccountSplitter:
|
||||||
|
Split = collections.namedtuple('Split', ('account', 'percentage'))
|
||||||
|
|
||||||
|
def __init__(self, sign, signed_currencies, signed_currency_fmt, unsigned_currency_fmt):
|
||||||
|
self.splits = []
|
||||||
|
self.sign = sign
|
||||||
|
self.signed_currency_fmt = signed_currency_fmt
|
||||||
|
self.unsigned_currency_fmt = unsigned_currency_fmt
|
||||||
|
self.signed_currencies = set(signed_currencies)
|
||||||
|
self.unsplit = sign * 100
|
||||||
|
|
||||||
|
def add(self, account, percentage_s):
|
||||||
|
int_pctage = decimal.Decimal(percentage_s.rstrip('%'))
|
||||||
|
self.unsplit -= int_pctage
|
||||||
|
percentage = None if self.unsplit == 0 else (int_pctage / 100)
|
||||||
|
self.splits.append(self.Split(account, percentage))
|
||||||
|
|
||||||
|
def render_next(self, template_vars):
|
||||||
|
try:
|
||||||
|
account, percentage = next(self._split_iter)
|
||||||
|
except (AttributeError, StopIteration):
|
||||||
|
self._split_iter = iter(self.splits)
|
||||||
|
account, percentage = next(self._split_iter)
|
||||||
|
self._remainder = self.sign * template_vars['amount']
|
||||||
|
self._currency = template_vars['currency']
|
||||||
|
if self._currency in self.signed_currencies:
|
||||||
|
self._amt_fmt = self.signed_currency_fmt
|
||||||
|
else:
|
||||||
|
self._amt_fmt = self.unsigned_currency_fmt
|
||||||
|
if percentage is None:
|
||||||
|
amount = self._remainder
|
||||||
|
else:
|
||||||
|
amount = decimal.Decimal(babel.numbers.format_currency(
|
||||||
|
template_vars['amount'] * percentage, self._currency, '###0.###'))
|
||||||
|
self._remainder -= amount
|
||||||
|
amt_s = babel.numbers.format_currency(amount, self._currency, self._amt_fmt)
|
||||||
|
return ' {:45} {:>19}\n'.format(account, amt_s)
|
||||||
|
|
||||||
|
|
||||||
|
class Template:
|
||||||
|
DATE_FMT = '%Y/%m/%d'
|
||||||
|
SIGNED_CURRENCY_FMT = '¤#,##0.###;¤-#,##0.###'
|
||||||
|
UNSIGNED_CURRENCY_FMT = '#,##0.### ¤¤'
|
||||||
|
|
||||||
|
def __init__(self, template_s, signed_currencies=frozenset(),
|
||||||
|
date_fmt=DATE_FMT,
|
||||||
|
signed_currency_fmt=SIGNED_CURRENCY_FMT,
|
||||||
|
unsigned_currency_fmt=UNSIGNED_CURRENCY_FMT):
|
||||||
|
self.date_fmt = date_fmt
|
||||||
|
self.pos_splits = AccountSplitter(
|
||||||
|
1, signed_currencies, signed_currency_fmt, unsigned_currency_fmt)
|
||||||
|
self.neg_splits = AccountSplitter(
|
||||||
|
-1, signed_currencies, signed_currency_fmt, unsigned_currency_fmt)
|
||||||
|
|
||||||
|
lines = [s.lstrip() for s in template_s.splitlines(True)]
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
if line:
|
||||||
|
break
|
||||||
|
del lines[:index]
|
||||||
|
self.format_funcs = [
|
||||||
|
'\n'.format_map,
|
||||||
|
'{date} {payee}\n'.format_map,
|
||||||
|
]
|
||||||
|
start_index = 0
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
if line[0].isalpha():
|
||||||
|
if start_index < index:
|
||||||
|
self._add_str_func(lines[start_index:index])
|
||||||
|
account, percentage_s = line.rsplit(None, 1)
|
||||||
|
splitter = self.neg_splits if percentage_s.startswith('-') else self.pos_splits
|
||||||
|
splitter.add(account, percentage_s)
|
||||||
|
self.format_funcs.append(splitter.render_next)
|
||||||
|
start_index = index + 1
|
||||||
|
if start_index <= index:
|
||||||
|
self._add_str_func(lines[start_index:])
|
||||||
|
if not line.endswith('\n'):
|
||||||
|
self.format_funcs.append('\n'.format_map)
|
||||||
|
|
||||||
|
def _add_str_func(self, str_seq):
|
||||||
|
str_flat = ''.join(' ' + s for s in str_seq)
|
||||||
|
self.format_funcs.append(str_flat.format_map)
|
||||||
|
|
||||||
|
def render(self, payee, amount, currency, date=None, **template_vars):
|
||||||
|
if date is None:
|
||||||
|
date = datetime.date.today()
|
||||||
|
template_vars.update(
|
||||||
|
date=date.strftime(self.date_fmt),
|
||||||
|
payee=payee,
|
||||||
|
amount=decimal.Decimal(amount),
|
||||||
|
currency=currency,
|
||||||
|
)
|
||||||
|
return ''.join(f(template_vars) for f in self.format_funcs)
|
34
import2ledger/util.py
Normal file
34
import2ledger/util.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import datetime
|
||||||
|
import importlib
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
logger = logging.getLogger('import2ledger')
|
||||||
|
|
||||||
|
def load_modules(src_dir_path):
|
||||||
|
rel_path = src_dir_path.relative_to(pathlib.Path(__file__).parent)
|
||||||
|
import_prefix = 'import2ledger.{}.'.format('.'.join(rel_path.parts))
|
||||||
|
for py_path in src_dir_path.glob('*.py'):
|
||||||
|
mod_name = py_path.name[:-3]
|
||||||
|
if mod_name.startswith(('.', '_')):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(import_prefix + mod_name)
|
||||||
|
except ImportError as error:
|
||||||
|
logger.info("failed to import %s: %s", py_path, error,
|
||||||
|
exc_info=logger.isEnabledFor(logging.DEBUG))
|
||||||
|
else:
|
||||||
|
yield module
|
||||||
|
|
||||||
|
def module_contents(module):
|
||||||
|
for name in dir(module):
|
||||||
|
yield name, getattr(module, name)
|
||||||
|
|
||||||
|
def submodule_items_named(file_path, name_test):
|
||||||
|
for module in load_modules(pathlib.Path(file_path).parent):
|
||||||
|
for name, item in module_contents(module):
|
||||||
|
if name_test(name):
|
||||||
|
yield item
|
||||||
|
|
||||||
|
def strpdate(date_s, date_fmt):
|
||||||
|
return datetime.datetime.strptime(date_s, date_fmt).date()
|
2
setup.cfg
Normal file
2
setup.cfg
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[aliases]
|
||||||
|
test=pytest
|
19
setup.py
Executable file
19
setup.py
Executable file
|
@ -0,0 +1,19 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name='import2ledger',
|
||||||
|
description="Import different sources of financial data to Ledger",
|
||||||
|
version='0.1',
|
||||||
|
author='Brett Smith',
|
||||||
|
author_email='brettcsmith@brettcsmith.org',
|
||||||
|
license='GNU AGPLv3+',
|
||||||
|
|
||||||
|
install_requires=['babel'],
|
||||||
|
setup_requires=['pytest-runner'],
|
||||||
|
tests_require=['pytest', 'PyYAML'],
|
||||||
|
|
||||||
|
packages=['import2ledger'],
|
||||||
|
entry_points={},
|
||||||
|
)
|
7
tests/__init__.py
Normal file
7
tests/__init__.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
DATA_DIR = pathlib.Path(__file__).with_name('data')
|
||||||
|
|
||||||
|
def normalize_whitespace(s):
|
||||||
|
return re.sub(r'(\t| {3,})', ' ', s)
|
3
tests/data/PatreonEarnings.csv
Normal file
3
tests/data/PatreonEarnings.csv
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
Month,Processed Pledges,Creator's Share,Processing Fees,Patreon Fee,Creator's Share (%),Processing Fees (%),Patreon Fee (%)
|
||||||
|
2017-09,"$1234.50","$1,120.30",$52.47,$61.73,90.75%,4.25%,5.0%
|
||||||
|
2017-10,"$2340.50","$2,124.00",$99.47,$117.03,90.75%,4.25%,5.0%
|
|
6
tests/data/PatreonPatronReport_2017-09-01.csv
Normal file
6
tests/data/PatreonPatronReport_2017-09-01.csv
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
FirstName,LastName,Email,Pledge,Lifetime,Status,Twitter,Street,City,State,Zip,Country,Start,MaxAmount,Complete
|
||||||
|
100 + Reward,Description You donate a lot of money!,,,,,,,,,,,,,
|
||||||
|
Alex,Jones,alex@example.org,150,300,Processed,,,,,,,2017-08-11 11:28:06.166065,200,0
|
||||||
|
5 + Reward,Description You’re nice!,,,,,,,,,,,,,
|
||||||
|
Brett,Smith,brett@example.org,10,30,Declined,Brett20XX,,,,,,2017-08-10 13:24:15.955782,10,0
|
||||||
|
Dakota,Doe,ddoe@example.org,12,48,Processed,,,,,,,2017-08-10 12:58:31.919341,12,0
|
|
35
tests/data/imports.yml
Normal file
35
tests/data/imports.yml
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
- source: PatreonPatronReport_2017-09-01.csv
|
||||||
|
importer: patreon.IncomeImporter
|
||||||
|
expect:
|
||||||
|
- payee: Alex Jones
|
||||||
|
date: [2017, 9, 1]
|
||||||
|
amount: "150"
|
||||||
|
currency: USD
|
||||||
|
- payee: Dakota Doe
|
||||||
|
date: [2017, 9, 1]
|
||||||
|
amount: "12"
|
||||||
|
currency: USD
|
||||||
|
|
||||||
|
- source: PatreonEarnings.csv
|
||||||
|
importer: patreon.PatreonFeeImporter
|
||||||
|
expect:
|
||||||
|
- payee: Patreon
|
||||||
|
date: [2017, 9, 1]
|
||||||
|
amount: "61.73"
|
||||||
|
currency: USD
|
||||||
|
- payee: Patreon
|
||||||
|
date: [2017, 10, 1]
|
||||||
|
amount: "117.03"
|
||||||
|
currency: USD
|
||||||
|
|
||||||
|
- source: PatreonEarnings.csv
|
||||||
|
importer: patreon.CardFeeImporter
|
||||||
|
expect:
|
||||||
|
- payee: Patreon
|
||||||
|
date: [2017, 9, 1]
|
||||||
|
amount: "52.47"
|
||||||
|
currency: USD
|
||||||
|
- payee: Patreon
|
||||||
|
date: [2017, 10, 1]
|
||||||
|
amount: "99.47"
|
||||||
|
currency: USD
|
22
tests/data/templates.ini
Normal file
22
tests/data/templates.ini
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
[Simplest]
|
||||||
|
template =
|
||||||
|
Accrued:Accounts Receivable 100%%
|
||||||
|
Income:Donations -100%%
|
||||||
|
|
||||||
|
[FiftyFifty]
|
||||||
|
template =
|
||||||
|
Accrued:Accounts Receivable 100%%
|
||||||
|
Income:Donations -50%%
|
||||||
|
Income:Sales -50%%
|
||||||
|
|
||||||
|
[Complex]
|
||||||
|
template =
|
||||||
|
;Tag: Value
|
||||||
|
;TransactionID: {txid}
|
||||||
|
Accrued:Accounts Receivable 100%%
|
||||||
|
;Entity: Supplier
|
||||||
|
Income:Donations:Specific -95.5%%
|
||||||
|
;Program: Specific
|
||||||
|
;Entity: {entity}
|
||||||
|
Income:Donations:General -4.5%%
|
||||||
|
;Entity: {entity}
|
24
tests/data/test_config.ini
Normal file
24
tests/data/test_config.ini
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
[DEFAULT]
|
||||||
|
date_format = %%Y-%%m-%%d
|
||||||
|
signed_currencies = EUR
|
||||||
|
|
||||||
|
[Templates]
|
||||||
|
output_path = Template.output
|
||||||
|
one =
|
||||||
|
Accrued:Accounts Receivable 100%%
|
||||||
|
Income:Donations -100%%
|
||||||
|
two =
|
||||||
|
;Tag1: {value}
|
||||||
|
Income:Donations -100%%
|
||||||
|
;IncomeTag: Donations
|
||||||
|
Expenses:Fundraising 5%%
|
||||||
|
;ExpenseTag: Fundraising
|
||||||
|
Accrued:Accounts Receivable 95%%
|
||||||
|
;AccrualTag: Receivable
|
||||||
|
|
||||||
|
[Date]
|
||||||
|
date_format = %%Y|%%m|%%d
|
||||||
|
date = 2017|10|08
|
||||||
|
|
||||||
|
[Bad Loglevel]
|
||||||
|
loglevel = wraning
|
12
tests/data/test_main.ini
Normal file
12
tests/data/test_main.ini
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
[DEFAULT]
|
||||||
|
default_date = 2016/04/04
|
||||||
|
loglevel = critical
|
||||||
|
signed_currencies = USD
|
||||||
|
|
||||||
|
[One]
|
||||||
|
template patreon cardfees =
|
||||||
|
Accrued:Accounts Receivable -100%%
|
||||||
|
Expenses:Fees:Credit Card 100%%
|
||||||
|
template patreon svcfees =
|
||||||
|
Accrued:Accounts Receivable -100%%
|
||||||
|
Expenses:Fundraising 100%%
|
15
tests/data/test_main_fees_import.ledger
Normal file
15
tests/data/test_main_fees_import.ledger
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
2017/09/01 Patreon
|
||||||
|
Accrued:Accounts Receivable $-52.47
|
||||||
|
Expenses:Fees:Credit Card $52.47
|
||||||
|
|
||||||
|
2017/09/01 Patreon
|
||||||
|
Accrued:Accounts Receivable $-61.73
|
||||||
|
Expenses:Fundraising $61.73
|
||||||
|
|
||||||
|
2017/10/01 Patreon
|
||||||
|
Accrued:Accounts Receivable $-99.47
|
||||||
|
Expenses:Fees:Credit Card $99.47
|
||||||
|
|
||||||
|
2017/10/01 Patreon
|
||||||
|
Accrued:Accounts Receivable $-117.03
|
||||||
|
Expenses:Fundraising $117.03
|
109
tests/test_config.py
Normal file
109
tests/test_config.py
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import contextlib
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
START_DATE = datetime.date.today()
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from import2ledger import config, errors
|
||||||
|
|
||||||
|
from . import DATA_DIR
|
||||||
|
|
||||||
|
def config_from_file(path, arglist=[]):
|
||||||
|
path = pathlib.Path(path)
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = DATA_DIR / path
|
||||||
|
arglist = ['-C', path.as_posix(), *arglist, os.devnull]
|
||||||
|
return config.Configuration(arglist)
|
||||||
|
|
||||||
|
def test_defaults():
|
||||||
|
config = config_from_file('test_config.ini', ['--sign', 'GBP', '-O', 'out_arg'])
|
||||||
|
factory = mock.Mock(name='Template')
|
||||||
|
template = config.get_template('one', 'Templates', factory)
|
||||||
|
assert factory.called
|
||||||
|
kwargs = factory.call_args[1]
|
||||||
|
assert list(kwargs.pop('signed_currencies', '')) == ['GBP']
|
||||||
|
assert kwargs == {
|
||||||
|
'date_fmt': '%Y-%m-%d',
|
||||||
|
'signed_currency_fmt': kwargs['signed_currency_fmt'],
|
||||||
|
'unsigned_currency_fmt': kwargs['unsigned_currency_fmt'],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_template_parsing():
|
||||||
|
config = config_from_file('test_config.ini')
|
||||||
|
factory = mock.Mock(name='Template')
|
||||||
|
template = config.get_template('two', 'Templates', factory)
|
||||||
|
try:
|
||||||
|
tmpl_s = factory.call_args[0][0]
|
||||||
|
except IndexError as error:
|
||||||
|
assert False, error
|
||||||
|
assert "\n;Tag1: {value}\n" in tmpl_s
|
||||||
|
assert "\nIncome:Donations -100%\n" in tmpl_s
|
||||||
|
assert "\n;IncomeTag: Donations\n" in tmpl_s
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('arg_s', [None, '-', 'output.ledger'])
|
||||||
|
def test_output_path(arg_s):
|
||||||
|
arglist = [] if arg_s is None else ['-O', arg_s]
|
||||||
|
config = config_from_file(os.devnull, arglist)
|
||||||
|
output_path = config.get_output_path()
|
||||||
|
if (arg_s is None) or (arg_s == '-'):
|
||||||
|
assert output_path is None
|
||||||
|
else:
|
||||||
|
assert output_path == pathlib.Path(arg_s)
|
||||||
|
|
||||||
|
def test_output_path_from_section():
|
||||||
|
expected_path = pathlib.Path('Template.output')
|
||||||
|
config = config_from_file('test_config.ini', ['-O', 'output.ledger'])
|
||||||
|
assert config.get_output_path('Templates') == expected_path
|
||||||
|
assert config.get_output_path() != expected_path
|
||||||
|
with config.from_section('Templates'):
|
||||||
|
assert config.get_output_path() == expected_path
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('arglist,expect_date', [
|
||||||
|
([], None),
|
||||||
|
(['-d', '2017-10-12'], datetime.date(2017, 10, 12)),
|
||||||
|
(['-c', 'Date'], datetime.date(2017, 10, 8)),
|
||||||
|
])
|
||||||
|
def test_default_date(arglist, expect_date):
|
||||||
|
config = config_from_file('test_config.ini', arglist)
|
||||||
|
default_date = config.get_default_date()
|
||||||
|
if expect_date is None:
|
||||||
|
assert START_DATE <= default_date <= datetime.date.today()
|
||||||
|
else:
|
||||||
|
assert default_date == expect_date
|
||||||
|
assert config.get_default_date('Date') == datetime.date(2017, 10, 8)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('level_s,expect_level', [
|
||||||
|
(s, getattr(logging, s.upper()))
|
||||||
|
for s in ['critical', 'debug', 'error', 'info', 'warning']
|
||||||
|
])
|
||||||
|
def test_loglevel(level_s, expect_level):
|
||||||
|
config = config_from_file(os.devnull, ['--loglevel', level_s])
|
||||||
|
assert config.get_loglevel() == expect_level
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def bad_config(expect_input):
|
||||||
|
with pytest.raises(errors.UserInputConfigurationError) as exc_info:
|
||||||
|
yield exc_info
|
||||||
|
assert exc_info.value.user_input == expect_input
|
||||||
|
|
||||||
|
def test_bad_default_date():
|
||||||
|
date_s = '2017-10-06'
|
||||||
|
with bad_config(date_s):
|
||||||
|
config = config_from_file(os.devnull, ['--date', date_s])
|
||||||
|
config.get_default_date()
|
||||||
|
|
||||||
|
def test_bad_loglevel():
|
||||||
|
with bad_config('wraning'):
|
||||||
|
config = config_from_file('test_config.ini', ['-c', 'Bad Loglevel'])
|
||||||
|
config.get_loglevel()
|
||||||
|
|
||||||
|
def test_undefined_template():
|
||||||
|
template_name = 'template nonexistent'
|
||||||
|
config = config_from_file(os.devnull)
|
||||||
|
with bad_config(template_name):
|
||||||
|
config.get_template(template_name)
|
66
tests/test_hooks.py
Normal file
66
tests/test_hooks.py
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from import2ledger import hooks
|
||||||
|
from import2ledger.hooks import add_entity, default_date
|
||||||
|
|
||||||
|
def test_load_all():
|
||||||
|
all_hooks = list(hooks.load_all())
|
||||||
|
assert add_entity.AddEntityHook in all_hooks
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('payee,expected', [
|
||||||
|
('Alex Smith', 'Smith-Alex'),
|
||||||
|
('Dakota D. Doe', 'Doe-Dakota-D'),
|
||||||
|
('Björk', 'Bjork'),
|
||||||
|
('Fran Doe-Smith', 'Doe-Smith-Fran'),
|
||||||
|
('Alex(Nickname) Smith', 'Smith-Alex'),
|
||||||
|
('稲荷', '稲荷'),
|
||||||
|
])
|
||||||
|
def test_add_entity(payee, expected):
|
||||||
|
data = {'payee': payee}
|
||||||
|
hook = add_entity.AddEntityHook(argparse.Namespace())
|
||||||
|
hook.run(data)
|
||||||
|
assert data['entity'] == expected
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultDateConfig:
|
||||||
|
ONE_DAY = datetime.timedelta(days=1)
|
||||||
|
|
||||||
|
def __init__(self, start_date=None):
|
||||||
|
if start_date is None:
|
||||||
|
start_date = datetime.date(2016, 3, 5)
|
||||||
|
self.date = start_date - self.ONE_DAY
|
||||||
|
|
||||||
|
def get_default_date(self, section_name=None):
|
||||||
|
self.date += self.ONE_DAY
|
||||||
|
return self.date
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultDate:
|
||||||
|
def test_simple_case(self):
|
||||||
|
expect_date = datetime.date(2016, 2, 4)
|
||||||
|
config = DefaultDateConfig(expect_date)
|
||||||
|
data = {}
|
||||||
|
hook = default_date.DefaultDateHook(config)
|
||||||
|
hook.run(data)
|
||||||
|
assert data['date'] == expect_date
|
||||||
|
|
||||||
|
def test_no_caching(self):
|
||||||
|
config = DefaultDateConfig()
|
||||||
|
hook = default_date.DefaultDateHook(config)
|
||||||
|
d1 = {}
|
||||||
|
d2 = {}
|
||||||
|
hook.run(d1)
|
||||||
|
hook.run(d2)
|
||||||
|
assert d1['date'] != d2['date']
|
||||||
|
|
||||||
|
def test_no_override(self):
|
||||||
|
expect_date = datetime.date(2016, 2, 6)
|
||||||
|
config = DefaultDateConfig(expect_date + datetime.timedelta(days=300))
|
||||||
|
hook = default_date.DefaultDateHook(config)
|
||||||
|
data = {'date': expect_date}
|
||||||
|
hook.run(data)
|
||||||
|
assert data['date'] is expect_date
|
2
tests/test_import_patreon_income.py
Normal file
2
tests/test_import_patreon_income.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
import datetime
|
||||||
|
|
47
tests/test_importers.py
Normal file
47
tests/test_importers.py
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import datetime
|
||||||
|
import importlib
|
||||||
|
import itertools
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
from import2ledger import importers
|
||||||
|
|
||||||
|
from . import DATA_DIR
|
||||||
|
|
||||||
|
class TestImporters:
|
||||||
|
with pathlib.Path(DATA_DIR, 'imports.yml').open() as yaml_file:
|
||||||
|
test_data = yaml.load(yaml_file)
|
||||||
|
for test in test_data:
|
||||||
|
test['source'] = DATA_DIR / test['source']
|
||||||
|
|
||||||
|
module_name, class_name = test['importer'].rsplit('.', 1)
|
||||||
|
module = importlib.import_module('.' + module_name, 'import2ledger.importers')
|
||||||
|
test['importer'] = getattr(module, class_name)
|
||||||
|
|
||||||
|
for expect_result in test['expect']:
|
||||||
|
try:
|
||||||
|
expect_result['date'] = datetime.date(*expect_result['date'])
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('source_path,importer', [
|
||||||
|
(t['source'], t['importer']) for t in test_data
|
||||||
|
])
|
||||||
|
def test_can_import(self, source_path, importer):
|
||||||
|
with source_path.open() as source_file:
|
||||||
|
assert importer.can_import(source_file)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('source_path,import_class,expect_results', [
|
||||||
|
(t['source'], t['importer'], t['expect']) for t in test_data
|
||||||
|
])
|
||||||
|
def test_import(self, source_path, import_class, expect_results):
|
||||||
|
with source_path.open() as source_file:
|
||||||
|
importer = import_class(source_file)
|
||||||
|
for actual, expected in itertools.zip_longest(importer, expect_results):
|
||||||
|
assert actual == expected
|
||||||
|
|
||||||
|
def test_loader(self):
|
||||||
|
all_importers = list(importers.load_all())
|
||||||
|
for test in self.test_data:
|
||||||
|
assert test['importer'] in all_importers
|
52
tests/test_main.py
Normal file
52
tests/test_main.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import io
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from . import DATA_DIR, normalize_whitespace
|
||||||
|
|
||||||
|
from import2ledger import __main__ as i2lmain
|
||||||
|
|
||||||
|
ARGLIST = [
|
||||||
|
'-C', (DATA_DIR / 'test_main.ini').as_posix(),
|
||||||
|
]
|
||||||
|
|
||||||
|
def run_main(arglist):
|
||||||
|
stdout = io.StringIO()
|
||||||
|
stderr = io.StringIO()
|
||||||
|
exitcode = i2lmain.main(arglist, stdout, stderr)
|
||||||
|
stdout.seek(0)
|
||||||
|
stderr.seek(0)
|
||||||
|
return exitcode, stdout, stderr
|
||||||
|
|
||||||
|
def iter_entries(in_file):
|
||||||
|
lines = []
|
||||||
|
for line in in_file:
|
||||||
|
if line == '\n':
|
||||||
|
if lines:
|
||||||
|
yield ''.join(lines)
|
||||||
|
lines = []
|
||||||
|
else:
|
||||||
|
lines.append(line)
|
||||||
|
if lines:
|
||||||
|
yield ''.join(lines)
|
||||||
|
|
||||||
|
def entries2set(in_file):
|
||||||
|
return set(normalize_whitespace(e) for e in iter_entries(in_file))
|
||||||
|
|
||||||
|
def expected_entries(path):
|
||||||
|
path = pathlib.Path(path)
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = DATA_DIR / path
|
||||||
|
with path.open() as in_file:
|
||||||
|
return entries2set(in_file)
|
||||||
|
|
||||||
|
def test_fees_import():
|
||||||
|
arglist = ARGLIST + [
|
||||||
|
'-c', 'One',
|
||||||
|
pathlib.Path(DATA_DIR, 'PatreonEarnings.csv').as_posix(),
|
||||||
|
]
|
||||||
|
exitcode, stdout, _ = run_main(arglist)
|
||||||
|
print(_.getvalue())
|
||||||
|
assert exitcode == 0
|
||||||
|
actual = entries2set(stdout)
|
||||||
|
assert actual == expected_entries('test_main_fees_import.ledger')
|
75
tests/test_templates.py
Normal file
75
tests/test_templates.py
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import configparser
|
||||||
|
import datetime
|
||||||
|
import decimal
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from import2ledger import template
|
||||||
|
|
||||||
|
from . import DATA_DIR, normalize_whitespace
|
||||||
|
|
||||||
|
DATE = datetime.date(2015, 3, 14)
|
||||||
|
|
||||||
|
config = configparser.ConfigParser(comment_prefixes='#')
|
||||||
|
with pathlib.Path(DATA_DIR, 'templates.ini').open() as conffile:
|
||||||
|
config.read_file(conffile)
|
||||||
|
|
||||||
|
def template_from(section_name, *args, **kwargs):
|
||||||
|
return template.Template(config[section_name]['template'], *args, **kwargs)
|
||||||
|
|
||||||
|
def assert_easy_render(tmpl, entity, amount, currency, expect_date, expect_amt):
|
||||||
|
rendered = tmpl.render(entity, decimal.Decimal(amount), currency, DATE)
|
||||||
|
print(repr(rendered))
|
||||||
|
lines = [normalize_whitespace(s) for s in rendered.splitlines()]
|
||||||
|
assert lines == [
|
||||||
|
"",
|
||||||
|
"{} {}".format(expect_date, entity),
|
||||||
|
" Accrued:Accounts Receivable " + expect_amt,
|
||||||
|
" Income:Donations " + expect_amt.replace(amount, "-" + amount),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_easy_template():
|
||||||
|
tmpl = template_from('Simplest')
|
||||||
|
assert_easy_render(tmpl, 'JJ', '5.99', 'CAD', '2015/03/14', '5.99 CAD')
|
||||||
|
|
||||||
|
def test_date_formatting():
|
||||||
|
tmpl = template_from('Simplest', date_fmt='%Y-%m-%d')
|
||||||
|
assert_easy_render(tmpl, 'KK', '6.99', 'CAD', '2015-03-14', '6.99 CAD')
|
||||||
|
|
||||||
|
def test_currency_formatting():
|
||||||
|
tmpl = template_from('Simplest', signed_currencies=['USD'])
|
||||||
|
assert_easy_render(tmpl, 'CC', '7.99', 'USD', '2015/03/14', '$7.99')
|
||||||
|
|
||||||
|
def test_complex_template():
|
||||||
|
template_vars = {
|
||||||
|
'entity': 'T-T',
|
||||||
|
'txid': 'ABCDEF',
|
||||||
|
}
|
||||||
|
tmpl = template_from('Complex', date_fmt='%Y-%m-%d', signed_currencies=['USD'])
|
||||||
|
rendered = tmpl.render('TT', decimal.Decimal('125.50'), 'USD', DATE, **template_vars)
|
||||||
|
lines = [normalize_whitespace(s) for s in rendered.splitlines()]
|
||||||
|
assert lines == [
|
||||||
|
"",
|
||||||
|
"2015-03-14 TT",
|
||||||
|
" ;Tag: Value",
|
||||||
|
" ;TransactionID: ABCDEF",
|
||||||
|
" Accrued:Accounts Receivable $125.50",
|
||||||
|
" ;Entity: Supplier",
|
||||||
|
" Income:Donations:Specific $-119.85",
|
||||||
|
" ;Program: Specific",
|
||||||
|
" ;Entity: T-T",
|
||||||
|
" Income:Donations:General $-5.65",
|
||||||
|
" ;Entity: T-T",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_balancing():
|
||||||
|
tmpl = template_from('FiftyFifty')
|
||||||
|
rendered = tmpl.render('FF', decimal.Decimal('1.01'), 'USD', DATE)
|
||||||
|
lines = [normalize_whitespace(s) for s in rendered.splitlines()]
|
||||||
|
assert lines == [
|
||||||
|
"",
|
||||||
|
"2015/03/14 FF",
|
||||||
|
" Accrued:Accounts Receivable 1.01 USD",
|
||||||
|
" Income:Donations -0.50 USD",
|
||||||
|
" Income:Sales -0.51 USD",
|
||||||
|
]
|
Loading…
Reference in a new issue