query: Add ODS output format.
This commit is contained in:
parent
0b3175fe6b
commit
0f58960b67
2 changed files with 229 additions and 8 deletions
|
@ -17,6 +17,7 @@ import sys
|
||||||
from typing import (
|
from typing import (
|
||||||
cast,
|
cast,
|
||||||
AbstractSet,
|
AbstractSet,
|
||||||
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
Dict,
|
Dict,
|
||||||
Iterable,
|
Iterable,
|
||||||
|
@ -38,6 +39,7 @@ from ..beancount_types import (
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from beancount.core.amount import _Amount as BeancountAmount
|
||||||
|
|
||||||
import beancount.query.numberify as bc_query_numberify
|
import beancount.query.numberify as bc_query_numberify
|
||||||
import beancount.query.query_compile as bc_query_compile
|
import beancount.query.query_compile as bc_query_compile
|
||||||
|
@ -46,6 +48,7 @@ import beancount.query.query_execute as bc_query_execute
|
||||||
import beancount.query.query_parser as bc_query_parser
|
import beancount.query.query_parser as bc_query_parser
|
||||||
import beancount.query.query_render as bc_query_render
|
import beancount.query.query_render as bc_query_render
|
||||||
import beancount.query.shell as bc_query_shell
|
import beancount.query.shell as bc_query_shell
|
||||||
|
import odf.table # type:ignore[import]
|
||||||
|
|
||||||
from . import core
|
from . import core
|
||||||
from . import rewrite
|
from . import rewrite
|
||||||
|
@ -53,6 +56,7 @@ from .. import books
|
||||||
from .. import cliutil
|
from .. import cliutil
|
||||||
from .. import config as configmod
|
from .. import config as configmod
|
||||||
from .. import data
|
from .. import data
|
||||||
|
from .. import rtutil
|
||||||
|
|
||||||
BUILTIN_FIELDS: AbstractSet[str] = frozenset(itertools.chain(
|
BUILTIN_FIELDS: AbstractSet[str] = frozenset(itertools.chain(
|
||||||
bc_query_env.TargetsEnvironment.columns, # type:ignore[has-type]
|
bc_query_env.TargetsEnvironment.columns, # type:ignore[has-type]
|
||||||
|
@ -62,6 +66,7 @@ PROGNAME = 'query-report'
|
||||||
QUERY_PARSER = bc_query_parser.Parser()
|
QUERY_PARSER = bc_query_parser.Parser()
|
||||||
logger = logging.getLogger('conservancy_beancount.reports.query')
|
logger = logging.getLogger('conservancy_beancount.reports.query')
|
||||||
|
|
||||||
|
CellFunc = Callable[[Any], odf.table.TableCell]
|
||||||
RowTypes = Sequence[Tuple[str, Type]]
|
RowTypes = Sequence[Tuple[str, Type]]
|
||||||
Rows = Sequence[NamedTuple]
|
Rows = Sequence[NamedTuple]
|
||||||
|
|
||||||
|
@ -102,7 +107,88 @@ class BooksLoader:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class QueryODS(core.BaseODS[NamedTuple, None]):
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
return not self.sheet.childNodes
|
||||||
|
|
||||||
|
def section_key(self, row: NamedTuple) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _cell_type(self, row_type: Type) -> CellFunc:
|
||||||
|
if issubclass(row_type, BeancountAmount):
|
||||||
|
return self.currency_cell
|
||||||
|
elif issubclass(row_type, (int, float, Decimal)):
|
||||||
|
return self.float_cell
|
||||||
|
elif issubclass(row_type, datetime.date):
|
||||||
|
return self.date_cell
|
||||||
|
elif issubclass(row_type, str):
|
||||||
|
return self.string_cell
|
||||||
|
else:
|
||||||
|
return self._generic_cell
|
||||||
|
|
||||||
|
def _generic_cell(self, value: Any) -> odf.table.TableCell:
|
||||||
|
return self.string_cell('' if value is None else str(value))
|
||||||
|
|
||||||
|
def _link_cell(self, value: MetaValue) -> odf.table.TableCell:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return self.meta_links_cell(value.split())
|
||||||
|
else:
|
||||||
|
return self._generic_cell(value)
|
||||||
|
|
||||||
|
def _metadata_cell(self, value: MetaValue) -> odf.table.TableCell:
|
||||||
|
return self._cell_type(type(value))(value)
|
||||||
|
|
||||||
|
def _cell_types(self, row_types: RowTypes) -> Iterator[CellFunc]:
|
||||||
|
for name, row_type in row_types:
|
||||||
|
if row_type is object:
|
||||||
|
if name.replace('_', '-') in data.LINK_METADATA:
|
||||||
|
yield self._link_cell
|
||||||
|
else:
|
||||||
|
yield self._metadata_cell
|
||||||
|
else:
|
||||||
|
yield self._cell_type(row_type)
|
||||||
|
|
||||||
|
def write_query(self, row_types: RowTypes, rows: Rows) -> None:
|
||||||
|
if self.is_empty():
|
||||||
|
self.sheet.setAttribute('name', "Query 1")
|
||||||
|
else:
|
||||||
|
self.use_sheet(f"Query {len(self.document.spreadsheet.childNodes) + 1}")
|
||||||
|
for name, row_type in row_types:
|
||||||
|
if row_type is object or issubclass(row_type, str):
|
||||||
|
col_width = 2.0
|
||||||
|
elif issubclass(row_type, BeancountAmount):
|
||||||
|
col_width = 1.5
|
||||||
|
else:
|
||||||
|
col_width = 1.0
|
||||||
|
col_style = self.column_style(col_width)
|
||||||
|
self.sheet.addElement(odf.table.TableColumn(stylename=col_style))
|
||||||
|
self.add_row(*(
|
||||||
|
self.string_cell(data.Metadata.human_name(name.replace('_', '-')),
|
||||||
|
stylename=self.style_bold)
|
||||||
|
for name, _ in row_types
|
||||||
|
))
|
||||||
|
self.lock_first_row()
|
||||||
|
cell_funcs = list(self._cell_types(row_types))
|
||||||
|
for row in rows:
|
||||||
|
self.add_row(*(
|
||||||
|
cell_func(value)
|
||||||
|
for cell_func, value in zip(cell_funcs, row)
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
class BQLShell(bc_query_shell.BQLShell):
|
class BQLShell(bc_query_shell.BQLShell):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
is_interactive: bool,
|
||||||
|
loadfun: Callable[[], books.LoadResult],
|
||||||
|
outfile: TextIO,
|
||||||
|
default_format: str='text',
|
||||||
|
do_numberify: bool=False,
|
||||||
|
rt_wrapper: Optional[rtutil.RT]=None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(is_interactive, loadfun, outfile, default_format, do_numberify)
|
||||||
|
self.ods = QueryODS(rt_wrapper)
|
||||||
|
|
||||||
def on_Select(self, statement: str) -> None:
|
def on_Select(self, statement: str) -> None:
|
||||||
output_format: str = self.vars['format']
|
output_format: str = self.vars['format']
|
||||||
try:
|
try:
|
||||||
|
@ -144,6 +230,10 @@ class BQLShell(bc_query_shell.BQLShell):
|
||||||
self.vars['expand'],
|
self.vars['expand'],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _render_ods(self, row_types: RowTypes, rows: Rows) -> None:
|
||||||
|
self.ods.write_query(row_types, rows)
|
||||||
|
logger.info("results saved in sheet %s", self.ods.sheet.getAttribute('name'))
|
||||||
|
|
||||||
def _render_text(self, row_types: RowTypes, rows: Rows) -> None:
|
def _render_text(self, row_types: RowTypes, rows: Rows) -> None:
|
||||||
with contextlib.ExitStack() as stack:
|
with contextlib.ExitStack() as stack:
|
||||||
if self.is_interactive:
|
if self.is_interactive:
|
||||||
|
@ -173,7 +263,7 @@ class ReportFormat(enum.Enum):
|
||||||
TEXT = 'text'
|
TEXT = 'text'
|
||||||
TXT = TEXT
|
TXT = TEXT
|
||||||
CSV = 'csv'
|
CSV = 'csv'
|
||||||
# ODS = 'ods'
|
ODS = 'ods'
|
||||||
|
|
||||||
|
|
||||||
def _date_condition(
|
def _date_condition(
|
||||||
|
@ -202,10 +292,9 @@ def build_query(
|
||||||
args.join = JoinOperator.AND
|
args.join = JoinOperator.AND
|
||||||
select = [
|
select = [
|
||||||
'date',
|
'date',
|
||||||
'ANY_META("entity") as entity',
|
'ANY_META("entity") AS entity',
|
||||||
'narration',
|
'narration AS description',
|
||||||
'position',
|
'COST(position) AS booked_amount',
|
||||||
'COST(position)',
|
|
||||||
*(f'ANY_META("{field}") AS {field.replace("-", "_")}'
|
*(f'ANY_META("{field}") AS {field.replace("-", "_")}'
|
||||||
if field not in BUILTIN_FIELDS
|
if field not in BUILTIN_FIELDS
|
||||||
and re.fullmatch(r'[a-z][-_A-Za-z0-9]*', field)
|
and re.fullmatch(r'[a-z][-_A-Za-z0-9]*', field)
|
||||||
|
@ -337,7 +426,8 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
try:
|
try:
|
||||||
args.report_type = ReportFormat[args.output_file.suffix[1:].upper()]
|
args.report_type = ReportFormat[args.output_file.suffix[1:].upper()]
|
||||||
except (AttributeError, KeyError):
|
except (AttributeError, KeyError):
|
||||||
args.report_type = ReportFormat.TEXT # if is_interactive else ReportFormat.ODS
|
args.report_type = ReportFormat.TEXT if is_interactive else ReportFormat.ODS
|
||||||
|
|
||||||
load_func = BooksLoader(
|
load_func = BooksLoader(
|
||||||
config.books_loader(),
|
config.books_loader(),
|
||||||
args.start_date,
|
args.start_date,
|
||||||
|
@ -350,6 +440,7 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
stdout,
|
stdout,
|
||||||
args.report_type.value,
|
args.report_type.value,
|
||||||
args.numberify,
|
args.numberify,
|
||||||
|
config.rt_wrapper(),
|
||||||
)
|
)
|
||||||
shell.on_Reload()
|
shell.on_Reload()
|
||||||
if query is None:
|
if query is None:
|
||||||
|
@ -357,6 +448,18 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
else:
|
else:
|
||||||
shell.onecmd(query)
|
shell.onecmd(query)
|
||||||
|
|
||||||
|
if not shell.ods.is_empty():
|
||||||
|
shell.ods.set_common_properties(config.books_repo())
|
||||||
|
shell.ods.set_custom_property('BeanQuery', query or '<interactive>')
|
||||||
|
if args.output_file is None:
|
||||||
|
out_dir_path = config.repository_path() or Path()
|
||||||
|
args.output_file = out_dir_path / 'QueryResults_{}.ods'.format(
|
||||||
|
datetime.datetime.now().isoformat(timespec='minutes'),
|
||||||
|
)
|
||||||
|
logger.info("Writing spreadsheet to %s", args.output_file)
|
||||||
|
ods_file = cliutil.bytes_output(args.output_file, stdout)
|
||||||
|
shell.ods.save_file(ods_file)
|
||||||
|
|
||||||
return cliutil.ExitCode.OK
|
return cliutil.ExitCode.OK
|
||||||
|
|
||||||
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
||||||
|
|
|
@ -14,12 +14,16 @@ import io
|
||||||
import itertools
|
import itertools
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
import odf.table
|
||||||
|
import odf.text
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from . import testutil
|
from . import testutil
|
||||||
|
|
||||||
|
from beancount.core import data as bc_data
|
||||||
from conservancy_beancount.books import FiscalYear
|
from conservancy_beancount.books import FiscalYear
|
||||||
from conservancy_beancount.reports import query as qmod
|
from conservancy_beancount.reports import query as qmod
|
||||||
|
from conservancy_beancount import rtutil
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
@ -38,8 +42,12 @@ class MockRewriteRuleset:
|
||||||
def fy():
|
def fy():
|
||||||
return FiscalYear(3, 1)
|
return FiscalYear(3, 1)
|
||||||
|
|
||||||
def pipe_main(arglist, config):
|
@pytest.fixture(scope='module')
|
||||||
stdout = io.StringIO()
|
def rt():
|
||||||
|
return rtutil.RT(testutil.RTClient())
|
||||||
|
|
||||||
|
def pipe_main(arglist, config, stdout_type=io.StringIO):
|
||||||
|
stdout = stdout_type()
|
||||||
stderr = io.StringIO()
|
stderr = io.StringIO()
|
||||||
returncode = qmod.main(arglist, stdout, stderr, config)
|
returncode = qmod.main(arglist, stdout, stderr, config)
|
||||||
return returncode, stdout, stderr
|
return returncode, stdout, stderr
|
||||||
|
@ -258,3 +266,113 @@ def test_rewrite_query(end_index):
|
||||||
actual = frozenset(line.rstrip('\n') for line in stdout)
|
actual = frozenset(line.rstrip('\n') for line in stdout)
|
||||||
assert expected.issubset(actual)
|
assert expected.issubset(actual)
|
||||||
assert frozenset(accounts).difference(expected).isdisjoint(actual)
|
assert frozenset(accounts).difference(expected).isdisjoint(actual)
|
||||||
|
|
||||||
|
def test_ods_amount_formatting():
|
||||||
|
row_types = [('amount', bc_data.Amount)]
|
||||||
|
row_source = [(testutil.Amount(12),), (testutil.Amount(1480, 'JPY'),)]
|
||||||
|
ods = qmod.QueryODS()
|
||||||
|
ods.write_query(row_types, row_source)
|
||||||
|
actual = testutil.ODSCell.from_sheet(ods.document.spreadsheet.firstChild)
|
||||||
|
assert next(actual)[0].text == 'Amount'
|
||||||
|
assert next(actual)[0].text == '$12.00'
|
||||||
|
assert next(actual)[0].text == '¥1,480'
|
||||||
|
assert next(actual, None) is None
|
||||||
|
|
||||||
|
def test_ods_datetime_formatting():
|
||||||
|
row_types = [('date', datetime.date)]
|
||||||
|
row_source = [(testutil.PAST_DATE,), (testutil.FUTURE_DATE,)]
|
||||||
|
ods = qmod.QueryODS()
|
||||||
|
ods.write_query(row_types, row_source)
|
||||||
|
actual = testutil.ODSCell.from_sheet(ods.document.spreadsheet.firstChild)
|
||||||
|
assert next(actual)[0].text == 'Date'
|
||||||
|
assert next(actual)[0].text == testutil.PAST_DATE.isoformat()
|
||||||
|
assert next(actual)[0].text == testutil.FUTURE_DATE.isoformat()
|
||||||
|
assert next(actual, None) is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('meta_key,header_text', [
|
||||||
|
('check', 'Check'),
|
||||||
|
('purchase-order', 'Purchase Order'),
|
||||||
|
('rt-id', 'Ticket'),
|
||||||
|
])
|
||||||
|
def test_ods_link_formatting(rt, meta_key, header_text):
|
||||||
|
row_types = [(meta_key.replace('-', '_'), object)]
|
||||||
|
row_source = [('rt:1/5',), ('rt:3 Checks/9.pdf',)]
|
||||||
|
ods = qmod.QueryODS(rt)
|
||||||
|
ods.write_query(row_types, row_source)
|
||||||
|
rows = iter(ods.document.spreadsheet.firstChild.getElementsByType(odf.table.TableRow))
|
||||||
|
assert next(rows).text == header_text
|
||||||
|
actual = iter(
|
||||||
|
[link.text for link in row.getElementsByType(odf.text.A)]
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
assert next(actual) == ['photo.jpg']
|
||||||
|
assert next(actual) == ['rt:3', '9.pdf']
|
||||||
|
assert next(actual, None) is None
|
||||||
|
|
||||||
|
def test_ods_meta_formatting():
|
||||||
|
row_types = [('metadata', object)]
|
||||||
|
row_source = [(testutil.Amount(14),), (None,), ('foo bar',)]
|
||||||
|
ods = qmod.QueryODS()
|
||||||
|
ods.write_query(row_types, row_source)
|
||||||
|
actual = testutil.ODSCell.from_sheet(ods.document.spreadsheet.firstChild)
|
||||||
|
assert next(actual)[0].text == 'Metadata'
|
||||||
|
assert next(actual)[0].text == '$14.00'
|
||||||
|
assert next(actual)[0].text == ''
|
||||||
|
assert next(actual)[0].text == 'foo bar'
|
||||||
|
assert next(actual, None) is None
|
||||||
|
|
||||||
|
def test_ods_multicolumn_write(rt):
|
||||||
|
row_types = [('date', datetime.date), ('rt-id', object), ('desc', str)]
|
||||||
|
row_source = [
|
||||||
|
(testutil.PAST_DATE, 'rt:1', 'aaa'),
|
||||||
|
(testutil.FY_START_DATE, 'rt:2', 'bbb'),
|
||||||
|
(testutil.FUTURE_DATE, 'rt:3', 'ccc'),
|
||||||
|
]
|
||||||
|
ods = qmod.QueryODS(rt)
|
||||||
|
ods.write_query(row_types, row_source)
|
||||||
|
actual = iter(
|
||||||
|
cell.text
|
||||||
|
for row in testutil.ODSCell.from_sheet(ods.document.spreadsheet.firstChild)
|
||||||
|
for cell in row
|
||||||
|
)
|
||||||
|
assert next(actual) == 'Date'
|
||||||
|
assert next(actual) == 'Ticket'
|
||||||
|
assert next(actual) == 'Desc'
|
||||||
|
assert next(actual) == testutil.PAST_DATE.isoformat()
|
||||||
|
assert next(actual) == 'rt:1'
|
||||||
|
assert next(actual) == 'aaa'
|
||||||
|
assert next(actual) == testutil.FY_START_DATE.isoformat()
|
||||||
|
assert next(actual) == 'rt:2'
|
||||||
|
assert next(actual) == 'bbb'
|
||||||
|
assert next(actual) == testutil.FUTURE_DATE.isoformat()
|
||||||
|
assert next(actual) == 'rt:3'
|
||||||
|
assert next(actual) == 'ccc'
|
||||||
|
assert next(actual, None) is None
|
||||||
|
|
||||||
|
def test_ods_is_empty():
|
||||||
|
ods = qmod.QueryODS()
|
||||||
|
assert ods.is_empty()
|
||||||
|
ods.write_query([], [])
|
||||||
|
assert not ods.is_empty()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('fy,account,amt_prefix', [
|
||||||
|
(2018, 'Assets', '($'),
|
||||||
|
(2019, 'Income', '$'),
|
||||||
|
])
|
||||||
|
def test_ods_output(fy, account, amt_prefix):
|
||||||
|
books_path = testutil.test_path(f'books/books/{fy}.beancount')
|
||||||
|
config = testutil.TestConfig(books_path=books_path)
|
||||||
|
arglist = ['-O', '-', '-f', 'ods', f'account ~ "^{account}:"']
|
||||||
|
returncode, stdout, stderr = pipe_main(arglist, config, io.BytesIO)
|
||||||
|
assert returncode == 0
|
||||||
|
stdout.seek(0)
|
||||||
|
ods_doc = odf.opendocument.load(stdout)
|
||||||
|
rows = iter(ods_doc.spreadsheet.firstChild.getElementsByType(odf.table.TableRow))
|
||||||
|
next(rows) # Skip header row
|
||||||
|
amt_pattern = rf'^{re.escape(amt_prefix)}\d'
|
||||||
|
for count, row in enumerate(rows, 1):
|
||||||
|
date, entity, narration, amount = row.childNodes
|
||||||
|
assert re.fullmatch(rf'{fy}-\d{{2}}-\d{{2}}', date.text)
|
||||||
|
assert narration.text.startswith(f'{fy} ')
|
||||||
|
assert re.match(amt_pattern, amount.text)
|
||||||
|
assert count
|
||||||
|
|
Loading…
Reference in a new issue