conservancy_beancount/conservancy_beancount/books.py
Brett Smith 072937eff5 books.Loader: New loading strategy.
The old loading strategy didn't load options, which yielded some
spurious errors. It also created awkward duplication of plugin
information in the code as well as the books.

Implement a new loading strategy that works by reading one of the
"main files" under the books/ subdirectory and includes entries
for additional FYs beyond that.

This is still not ideal in a lot of ways. In particular, Beancount can't
cache any results, causing any load to be slower than it theoretically could
be. I expect more commits to follow. But some of them might require
restructuring the books, and that should happen separately.
2020-05-05 14:31:08 -04:00

163 lines
5.2 KiB
Python

"""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 contextlib
import datetime
import os
from pathlib import Path
from beancount import loader as bc_loader
from typing import (
Any,
Iterable,
Iterator,
Mapping,
NamedTuple,
Optional,
Union,
)
from .beancount_types import (
LoadResult,
)
PathLike = Union[str, Path]
Year = Union[int, datetime.date]
@contextlib.contextmanager
def workdir(path: PathLike) -> Iterator[Path]:
old_dir = os.getcwd()
os.chdir(path)
try:
yield Path(old_dir)
finally:
os.chdir(old_dir)
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
elif from_fy < 1000:
from_fy, to_fy = to_fy, from_fy + to_fy
return range(from_fy, to_fy + 1)
class Loader:
"""Load Beancount books organized by fiscal year"""
def __init__(self,
books_root: Path,
fiscal_year: FiscalYear,
) -> 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.
"""
self.books_root = books_root
self.opening_root = books_root / 'books'
self.fiscal_year = fiscal_year
def _iter_fy_books(self, fy_range: Iterable[int]) -> Iterator[Path]:
dir_path = self.opening_root
for year in fy_range:
path = dir_path / f'{year}.beancount'
if path.exists():
yield path
dir_path = self.books_root
def fy_range_string(self,
from_fy: Year,
to_fy: Optional[Year]=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 the books from the first available fiscal
year through the end of the range.
Pass the string to Loader.load_string() to actually load data from it.
"""
paths = self._iter_fy_books(self.fiscal_year.range(from_fy, to_fy))
try:
with next(paths).open() as opening_books:
lines = [opening_books.read()]
except StopIteration:
return ''
for path in paths:
lines.append(f'include "../{path.name}"')
return '\n'.join(lines)
def load_string(self, source: str) -> LoadResult:
"""Load a generated string of Beancount directives
This method takes a string generated by another Loader method, like
fy_range_string, and loads it through Beancount, setting up the
environment as necessary to do that.
"""
with workdir(self.opening_root):
retval: LoadResult = bc_loader.load_string(source)
return retval
def load_fy_range(self,
from_fy: Year,
to_fy: Optional[Year]=None,
) -> LoadResult:
"""Load books for a range of fiscal years"""
return self.load_string(self.fy_range_string(from_fy, to_fy))