From ab9c65d20dc5d6b1b8f5aa04d76f92287e32cb54 2017-10-22 20:10:17 From: Brett Smith Date: 2017-10-22 20:10:17 Subject: [PATCH] import2ledger: Support only importing entries in a date range. --- diff --git a/import2ledger/__main__.py b/import2ledger/__main__.py index c1184e297e60f0fa0898b562450a86c304f789c7..d131663fc60e6a7d457c7627e9e0162a50aca9aa 100644 --- a/import2ledger/__main__.py +++ b/import2ledger/__main__.py @@ -34,7 +34,10 @@ class FileImporter: 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='') + if not entry_data: + break + else: + print(template.render(**entry_data), file=out_file, end='') def import_path(self, in_path): if in_path is None: diff --git a/import2ledger/config.py b/import2ledger/config.py index ea637c80f96486956f8638690a5737e1907138d0..06e6a51d9e68d1a0764debd9b9fa835417280c31 100644 --- a/import2ledger/config.py +++ b/import2ledger/config.py @@ -19,6 +19,7 @@ class Configuration: TODAY = datetime.date.today() CONFIG_DEFAULTS = { 'date_format': '%%Y/%%m/%%d', + 'date_range': '-', 'loglevel': 'WARNING', 'output_path': '-', 'signed_currencies': ','.join(babel.numbers.get_territory_currencies( @@ -67,11 +68,6 @@ class Configuration: 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 " @@ -82,6 +78,17 @@ class Configuration: '--date-format', '-D', metavar='FORMAT', help="Date format to use in Ledger entries", ) + out_args.add_argument( + '--date-range', metavar='DATE-DATE', + help="Only import entries in this date range, inclusive. " + "Write dates in your configured date format. " + "You can omit either side of the range.", + ) + out_args.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( '--output-path', '-O', metavar='PATH', help="Path of file to append entries to, or '-' for stdout (default).", @@ -125,14 +132,36 @@ class Configuration: except KeyError: return default + def _parse_date_range(self, section_name): + section = self.conffile[section_name] + range_s = section['date_range'] + date_fmt = section['date_format'] + if not range_s: + range_s = '-' + if range_s.startswith('-'): + start_s = '' + end_s = range_s[1:] + elif range_s.endswith('-'): + start_s = range_s[:-1] + end_s = '' + else: + range_parts = range_s.split('-') + mid_index = len(range_parts) // 2 + start_s = '-'.join(range_parts[:mid_index]) + end_s = '-'.join(range_parts[mid_index:]) + start_d = self._strpdate(start_s, date_fmt) if start_s else datetime.date.min + end_d = self._strpdate(end_s, date_fmt) if end_s else datetime.date.max + return range(start_d.toordinal(), end_d.toordinal() + 1) + def finalize(self): + default_secname = self.conffile.default_section if self.args.use_config is None: - self.args.use_config = self.conffile.default_section + self.args.use_config = default_secname 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] + defaults = self.conffile[default_secname] for key in self.CONFIG_DEFAULTS: value = getattr(self.args, key) if value is None: @@ -140,7 +169,7 @@ class Configuration: elif key == 'signed_currencies': defaults[key] = ','.join(value) else: - defaults[key] = value + defaults[key] = value.replace('%', '%%') # We parse all the dates now to make sure they're valid. if self.args.date is not None: @@ -152,7 +181,10 @@ class Configuration: self.dates = {secname: self._parse_section_date(secname, default_date) for secname in self.conffile} - self.dates[self.conffile.default_section] = default_date + self.dates[default_secname] = default_date + self.date_ranges = {secname: self._parse_date_range(secname) + for secname in self.conffile} + self.date_ranges[default_secname] = self._parse_date_range(default_secname) @contextlib.contextmanager def from_section(self, section_name): @@ -168,13 +200,19 @@ class Configuration: section_name = self.args.use_config return self.conffile[section_name] - def get_default_date(self, section_name=None): + def _get_from_dict(self, confdict, section_name=None): if section_name is None: section_name = self.args.use_config try: - return self.dates[section_name] + return confdict[section_name] except KeyError: - return self.dates[self.conffile.default_section] + return confdict[self.conffile.default_section] + + def date_in_want_range(self, date, section_name=None): + return date.toordinal() in self._get_from_dict(self.date_ranges, section_name) + + def get_default_date(self, section_name=None): + return self._get_from_dict(self.dates, section_name) def get_loglevel(self, section_name=None): section_config = self._get_section(section_name) diff --git a/import2ledger/hooks/filter_by_date.py b/import2ledger/hooks/filter_by_date.py new file mode 100644 index 0000000000000000000000000000000000000000..c7dab317c66cfe5e6ef605e7fdcf033ec5a3db4c --- /dev/null +++ b/import2ledger/hooks/filter_by_date.py @@ -0,0 +1,12 @@ +class FilterByDateHook: + def __init__(self, config): + self.config = config + + def run(self, entry_data): + try: + date = entry_data['date'] + except KeyError: + pass + else: + if not self.config.date_in_want_range(date): + entry_data.clear() diff --git a/tests/test_config.py b/tests/test_config.py index d16a89dc513cc274005681aa616d2bbf83619311..7e38c8ea851fc61d9ea6e490b47c96c02bee9621 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ import contextlib import datetime +import itertools import logging import os import pathlib @@ -63,6 +64,22 @@ def test_output_path_from_section(): with config.from_section('Templates'): assert config.get_output_path() == expected_path +@pytest.mark.parametrize('range_s,date_fmt', [ + (range_s.replace('/', sep), sep.join(['%Y', '%m', '%d'])) + for range_s, sep in itertools.product([ + '-', + '2016/06/01-2016/06/30', + '2016/06/01-', + '-2016/06/30', + ], '/-') +]) +def test_date_in_want_range(range_s, date_fmt): + config = config_from_file(os.devnull, ['--date-range=' + range_s, '--date-format', date_fmt]) + assert config.date_in_want_range(datetime.date(2016, 5, 31)) == range_s.startswith('-') + assert config.date_in_want_range(datetime.date(2016, 6, 1)) + assert config.date_in_want_range(datetime.date(2016, 6, 30)) + assert config.date_in_want_range(datetime.date(2016, 7, 1)) == range_s.endswith('-') + @pytest.mark.parametrize('arglist,expect_date', [ ([], None), (['-d', '2017-10-12'], datetime.date(2017, 10, 12)), diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 270209f5f1b525ae2ec00ae781ca9b8555c547d8..583453e9c3e18f56e0e293fc0980b877314f3f0a 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -5,7 +5,7 @@ import itertools import pytest from import2ledger import hooks -from import2ledger.hooks import add_entity, default_date +from import2ledger.hooks import add_entity, default_date, filter_by_date def test_load_all(): all_hooks = list(hooks.load_all()) @@ -30,6 +30,36 @@ def test_add_entity(payee, expected): assert data['entity'] == expected +class DateRangeConfig: + def __init__(self, start_date=None, end_date=None): + self.start_date = start_date + self.end_date = end_date + + def date_in_want_range(self, date): + return ( + ((self.start_date is None) or (date >= self.start_date)) + and ((self.end_date is None) or (date <= self.end_date)) + ) + + +@pytest.mark.parametrize('entry_date,start_date,end_date,allowed', [ + (datetime.date(2016, 5, 10), datetime.date(2016, 1, 1), datetime.date(2016, 12, 31), True), + (datetime.date(2016, 1, 1), datetime.date(2016, 1, 1), datetime.date(2016, 12, 31), True), + (datetime.date(2016, 12, 31), datetime.date(2016, 1, 1), datetime.date(2016, 12, 31), True), + (datetime.date(2016, 1, 1), datetime.date(2016, 1, 1), None, True), + (datetime.date(2016, 12, 31), None, datetime.date(2016, 12, 31), True), + (datetime.date(1999, 1, 2), None, None, True), + (datetime.date(2016, 1, 25), datetime.date(2016, 2, 1), datetime.date(2016, 12, 31), False), + (datetime.date(2016, 12, 26), datetime.date(2016, 1, 1), datetime.date(2016, 11, 30), False), + (datetime.date(2016, 1, 31), datetime.date(2016, 2, 1), None, False), + (datetime.date(2016, 12, 1), None, datetime.date(2016, 11, 30), False), +]) +def test_filter_by_date(entry_date, start_date, end_date, allowed): + entry_data = {'date': entry_date} + hook = filter_by_date.FilterByDateHook(DateRangeConfig(start_date, end_date)) + hook.run(entry_data) + assert bool(entry_data) == allowed + class DefaultDateConfig: ONE_DAY = datetime.timedelta(days=1) diff --git a/tests/test_main.py b/tests/test_main.py index 9c131e3e345999dcd444c51cc2852c9adc7e714a..cb1264678ad7d54da02ca06d24c4ec4315f6577b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -49,3 +49,16 @@ def test_fees_import(): assert exitcode == 0 actual = entries2set(stdout) assert actual == expected_entries('test_main_fees_import.ledger') + +def test_date_range_import(): + arglist = ARGLIST + [ + '-c', 'One', + '--date-range', '2017/10/01-', + pathlib.Path(DATA_DIR, 'PatreonEarnings.csv').as_posix(), + ] + exitcode, stdout, _ = run_main(arglist) + assert exitcode == 0 + actual = entries2set(stdout) + expected = {entry for entry in expected_entries('test_main_fees_import.ledger') + if entry.startswith('2017/10/')} + assert actual == expected