books: Start Loader class.
This commit is contained in:
parent
adf402442b
commit
855c1c2bf0
2 changed files with 152 additions and 0 deletions
|
@ -16,13 +16,18 @@
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from typing import (
|
from typing import (
|
||||||
Iterable,
|
Iterable,
|
||||||
|
Mapping,
|
||||||
NamedTuple,
|
NamedTuple,
|
||||||
Optional,
|
Optional,
|
||||||
Union,
|
Union,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
PathLike = Union[str, Path]
|
||||||
|
PluginsSpec = Mapping[str, Optional[str]]
|
||||||
Year = Union[int, datetime.date]
|
Year = Union[int, datetime.date]
|
||||||
|
|
||||||
class FiscalYear(NamedTuple):
|
class FiscalYear(NamedTuple):
|
||||||
|
@ -71,3 +76,74 @@ class FiscalYear(NamedTuple):
|
||||||
elif from_fy < 1000:
|
elif from_fy < 1000:
|
||||||
from_fy, to_fy = to_fy, from_fy + to_fy
|
from_fy, to_fy = to_fy, from_fy + to_fy
|
||||||
return range(from_fy, to_fy + 1)
|
return range(from_fy, to_fy + 1)
|
||||||
|
|
||||||
|
|
||||||
|
class Loader:
|
||||||
|
"""Load Beancount books organized by fiscal year"""
|
||||||
|
|
||||||
|
DEFAULT_PLUGINS: PluginsSpec = {
|
||||||
|
'conservancy_beancount.plugin': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
books_root: Path,
|
||||||
|
fiscal_year: FiscalYear,
|
||||||
|
plugins: Optional[PluginsSpec]=None,
|
||||||
|
) -> None:
|
||||||
|
"""Set up a books loader
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
* books_root: A Path to a Beancount books checkout.
|
||||||
|
* fiscal_year: A FiscalYear object, used to determine what books to
|
||||||
|
load for a given date range.
|
||||||
|
* plugins: A mapping that specifies what plugins should be loaded
|
||||||
|
before any books. The keys are plugin names, and the values are the
|
||||||
|
configuration parameters string to follow. A value of None means the
|
||||||
|
plugin takes no configuration string. By default, the loader loads
|
||||||
|
conservancy_beancount.plugin.
|
||||||
|
"""
|
||||||
|
if plugins is None:
|
||||||
|
plugins = self.DEFAULT_PLUGINS
|
||||||
|
self.books_root = books_root
|
||||||
|
self.fiscal_year = fiscal_year
|
||||||
|
self.plugins = dict(plugins)
|
||||||
|
|
||||||
|
def _format_include(self, year: int, subdir: PathLike='') -> str:
|
||||||
|
file_path = Path(self.books_root, subdir, f'{year}.beancount')
|
||||||
|
return f'include "{file_path}"'
|
||||||
|
|
||||||
|
def _format_plugin(self, name: str, optstring: Optional[str]=None) -> str:
|
||||||
|
if optstring is None:
|
||||||
|
return f'plugin "{name}"'
|
||||||
|
else:
|
||||||
|
return f'plugin "{name}" "{optstring}"'
|
||||||
|
|
||||||
|
def fy_range_string(self,
|
||||||
|
from_fy: Year,
|
||||||
|
to_fy: Optional[Year]=None,
|
||||||
|
plugins: Optional[PluginsSpec]=None,
|
||||||
|
) -> str:
|
||||||
|
"""Return a string to load books for a range of fiscal years
|
||||||
|
|
||||||
|
This method generates a range of fiscal years by calling
|
||||||
|
FiscalYear.range() with its first two arguments. It returns a string of
|
||||||
|
Beancount directives to load all plugins and Beancount files for that
|
||||||
|
range of fiscal years, suitable for passing to
|
||||||
|
beancount.loader.load_string().
|
||||||
|
|
||||||
|
You can specify what plugins to load with the plugins argument. If not
|
||||||
|
specified, the string loads the plugins specified for this instance.
|
||||||
|
See the __init__ docstring for details.
|
||||||
|
"""
|
||||||
|
if plugins is None:
|
||||||
|
plugins = self.plugins
|
||||||
|
years = iter(self.fiscal_year.range(from_fy, to_fy))
|
||||||
|
try:
|
||||||
|
books_start = self._format_include(next(years), 'books')
|
||||||
|
except StopIteration:
|
||||||
|
return ''
|
||||||
|
return '\n'.join([
|
||||||
|
*(self._format_plugin(name, opts) for name, opts in plugins.items()),
|
||||||
|
books_start,
|
||||||
|
*(self._format_include(year) for year in years),
|
||||||
|
])
|
||||||
|
|
76
tests/test_books_loader.py
Normal file
76
tests/test_books_loader.py
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
"""test_books_loader - Unit tests for books Loader class"""
|
||||||
|
# Copyright © 2020 Brett Smith
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from . import testutil
|
||||||
|
|
||||||
|
from conservancy_beancount import books
|
||||||
|
|
||||||
|
books_path = testutil.test_path('booksroot')
|
||||||
|
|
||||||
|
@pytest.fixture(scope='module')
|
||||||
|
def conservancy_loader():
|
||||||
|
return books.Loader(books_path, books.FiscalYear(3))
|
||||||
|
|
||||||
|
def format_include(year, subdir=''):
|
||||||
|
path = Path(books_path, subdir, f'{year}.beancount')
|
||||||
|
return f'include "{path}"'
|
||||||
|
|
||||||
|
def format_plugin(name, optstring=None):
|
||||||
|
if optstring is None:
|
||||||
|
return f'plugin "{name}"'
|
||||||
|
else:
|
||||||
|
return f'plugin "{name}" "{optstring}"'
|
||||||
|
|
||||||
|
def expect_string(years, plugins={'conservancy_beancount.plugin': None}):
|
||||||
|
years = iter(years)
|
||||||
|
year1_s = format_include(next(years), 'books')
|
||||||
|
return '\n'.join([
|
||||||
|
*(format_plugin(name, opts) for name, opts in plugins.items()),
|
||||||
|
year1_s,
|
||||||
|
*(format_include(year) for year in years),
|
||||||
|
])
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('range_start,range_stop,expect_years', [
|
||||||
|
(2019, 2020, [2019, 2020]),
|
||||||
|
(-1, 2020, [2019, 2020]),
|
||||||
|
(date(2019, 1, 1), date(2020, 6, 1), range(2018, 2021)),
|
||||||
|
(-1, date(2020, 2, 1), [2018, 2019]),
|
||||||
|
])
|
||||||
|
def test_fy_range_string(conservancy_loader, range_start, range_stop, expect_years):
|
||||||
|
expected = expect_string(expect_years)
|
||||||
|
assert conservancy_loader.fy_range_string(range_start, range_stop) == expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('year_offset', range(-3, 1))
|
||||||
|
def test_fy_range_string_one_offset(conservancy_loader, year_offset):
|
||||||
|
this_year = date.today().year
|
||||||
|
expected = expect_string(range(this_year + year_offset, this_year + 1))
|
||||||
|
assert conservancy_loader.fy_range_string(year_offset) == expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('plugins', [
|
||||||
|
{},
|
||||||
|
{'conservancy_beancount.plugin': '-all'},
|
||||||
|
])
|
||||||
|
def test_fy_range_string_plugins_override(conservancy_loader, plugins):
|
||||||
|
expected = expect_string([2019, 2020], plugins)
|
||||||
|
assert conservancy_loader.fy_range_string(2019, 2020, plugins) == expected
|
||||||
|
|
||||||
|
def test_fy_range_string_empty_range(conservancy_loader):
|
||||||
|
assert conservancy_loader.fy_range_string(2020, 2019) == ''
|
Loading…
Reference in a new issue