Changeset - 6703d1af87ad
[Not reviewed]
0 2 0
Brett Smith - 3 years ago 2021-03-15 17:19:03
brettcsmith@brettcsmith.org
reports: BaseODS puts each line of strings in a P tag.

This seems to be the most straightforward way to get Calc to automatically
determine a nice row height for multi-line string cells. This has become a
lot more noticeable now that query-report supports putting postal addresses
in cells.
2 files changed with 13 insertions and 4 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reports/core.py
Show inline comments
...
 
@@ -1343,214 +1343,221 @@ class BaseODS(BaseSpreadsheet[RT, ST], metaclass=abc.ABCMeta):
 
            when: Optional[datetime.datetime]=None,
 
            parent: Optional[odf.table.TableCell]=None,
 
    ) -> odf.office.Annotation:
 
        if when is None:
 
            when = datetime.datetime.now()
 
        retval = odf.office.Annotation()
 
        retval.appendChild(odf.dc.Date(text=when.isoformat(timespec='seconds')))
 
        if text is not None:
 
            retval.appendChild(odf.text.P(text=text))
 
        if parent is not None:
 
            parent.appendChild(retval)
 
        return retval
 

	
 
    def add_row(self, *cells: odf.table.TableCell, **attrs: Any) -> odf.table.TableRow:
 
        row = odf.table.TableRow(**attrs)
 
        for cell in cells:
 
            row.addElement(cell)
 
        self.sheet.addElement(row)
 
        return row
 

	
 
    def row_count(self, sheet: Optional[odf.table.Table]=None) -> int:
 
        if sheet is None:
 
            sheet = self.sheet
 
        TableRow = odf.table.TableRow
 
        return sum(1 for cell in sheet.childNodes if cell.isInstanceOf(TableRow))
 

	
 
    def balance_cell(self, balance: Balance, **attrs: Any) -> odf.table.TableCell:
 
        balance = balance.clean_copy() or balance
 
        balance_currency_count = len(balance)
 
        if balance_currency_count == 0:
 
            return self.float_cell(0, **attrs)
 
        elif balance_currency_count == 1:
 
            amount = next(iter(balance.values()))
 
            attrs['stylename'] = self.merge_styles(
 
                attrs.get('stylename'), self.currency_style(amount.currency),
 
            )
 
            return self.currency_cell(amount, **attrs)
 
        else:
 
            lines = [babel.numbers.format_currency(number, currency, get_commodity_format(
 
                self.locale, currency, None, self.currency_fmt_key,
 
            )) for number, currency in balance.values()]
 
            attrs['stylename'] = self.merge_styles(
 
                attrs.get('stylename'), self.style_endtext,
 
            )
 
            return self.multiline_cell(lines, **attrs)
 

	
 
    def currency_cell(self, amount: bc_amount._Amount, **attrs: Any) -> odf.table.TableCell:
 
        if 'stylename' not in attrs:
 
            attrs['stylename'] = self.currency_style(amount.currency)
 
        number, currency = amount
 
        cell = odf.table.TableCell(valuetype='currency', value=number, **attrs)
 
        cell.addElement(odf.text.P(text=babel.numbers.format_currency(
 
            number, currency, locale=self.locale, format_type=self.currency_fmt_key,
 
        )))
 
        return cell
 

	
 
    def date_cell(self, date: datetime.date, **attrs: Any) -> odf.table.TableCell:
 
        attrs.setdefault('stylename', self.style_date)
 
        cell = odf.table.TableCell(valuetype='date', datevalue=date, **attrs)
 
        cell.addElement(odf.text.P(text=date.isoformat()))
 
        return cell
 

	
 
    def float_cell(self, value: Union[int, float, Decimal], **attrs: Any) -> odf.table.TableCell:
 
        cell = odf.table.TableCell(valuetype='float', value=value, **attrs)
 
        cell.addElement(odf.text.P(text=str(value)))
 
        return cell
 

	
 
    def _meta_link_pairs(self, links: Iterable[Optional[str]]) -> Iterator[Tuple[str, str]]:
 
        for href in links:
 
            if href is None:
 
                continue
 
            elif self.rt_wrapper is not None:
 
                rt_ids = self.rt_wrapper.parse(href)
 
                rt_href = rt_ids and self.rt_wrapper.url(*rt_ids)
 
            else:
 
                rt_ids = None
 
                rt_href = None
 
            if rt_ids is None or rt_href is None:
 
                # '..' pops the ODS filename off the link path. In other words,
 
                # make the link relative to the directory the ODS is in.
 
                href_path = Path('..', href)
 
                href = urlparse.quote(str(href_path))
 
                text = href_path.name
 
            else:
 
                rt_path = urlparse.urlparse(rt_href).path
 
                if rt_path.endswith('/Ticket/Display.html'):
 
                    text = rtutil.RT.unparse(*rt_ids)
 
                else:
 
                    text = urlparse.unquote(Path(rt_path).name)
 
                href = rt_href
 
            yield (href, text)
 

	
 
    def meta_links_cell(self, links: Iterable[Optional[str]], **attrs: Any) -> odf.table.TableCell:
 
        return self.multilink_cell(self._meta_link_pairs(links), **attrs)
 

	
 
    def multiline_cell(self, lines: Iterable[Any], **attrs: Any) -> odf.table.TableCell:
 
        item_lines = [str(item).splitlines() for item in lines if item is not None]
 
        if any(len(seq) > 1 for seq in item_lines):
 
            for seq in item_lines:
 
                seq.append('')
 
            seq.pop()
 
        cell = odf.table.TableCell(valuetype='string', **attrs)
 
        for line in lines:
 
            cell.addElement(odf.text.P(text=str(line)))
 
        for seq in item_lines:
 
            for line in seq:
 
                cell.addElement(odf.text.P(text=line))
 
        return cell
 

	
 
    def multilink_cell(self, links: Iterable[LinkType], **attrs: Any) -> odf.table.TableCell:
 
        cell = odf.table.TableCell(valuetype='string', **attrs)
 
        for link in links:
 
            if isinstance(link, tuple):
 
                href, text = link
 
            else:
 
                href = link
 
                text = None
 
            cell.addElement(odf.text.P())
 
            cell.lastChild.addElement(odf.text.A(
 
                type='simple', href=href, text=text or href,
 
            ))
 
        return cell
 

	
 
    def string_cell(self, text: str, **attrs: Any) -> odf.table.TableCell:
 
        cell = odf.table.TableCell(valuetype='string', **attrs)
 
        cell.addElement(odf.text.P(text=text))
 
        for line in text.splitlines():
 
            cell.addElement(odf.text.P(text=line))
 
        return cell
 

	
 
    def write_row(self, row: RT) -> None:
 
        """Write a single row of input data to the spreadsheet
 

	
 
        This default implementation adds a single row to the spreadsheet,
 
        with one cell per element of the row. The type of each element
 
        determines what kind of cell is created.
 

	
 
        This implementation will help get you started, but you'll probably
 
        want to override it to specify styles.
 
        """
 
        out_row = odf.table.TableRow()
 
        for cell_source in row:
 
            if isinstance(cell_source, (int, float, Decimal)):
 
                cell = self.float_cell(cell_source)
 
            else:
 
                cell = self.string_cell(cell_source)
 
            out_row.addElement(cell)
 
        self.sheet.addElement(out_row)
 

	
 
    def save_file(self, out_file: BinaryIO) -> None:
 
        self.document.write(out_file)
 

	
 
    def save_path(self, path: Path, mode: str='w') -> None:
 
        with path.open(f'{mode}b') as out_file:
 
            out_file = cast(BinaryIO, out_file)
 
            self.save_file(out_file)
 

	
 

	
 
def account_balances(
 
        groups: Mapping[data.Account, PeriodPostings],
 
        order: Optional[Sequence[str]]=None,
 
) -> Iterator[Tuple[str, Balance]]:
 
    """Iterate account balances over a date range
 

	
 
    1. ``subclass = PeriodPostings.with_start_date(start_date)``
 
    2. ``groups = dict(subclass.group_by_account(postings))``
 
    3. ``for acct, bal in account_balances(groups, [optional ordering]): ...``
 

	
 
    This function returns an iterator of 2-tuples ``(account, balance)``
 
    that you can use to generate a report in the style of ``ledger balance``.
 
    The accounts are accounts in ``groups`` that appeared under one of the
 
    account name strings in ``order``. ``balance`` is the corresponding
 
    balance over the time period (``groups[key].period_bal``). Accounts are
 
    iterated in the order provided by ``sort_and_filter_accounts()``.
 

	
 
    The first 2-tuple is ``(OPENING_BALANCE_NAME, balance)`` with the balance of
 
    all these accounts as of ``start_date``.
 
    The final 2-tuple is ``(ENDING_BALANCE_NAME, balance)`` with the final
 
    balance of all these accounts as of ``start_date``.
 
    The iterator will always yield these special 2-tuples, even when there are
 
    no accounts in the input or to report.
 
    """
 
    if order is None:
 
        order = ['Equity', 'Income', 'Expenses']
 
    acct_seq = [account for _, account in sort_and_filter_accounts(groups, order)]
 
    yield (OPENING_BALANCE_NAME, sum(
 
        (groups[key].start_bal for key in acct_seq),
 
        MutableBalance(),
 
    ))
 
    for key in acct_seq:
 
        postings = groups[key]
 
        try:
 
            in_date_range = postings[-1].meta.date >= postings.START_DATE
 
        except IndexError:
 
            in_date_range = False
 
        if in_date_range:
 
            yield (key, groups[key].period_bal)
 
    yield (ENDING_BALANCE_NAME, sum(
 
        (groups[key].stop_bal for key in acct_seq),
 
        MutableBalance(),
 
    ))
 

	
 
def get_commodity_format(locale: babel.core.Locale,
 
                         code: str,
 
                         amount: Optional[DecimalCompat]=None,
 
                         format_type: str='accounting',
 
) -> str:
 
    """Return a format string for a commodity
 

	
 
    Typical use looks like::
 

	
 
      number, code = post.units
 
      fmt = get_commodity_format(locale, code)
 
      units_s = babel.numbers.format_currency(number, code, fmt)
 

	
 
    When the commodity code refers to a real currency, you get the same format
 
    string provided by Babel.
 

	
 
    For other commodities like stock, you get a format code built from the
 
    locale's currency unit pattern.
 

	
 
    If ``amount`` is defined, the format string will be specifically for that
 
    number, whether positive or negative. Otherwise, the format string may
 
    define both positive and negative formats.
tests/test_reports_fund.py
Show inline comments
...
 
@@ -100,193 +100,195 @@ def format_amount(amount, currency='USD'):
 
        amount, currency, format_type='accounting',
 
    )
 

	
 
def check_text_balances(actual, expected, *expect_accounts):
 
    balance = Decimal()
 
    for expect_account in expect_accounts:
 
        expect_amount = expected[expect_account]
 
        balance += expect_amount
 
        if expect_account.startswith('Expenses:'):
 
            expect_amount *= -1
 
        if expect_amount:
 
            actual_account, actual_amount = next(actual)
 
            assert actual_account == expect_account
 
            assert actual_amount == format_amount(expect_amount)
 
    return balance
 

	
 
def check_text_report(output, project, start_date, stop_date):
 
    _, _, project = project.rpartition('=')
 
    balance_amount = Decimal(OPENING_BALANCES[project])
 
    expected = collections.defaultdict(Decimal)
 
    for year in range(2018, stop_date.year):
 
        try:
 
            amounts = BALANCES_BY_YEAR[(project, year)]
 
        except KeyError:
 
            pass
 
        else:
 
            for account, amount in amounts:
 
                if year < start_date.year and account.startswith(EQUITY_ROOT_ACCOUNTS):
 
                    balance_amount += amount
 
                else:
 
                    expected[account] += amount
 
    actual = split_text_lines(output)
 
    next(actual); next(actual)  # Discard headers
 
    open_acct, open_amt = next(actual)
 
    assert open_acct == "{} balance as of {}".format(
 
        project, start_date.isoformat(),
 
    )
 
    assert open_amt == format_amount(balance_amount)
 
    balance_amount += check_text_balances(
 
        actual, expected,
 
        'Income:Other',
 
        'Expenses:Other',
 
        'Equity:Funds:Restricted',
 
        'Equity:Funds:Unrestricted',
 
        'Equity:Realized:CurrencyConversion',
 
    )
 
    next(actual)
 
    end_acct, end_amt = next(actual)
 
    assert end_acct == "{} balance as of {}".format(
 
        project, stop_date.isoformat(),
 
    )
 
    assert end_amt == format_amount(balance_amount)
 
    next(actual)
 
    balance_amount += check_text_balances(
 
        actual, expected,
 
        'Assets:Receivable:Accounts',
 
        'Assets:Prepaid:Expenses',
 
        'Liabilities:Payable:Accounts',
 
        'Liabilities:UnearnedIncome',
 
    )
 
    assert next(actual, None) is None
 

	
 
def check_cell_balance(cell, balance):
 
    if balance:
 
        assert cell.value == balance
 
    else:
 
        assert not cell.value
 

	
 
def check_ods_sheet(sheet, account_balances, *, full):
 
    total_keys = ['opening', 'Income', 'Expenses', 'Equity']
 
    if full:
 
        account_bals = account_balances.copy()
 
        unrestricted = account_bals.pop('Conservancy')
 
        total_keys += [
 
            'Assets:Receivable',
 
            'Assets:Prepaid',
 
            'Liabilities',
 
            'Liabilities:Payable',
 
        ]
 
    else:
 
        account_bals = {
 
            key: balances
 
            for key, balances in account_balances.items()
 
            if key != 'Conservancy' and any(v >= .5 for v in balances.values())
 
        }
 
    totals = {key: Decimal() for key in total_keys}
 
    for fund, balances in account_bals.items():
 
        for key in totals:
 
            totals[key] += balances[key]
 
    account_bals[''] = totals
 
    if full:
 
        account_bals['Unrestricted'] = unrestricted
 
    for row in itertools.islice(sheet.getElementsByType(odf.table.TableRow), 4, None):
 
        cells = iter(testutil.ODSCell.from_row(row))
 
        try:
 
            fund = next(cells).firstChild.text
 
        except (AttributeError, StopIteration):
 
        except AttributeError:
 
            fund = ''
 
        except StopIteration:
 
            continue
 
        try:
 
            balances = account_bals.pop(fund)
 
        except KeyError:
 
            pytest.fail(f"report included unexpected fund {fund}")
 
        check_cell_balance(next(cells), balances['opening'])
 
        check_cell_balance(next(cells), balances['Income'])
 
        if full:
 
            check_cell_balance(next(cells), -balances['Expenses'])
 
            check_cell_balance(next(cells), balances['Equity'])
 
        else:
 
            check_cell_balance(
 
                next(cells), -sum(balances[key] for key in ['Expenses', 'Equity']),
 
            )
 
        check_cell_balance(next(cells), sum(balances[key] for key in [
 
            'opening', 'Income', 'Expenses', 'Equity',
 
        ]))
 
        if full:
 
            check_cell_balance(next(cells), balances['Assets:Receivable'])
 
            check_cell_balance(next(cells), balances['Assets:Prepaid'])
 
            check_cell_balance(next(cells), balances['Liabilities'])
 
            check_cell_balance(next(cells), balances['Liabilities:Payable'])
 
        assert next(cells, None) is None
 
        if full and fund == 'Unrestricted':
 
            assert '' not in account_bals, "Unrestricted funds reported before subtotals"
 
            for key, bal in balances.items():
 
                totals[key] += bal
 
            account_bals[''] = totals
 
    assert not account_bals, "did not see all funds in report"
 

	
 
def check_ods_report(ods, start_date, stop_date):
 
    account_bals = collections.OrderedDict((key, {
 
        'opening': Decimal(amount),
 
        'Income': Decimal(0),
 
        'Expenses': Decimal(0),
 
        'Equity': Decimal(0),
 
        'Assets:Receivable': Decimal(0),
 
        'Assets:Prepaid': Decimal(0),
 
        'Liabilities:Payable': Decimal(0),
 
        'Liabilities': Decimal(0),  # UnearnedIncome
 
    }) for key, amount in sorted(OPENING_BALANCES.items()))
 
    for fund, year in itertools.product(account_bals, range(2018, stop_date.year)):
 
        try:
 
            amounts = BALANCES_BY_YEAR[(fund, year)]
 
        except KeyError:
 
            pass
 
        else:
 
            for account, amount in amounts:
 
                if account.startswith(EQUITY_ROOT_ACCOUNTS):
 
                    if year < start_date.year:
 
                        acct_key = 'opening'
 
                    else:
 
                        acct_key, _, _ = account.partition(':')
 
                else:
 
                    acct_key, _, _ = account.rpartition(':')
 
                account_bals[fund][acct_key] += amount
 
    sheets = iter(ods.getElementsByType(odf.table.Table))
 
    check_ods_sheet(next(sheets), account_bals, full=False)
 
    check_ods_sheet(next(sheets), account_bals, full=True)
 
    assert next(sheets, None) is None, "found unexpected sheet"
 

	
 
def run_main(out_type, arglist, config=None):
 
    if config is None:
 
        config = testutil.TestConfig(
 
            books_path=testutil.test_path('books/fund.beancount'),
 
        )
 
    arglist.insert(0, '--output-file=-')
 
    output = out_type()
 
    errors = io.StringIO()
 
    retcode = fund.main(arglist, output, errors, config)
 
    output.seek(0)
 
    return retcode, output, errors
 

	
 
@pytest.mark.parametrize('project,start_date,stop_date', [
 
    ('Conservancy', START_DATE, STOP_DATE),
 
    ('project=Conservancy', MID_DATE, STOP_DATE),
 
    ('Conservancy', START_DATE, MID_DATE),
 
    ('Alpha', START_DATE, STOP_DATE),
 
    ('project=Alpha', MID_DATE, STOP_DATE),
 
    ('Alpha', START_DATE, MID_DATE),
 
    ('Bravo', START_DATE, STOP_DATE),
 
    ('project=Bravo', MID_DATE, STOP_DATE),
 
    ('Bravo', START_DATE, MID_DATE),
 
    ('project=Charlie', START_DATE, STOP_DATE),
 
])
 
def test_text_report(project, start_date, stop_date):
 
    retcode, output, errors = run_main(io.StringIO, [
 
        '-b', start_date.isoformat(), '-e', stop_date.isoformat(), project,
 
    ])
 
    assert not errors.getvalue()
 
    assert retcode == 0
 
    check_text_report(output, project, start_date, stop_date)
 

	
 
def test_text_report_empty_balances():
 
    retcode, output, errors = run_main(io.StringIO, [
 
        '-t', 'text', '-b', '2018-01-01',
0 comments (0 inline, 0 general)