reports: Start BaseSpreadsheet class.
This commit is contained in:
parent
c88c5ef3b0
commit
d920c5842a
2 changed files with 152 additions and 0 deletions
|
@ -14,6 +14,7 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import abc
|
||||||
import collections
|
import collections
|
||||||
import operator
|
import operator
|
||||||
|
|
||||||
|
@ -31,6 +32,7 @@ from typing import (
|
||||||
Callable,
|
Callable,
|
||||||
DefaultDict,
|
DefaultDict,
|
||||||
Dict,
|
Dict,
|
||||||
|
Generic,
|
||||||
Iterable,
|
Iterable,
|
||||||
Iterator,
|
Iterator,
|
||||||
List,
|
List,
|
||||||
|
@ -51,6 +53,8 @@ from ..beancount_types import (
|
||||||
DecimalCompat = data.DecimalCompat
|
DecimalCompat = data.DecimalCompat
|
||||||
BalanceType = TypeVar('BalanceType', bound='Balance')
|
BalanceType = TypeVar('BalanceType', bound='Balance')
|
||||||
RelatedType = TypeVar('RelatedType', bound='RelatedPostings')
|
RelatedType = TypeVar('RelatedType', bound='RelatedPostings')
|
||||||
|
RT = TypeVar('RT', bound=Sequence)
|
||||||
|
ST = TypeVar('ST')
|
||||||
|
|
||||||
class Balance(Mapping[str, data.Amount]):
|
class Balance(Mapping[str, data.Amount]):
|
||||||
"""A collection of amounts mapped by currency
|
"""A collection of amounts mapped by currency
|
||||||
|
@ -275,3 +279,72 @@ class RelatedPostings(Sequence[data.Posting]):
|
||||||
default: Optional[MetaValue]=None,
|
default: Optional[MetaValue]=None,
|
||||||
) -> Set[Optional[MetaValue]]:
|
) -> Set[Optional[MetaValue]]:
|
||||||
return {post.meta.get(key, default) for post in self}
|
return {post.meta.get(key, default) for post in self}
|
||||||
|
|
||||||
|
|
||||||
|
class BaseSpreadsheet(Generic[RT, ST], metaclass=abc.ABCMeta):
|
||||||
|
"""Abstract base class to help write spreadsheets
|
||||||
|
|
||||||
|
This class provides the very core logic to write an arbitrary set of data
|
||||||
|
rows to arbitrary output. It calls hooks when it starts writing the
|
||||||
|
spreadsheet, starts a new "section" of rows, ends a section, and ends the
|
||||||
|
spreadsheet.
|
||||||
|
|
||||||
|
RT is the type of the input data rows. ST is the type of the section
|
||||||
|
identifier that you create from each row. If you don't want to use the
|
||||||
|
section logic at all, set ST to None and define section_key to return None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def section_key(self, row: RT) -> ST:
|
||||||
|
"""Return the section a row belongs to
|
||||||
|
|
||||||
|
Given a data row, this method should return some identifier for the
|
||||||
|
"section" the row belongs to. The write method uses this to
|
||||||
|
determine when to call start_section and end_section.
|
||||||
|
|
||||||
|
If your spreadsheet doesn't need sections, define this to return None.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def write_row(self, row: RT) -> None:
|
||||||
|
"""Write a data row to the output spreadsheet
|
||||||
|
|
||||||
|
This method is called once for each data row in the input.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# The next four methods are all called by the write method when the name
|
||||||
|
# says. You may override them to output headers or sums, record
|
||||||
|
# state, etc. The default implementations are all noops.
|
||||||
|
|
||||||
|
def start_spreadsheet(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def start_section(self, key: ST) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def end_section(self, key: ST) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def end_spreadsheet(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def write(self, rows: Iterable[RT]) -> None:
|
||||||
|
prev_section: Optional[ST] = None
|
||||||
|
self.start_spreadsheet()
|
||||||
|
for row in rows:
|
||||||
|
section = self.section_key(row)
|
||||||
|
if section != prev_section:
|
||||||
|
if prev_section is not None:
|
||||||
|
self.end_section(prev_section)
|
||||||
|
self.start_section(section)
|
||||||
|
prev_section = section
|
||||||
|
self.write_row(row)
|
||||||
|
try:
|
||||||
|
should_end = section is not None
|
||||||
|
except NameError:
|
||||||
|
should_end = False
|
||||||
|
if should_end:
|
||||||
|
self.end_section(section)
|
||||||
|
self.end_spreadsheet()
|
||||||
|
|
79
tests/test_reports_spreadsheet.py
Normal file
79
tests/test_reports_spreadsheet.py
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
"""test_reports_spreadsheet - Unit tests for spreadsheet classes"""
|
||||||
|
# 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 pytest
|
||||||
|
|
||||||
|
from . import testutil
|
||||||
|
|
||||||
|
from conservancy_beancount.reports import core
|
||||||
|
|
||||||
|
class BaseTester(core.BaseSpreadsheet[tuple, str]):
|
||||||
|
def __init__(self):
|
||||||
|
self.start_call = None
|
||||||
|
self.end_call = None
|
||||||
|
self.started_sections = []
|
||||||
|
self.ended_sections = []
|
||||||
|
self.written_rows = []
|
||||||
|
|
||||||
|
def section_key(self, row):
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
def start_spreadsheet(self):
|
||||||
|
self.start_call = self.started_sections.copy()
|
||||||
|
|
||||||
|
def start_section(self, key):
|
||||||
|
self.started_sections.append(key)
|
||||||
|
|
||||||
|
def end_section(self, key):
|
||||||
|
self.ended_sections.append(key)
|
||||||
|
|
||||||
|
def end_spreadsheet(self):
|
||||||
|
self.end_call = self.ended_sections.copy()
|
||||||
|
|
||||||
|
def write_row(self, key):
|
||||||
|
self.written_rows.append(key)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def spreadsheet():
|
||||||
|
return BaseTester()
|
||||||
|
|
||||||
|
def test_spreadsheet(spreadsheet):
|
||||||
|
rows = [(ch, ii) for ii, ch in enumerate('aabbcc', 1)]
|
||||||
|
spreadsheet.write(iter(rows))
|
||||||
|
assert spreadsheet.written_rows == rows
|
||||||
|
assert spreadsheet.ended_sections == spreadsheet.started_sections
|
||||||
|
assert spreadsheet.started_sections == list('abc')
|
||||||
|
assert spreadsheet.start_call == []
|
||||||
|
assert spreadsheet.end_call == spreadsheet.ended_sections
|
||||||
|
|
||||||
|
def test_empty_spreadsheet(spreadsheet):
|
||||||
|
empty_list = []
|
||||||
|
spreadsheet.write(iter(empty_list))
|
||||||
|
assert spreadsheet.start_call == empty_list
|
||||||
|
assert spreadsheet.end_call == empty_list
|
||||||
|
assert spreadsheet.started_sections == empty_list
|
||||||
|
assert spreadsheet.ended_sections == empty_list
|
||||||
|
assert spreadsheet.written_rows == empty_list
|
||||||
|
|
||||||
|
def test_one_section_spreadsheet(spreadsheet):
|
||||||
|
rows = [('A', n) for n in range(1, 4)]
|
||||||
|
spreadsheet.write(iter(rows))
|
||||||
|
assert spreadsheet.written_rows == rows
|
||||||
|
assert spreadsheet.ended_sections == spreadsheet.started_sections
|
||||||
|
assert spreadsheet.started_sections == list('A')
|
||||||
|
assert spreadsheet.start_call == []
|
||||||
|
assert spreadsheet.end_call == spreadsheet.ended_sections
|
Loading…
Reference in a new issue