Changeset - 96a363633f5e
[Not reviewed]
0 2 0
Brett Smith - 4 years ago 2020-04-21 15:58:28
brettcsmith@brettcsmith.org
books: Add Loader.load_fy_range() method.
2 files changed with 28 insertions and 0 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/books.py
Show inline comments
 
"""books - Tools for loading the books"""
 
# 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/>.
 

	
 
import datetime
 

	
 
from pathlib import Path
 

	
 
from beancount import loader as bc_loader
 

	
 
from typing import (
 
    Any,
 
    Iterable,
 
    Mapping,
 
    NamedTuple,
 
    Optional,
 
    Union,
 
)
 
from .beancount_types import (
 
    LoadResult,
 
)
 

	
 
PathLike = Union[str, Path]
 
PluginsSpec = Mapping[str, Optional[str]]
 
Year = Union[int, datetime.date]
 

	
 
class FiscalYear(NamedTuple):
 
    month: int = 3
 
    day: int = 1
 

	
 
    def for_date(self, date: Optional[datetime.date]=None) -> int:
 
        if date is None:
 
            date = datetime.date.today()
 
        if (date.month, date.day) < self:
 
            return date.year - 1
 
        else:
 
            return date.year
 

	
 
    def range(self, from_fy: Year, to_fy: Optional[Year]=None) -> Iterable[int]:
 
        """Return a range of fiscal years
 

	
 
        Both arguments can be either a year (represented as an integer) or a
 
        date. Dates will be converted into a year by calling for_date() on
 
        them.
 

	
 
        If the first argument is negative or below 1000, it will be treated as
 
        an offset. You'll get a range of fiscal years between the second
 
        argument offset by this amount.
 

	
 
        If the second argument is omitted, it defaults to the current fiscal
 
        year.
 

	
 
        Note that unlike normal Python ranges, these ranges include the final
 
        fiscal year.
 

	
 
        Examples:
 

	
 
          range(2015)  # Iterate all fiscal years from 2015 to today, inclusive
 

	
 
          range(-1)  # Iterate the previous fiscal year and current fiscal year
 
        """
 
        if not isinstance(from_fy, int):
 
            from_fy = self.for_date(from_fy)
 
        if to_fy is None:
 
            to_fy = self.for_date()
 
        elif not isinstance(to_fy, int):
 
            to_fy = self.for_date(to_fy - datetime.timedelta(days=1))
 
        if from_fy < 1:
 
            from_fy += to_fy
...
 
@@ -102,48 +108,63 @@ class Loader:
 
          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),
 
        ])
 

	
 
    def load_fy_range(self,
 
                      from_fy: Year,
 
                      to_fy: Optional[Year]=None,
 
                      plugins: Optional[PluginsSpec]=None,
 
    ) -> LoadResult:
 
        """Load books for a range of fiscal years
 

	
 
        This is a convenience wrapper to call
 
        self.fy_range_string(from_fy, to_fy, plugins)
 
        and load the result with beancount.loader.load_string.
 
        """
 
        return bc_loader.load_string(  # type:ignore[no-untyped-call, no-any-return]
 
            self.fy_range_string(from_fy, to_fy, plugins),
 
        )
tests/test_books_loader.py
Show inline comments
 
"""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/>.
 

	
 
import hashlib
 

	
 
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) == ''
 

	
 
def test_load_fy_range_empty(conservancy_loader):
 
    entries, errors, options_map = conservancy_loader.load_fy_range(2020, 2019)
 
    assert not entries
 
    assert options_map.get('input_hash') == hashlib.md5().hexdigest()
0 comments (0 inline, 0 general)