diff --git a/conservancy_beancount/books.py b/conservancy_beancount/books.py new file mode 100644 index 0000000..300bfac --- /dev/null +++ b/conservancy_beancount/books.py @@ -0,0 +1,74 @@ +"""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 . + +import datetime + +from typing import ( + Iterable, + NamedTuple, + Optional, + Union, +) + +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: Union[int, datetime.date], + to_fy: Union[int, datetime.date, None]=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 + elif from_fy < 1000: + from_fy, to_fy = to_fy, from_fy + to_fy + return range(from_fy, to_fy + 1) diff --git a/tests/test_books_fiscal_year.py b/tests/test_books_fiscal_year.py new file mode 100644 index 0000000..9327ab0 --- /dev/null +++ b/tests/test_books_fiscal_year.py @@ -0,0 +1,136 @@ +"""test_books_fiscal_year - Unit tests for books.FiscalYear""" +# 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 . + +import datetime +import itertools + +import pytest + +from conservancy_beancount import books + +@pytest.fixture(scope='module') +def conservancy_fy(): + return books.FiscalYear(3, 1) + +@pytest.fixture(scope='module') +def cy_fy(): + return books.FiscalYear(1, 1) + +# Not bothering because it's too much trouble to override __new__ +# @pytest.mark.parametrize('month,day', [ +# (0, 0), +# (2, 29), +# (4, 31), +# (13, 15), +# (-1, 1), +# (1, -5), +# ]) +# def test_values_checked(month, day): +# with pytest.raises(ValueError): +# books.FiscalYear(month, day) + +def test_attribute_access(): + fy = books.FiscalYear(2, 15) + assert fy.month == 2 + assert fy.day == 15 + with pytest.raises(AttributeError): + fy.year + +@pytest.mark.parametrize('month,day,expected', [ + (1, 1, 2019), + (2, 15, 2019), + (2, 28, 2019), + (2, 29, 2019), + (3, 1, 2020), + (3, 9, 2020), + (6, 1, 2020), + (12, 31, 2020), +]) +def test_for_date(conservancy_fy, month, day, expected): + date = datetime.date(2020, month, day) + assert conservancy_fy.for_date(date) == expected + +def test_for_date_default_today(cy_fy): + assert cy_fy.for_date() == datetime.date.today().year + +@pytest.mark.parametrize('begin_date,end_date,expected', [ + ((2020, 3, 1), (2021, 2, 28), [2020]), + ((2020, 1, 1), (2020, 3, 1), [2019]), + ((2020, 1, 1), (2020, 3, 5), [2019, 2020]), + ((2019, 2, 1), (2020, 6, 1), [2018, 2019, 2020]), +]) +def test_range_two_dates(conservancy_fy, begin_date, end_date, expected): + actual = list(conservancy_fy.range(datetime.date(*begin_date), datetime.date(*end_date))) + assert actual == expected + +@pytest.mark.parametrize('year_offset', range(3)) +def test_range_one_date(cy_fy, year_offset): + this_year = datetime.date.today().year + actual = list(cy_fy.range(datetime.date(this_year - year_offset, 1, 1))) + assert actual == list(range(this_year - year_offset, this_year + 1)) + +@pytest.mark.parametrize('begin_year,end_year', [ + (2006, 2020), + (2019, 2020), + (2020, 2020), +]) +def test_range_two_years(conservancy_fy, begin_year, end_year): + actual = list(conservancy_fy.range(begin_year, end_year)) + assert actual == list(range(begin_year, end_year + 1)) + +@pytest.mark.parametrize('year_offset', range(3)) +def test_range_one_year(cy_fy, year_offset): + this_year = datetime.date.today().year + actual = list(cy_fy.range(this_year - year_offset)) + assert actual == list(range(this_year - year_offset, this_year + 1)) + +@pytest.mark.parametrize('year_offset,month_offset', itertools.product( + range(-3, 3), + [-1, 1] +)) +def test_range_offset_and_date(conservancy_fy, year_offset, month_offset): + end_date = datetime.date(2020, conservancy_fy.month + month_offset, 10) + base_year = end_date.year + if month_offset < 0: + base_year -= 1 + if year_offset < 0: + expected = range(base_year + year_offset, base_year + 1) + else: + expected = range(base_year, base_year + year_offset + 1) + actual = list(conservancy_fy.range(year_offset, end_date)) + assert actual == list(expected) + +@pytest.mark.parametrize('year_offset,year', itertools.product( + range(-3, 3), + [2010, 2015], +)) +def test_range_offset_and_year(conservancy_fy, year_offset, year): + if year_offset < 0: + expected = range(year + year_offset, year + 1) + else: + expected = range(year, year + year_offset + 1) + actual = list(conservancy_fy.range(year_offset, year)) + assert actual == list(expected) + +@pytest.mark.parametrize('year_offset', range(-3, 3)) +def test_range_offset_only(cy_fy, year_offset): + year = datetime.date.today().year + if year_offset < 0: + expected = range(year + year_offset, year + 1) + else: + expected = range(year, year + year_offset + 1) + actual = list(cy_fy.range(year_offset)) + assert actual == list(expected)