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:
Brett Smith 2017-10-22 13:38:53 -04:00
commit 5c73c40bcc
29 changed files with 1099 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
build/
.cache/
*.egg
*.egg-info/
.eggs
__pycache__/

View file

84
import2ledger/__main__.py Normal file
View 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
View 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
View 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
View 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

View file

@ -0,0 +1,6 @@
import operator
from .. import util
def load_all():
return util.submodule_items_named(__file__, operator.methodcaller('endswith', 'Hook'))

View 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

View 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()

View file

@ -0,0 +1,6 @@
import operator
from .. import util
def load_all():
return util.submodule_items_named(__file__, operator.methodcaller('endswith', 'Importer'))

View 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
View 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
View 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
View file

@ -0,0 +1,2 @@
[aliases]
test=pytest

19
setup.py Executable file
View 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
View 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)

View 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%
1 Month Processed Pledges Creator's Share Processing Fees Patreon Fee Creator's Share (%) Processing Fees (%) Patreon Fee (%)
2 2017-09 $1234.50 $1,120.30 $52.47 $61.73 90.75% 4.25% 5.0%
3 2017-10 $2340.50 $2,124.00 $99.47 $117.03 90.75% 4.25% 5.0%

View 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 Youre 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
1 FirstName LastName Email Pledge Lifetime Status Twitter Street City State Zip Country Start MaxAmount Complete
2 100 + Reward Description You donate a lot of money!
3 Alex Jones alex@example.org 150 300 Processed 2017-08-11 11:28:06.166065 200 0
4 5 + Reward Description You’re nice!
5 Brett Smith brett@example.org 10 30 Declined Brett20XX 2017-08-10 13:24:15.955782 10 0
6 Dakota Doe ddoe@example.org 12 48 Processed 2017-08-10 12:58:31.919341 12 0

35
tests/data/imports.yml Normal file
View 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
View 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}

View 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
View 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%%

View 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
View 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
View 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

View file

@ -0,0 +1,2 @@
import datetime

47
tests/test_importers.py Normal file
View 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
View 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
View 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",
]