rustfava 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rustfava/__init__.py +30 -0
- rustfava/_ctx_globals_class.py +55 -0
- rustfava/api_models.py +36 -0
- rustfava/application.py +534 -0
- rustfava/beans/__init__.py +6 -0
- rustfava/beans/abc.py +327 -0
- rustfava/beans/account.py +79 -0
- rustfava/beans/create.py +377 -0
- rustfava/beans/flags.py +20 -0
- rustfava/beans/funcs.py +38 -0
- rustfava/beans/helpers.py +52 -0
- rustfava/beans/ingest.py +75 -0
- rustfava/beans/load.py +31 -0
- rustfava/beans/prices.py +151 -0
- rustfava/beans/protocols.py +82 -0
- rustfava/beans/str.py +454 -0
- rustfava/beans/types.py +63 -0
- rustfava/cli.py +187 -0
- rustfava/context.py +13 -0
- rustfava/core/__init__.py +729 -0
- rustfava/core/accounts.py +161 -0
- rustfava/core/attributes.py +145 -0
- rustfava/core/budgets.py +207 -0
- rustfava/core/charts.py +301 -0
- rustfava/core/commodities.py +37 -0
- rustfava/core/conversion.py +229 -0
- rustfava/core/documents.py +87 -0
- rustfava/core/extensions.py +132 -0
- rustfava/core/fava_options.py +255 -0
- rustfava/core/file.py +542 -0
- rustfava/core/filters.py +484 -0
- rustfava/core/group_entries.py +97 -0
- rustfava/core/ingest.py +509 -0
- rustfava/core/inventory.py +167 -0
- rustfava/core/misc.py +105 -0
- rustfava/core/module_base.py +18 -0
- rustfava/core/number.py +106 -0
- rustfava/core/query.py +180 -0
- rustfava/core/query_shell.py +301 -0
- rustfava/core/tree.py +265 -0
- rustfava/core/watcher.py +219 -0
- rustfava/ext/__init__.py +232 -0
- rustfava/ext/auto_commit.py +61 -0
- rustfava/ext/portfolio_list/PortfolioList.js +34 -0
- rustfava/ext/portfolio_list/__init__.py +29 -0
- rustfava/ext/portfolio_list/templates/PortfolioList.html +15 -0
- rustfava/ext/rustfava_ext_test/RustfavaExtTest.js +42 -0
- rustfava/ext/rustfava_ext_test/__init__.py +207 -0
- rustfava/ext/rustfava_ext_test/templates/RustfavaExtTest.html +45 -0
- rustfava/ext/rustfava_ext_test/templates/RustfavaExtTestInclude.html +1 -0
- rustfava/help/__init__.py +15 -0
- rustfava/help/_index.md +29 -0
- rustfava/help/beancount_syntax.md +156 -0
- rustfava/help/budgets.md +31 -0
- rustfava/help/conversion.md +29 -0
- rustfava/help/extensions.md +111 -0
- rustfava/help/features.md +179 -0
- rustfava/help/filters.md +103 -0
- rustfava/help/import.md +27 -0
- rustfava/help/options.md +289 -0
- rustfava/helpers.py +30 -0
- rustfava/internal_api.py +221 -0
- rustfava/json_api.py +952 -0
- rustfava/plugins/__init__.py +3 -0
- rustfava/plugins/link_documents.py +107 -0
- rustfava/plugins/tag_discovered_documents.py +44 -0
- rustfava/py.typed +0 -0
- rustfava/rustledger/__init__.py +31 -0
- rustfava/rustledger/constants.py +76 -0
- rustfava/rustledger/engine.py +485 -0
- rustfava/rustledger/loader.py +273 -0
- rustfava/rustledger/options.py +202 -0
- rustfava/rustledger/query.py +331 -0
- rustfava/rustledger/types.py +830 -0
- rustfava/serialisation.py +220 -0
- rustfava/static/app.css +2988 -0
- rustfava/static/app.css.map +7 -0
- rustfava/static/app.js +12854 -0
- rustfava/static/app.js.map +7 -0
- rustfava/static/beancount-JFV44ZVZ.css +5 -0
- rustfava/static/beancount-JFV44ZVZ.css.map +7 -0
- rustfava/static/beancount-VTTKRGSK.js +4642 -0
- rustfava/static/beancount-VTTKRGSK.js.map +7 -0
- rustfava/static/bql-MGFRUMBP.js +333 -0
- rustfava/static/bql-MGFRUMBP.js.map +7 -0
- rustfava/static/chunk-E7ZF4ASL.js +23061 -0
- rustfava/static/chunk-E7ZF4ASL.js.map +7 -0
- rustfava/static/chunk-V24TLQHT.js +12673 -0
- rustfava/static/chunk-V24TLQHT.js.map +7 -0
- rustfava/static/favicon.ico +0 -0
- rustfava/static/fira-mono-cyrillic-400-normal-BLAGXRCE.woff2 +0 -0
- rustfava/static/fira-mono-cyrillic-500-normal-EN7JUAAW.woff2 +0 -0
- rustfava/static/fira-mono-cyrillic-ext-400-normal-EX7VARTS.woff2 +0 -0
- rustfava/static/fira-mono-cyrillic-ext-500-normal-ZDPTUPRR.woff2 +0 -0
- rustfava/static/fira-mono-greek-400-normal-COGHKMOA.woff2 +0 -0
- rustfava/static/fira-mono-greek-500-normal-4EN2PKZT.woff2 +0 -0
- rustfava/static/fira-mono-greek-ext-400-normal-DYEQIJH7.woff2 +0 -0
- rustfava/static/fira-mono-greek-ext-500-normal-SG73CVKQ.woff2 +0 -0
- rustfava/static/fira-mono-latin-400-normal-NA3VLV7E.woff2 +0 -0
- rustfava/static/fira-mono-latin-500-normal-YC77GFWD.woff2 +0 -0
- rustfava/static/fira-mono-latin-ext-400-normal-DIKTZ5PW.woff2 +0 -0
- rustfava/static/fira-mono-latin-ext-500-normal-ZWY4UO4V.woff2 +0 -0
- rustfava/static/fira-mono-symbols2-400-normal-UITXT77Q.woff2 +0 -0
- rustfava/static/fira-mono-symbols2-500-normal-VWPC2EFN.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-400-normal-KLQMBCA6.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-500-normal-NFG7UD6J.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-ext-400-normal-GWO44OPC.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-ext-500-normal-SP47E5SC.woff2 +0 -0
- rustfava/static/fira-sans-greek-400-normal-UMQBTLC3.woff2 +0 -0
- rustfava/static/fira-sans-greek-500-normal-4ZKHN4FQ.woff2 +0 -0
- rustfava/static/fira-sans-greek-ext-400-normal-O2DVJAJZ.woff2 +0 -0
- rustfava/static/fira-sans-greek-ext-500-normal-SK6GNWGO.woff2 +0 -0
- rustfava/static/fira-sans-latin-400-normal-OYYTPMAV.woff2 +0 -0
- rustfava/static/fira-sans-latin-500-normal-SMQPZW5A.woff2 +0 -0
- rustfava/static/fira-sans-latin-ext-400-normal-OAUP3WK5.woff2 +0 -0
- rustfava/static/fira-sans-latin-ext-500-normal-LY3YDR5Y.woff2 +0 -0
- rustfava/static/fira-sans-vietnamese-400-normal-OBMQ72MR.woff2 +0 -0
- rustfava/static/fira-sans-vietnamese-500-normal-Y4NZR5EU.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-400-normal-TO22V6M3.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-500-normal-OGBWWWYW.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-ext-400-normal-XH44UCIA.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-ext-500-normal-3Z6MMVM6.woff2 +0 -0
- rustfava/static/source-code-pro-greek-400-normal-OUXXUQWK.woff2 +0 -0
- rustfava/static/source-code-pro-greek-500-normal-JA2Z5UXO.woff2 +0 -0
- rustfava/static/source-code-pro-greek-ext-400-normal-WCDKMX7U.woff2 +0 -0
- rustfava/static/source-code-pro-greek-ext-500-normal-ZHVI4VKW.woff2 +0 -0
- rustfava/static/source-code-pro-latin-400-normal-QOGTXED5.woff2 +0 -0
- rustfava/static/source-code-pro-latin-500-normal-X57QEOLQ.woff2 +0 -0
- rustfava/static/source-code-pro-latin-ext-400-normal-QXC74NBF.woff2 +0 -0
- rustfava/static/source-code-pro-latin-ext-500-normal-QGOY7MTT.woff2 +0 -0
- rustfava/static/source-code-pro-vietnamese-400-normal-NPDCDTBA.woff2 +0 -0
- rustfava/static/source-code-pro-vietnamese-500-normal-M6PJKTR5.woff2 +0 -0
- rustfava/static/tree-sitter-beancount-MLXFQBZ5.wasm +0 -0
- rustfava/static/web-tree-sitter-RNOQ6E74.wasm +0 -0
- rustfava/template_filters.py +64 -0
- rustfava/templates/_journal_table.html +156 -0
- rustfava/templates/_layout.html +26 -0
- rustfava/templates/_query_table.html +88 -0
- rustfava/templates/beancount_file +18 -0
- rustfava/templates/help.html +23 -0
- rustfava/templates/macros/_account_macros.html +5 -0
- rustfava/templates/macros/_commodity_macros.html +13 -0
- rustfava/translations/bg/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/bg/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/ca/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/ca/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/de/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/de/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/es/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/es/LC_MESSAGES/messages.po +619 -0
- rustfava/translations/fa/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/fa/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/fr/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/fr/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/ja/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/ja/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/nl/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/nl/LC_MESSAGES/messages.po +617 -0
- rustfava/translations/pt/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/pt/LC_MESSAGES/messages.po +617 -0
- rustfava/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/pt_BR/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/ru/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/ru/LC_MESSAGES/messages.po +617 -0
- rustfava/translations/sk/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/sk/LC_MESSAGES/messages.po +623 -0
- rustfava/translations/sv/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/sv/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/uk/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/uk/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/zh/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/zh/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.po +618 -0
- rustfava/util/__init__.py +157 -0
- rustfava/util/date.py +576 -0
- rustfava/util/excel.py +118 -0
- rustfava/util/ranking.py +79 -0
- rustfava/util/sets.py +18 -0
- rustfava/util/unreachable.py +20 -0
- rustfava-0.1.0.dist-info/METADATA +102 -0
- rustfava-0.1.0.dist-info/RECORD +187 -0
- rustfava-0.1.0.dist-info/WHEEL +5 -0
- rustfava-0.1.0.dist-info/entry_points.txt +2 -0
- rustfava-0.1.0.dist-info/licenses/AUTHORS +11 -0
- rustfava-0.1.0.dist-info/licenses/LICENSE +21 -0
- rustfava-0.1.0.dist-info/top_level.txt +1 -0
rustfava/core/misc.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Some miscellaneous reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from rustfava.core.module_base import FavaModule
|
|
8
|
+
from rustfava.helpers import BeancountError
|
|
9
|
+
from rustfava.util.date import local_today
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
from rustfava.beans.abc import Custom
|
|
15
|
+
from rustfava.beans.abc import Event
|
|
16
|
+
from rustfava.core import RustfavaLedger
|
|
17
|
+
|
|
18
|
+
SidebarLinks = Sequence[tuple[str, str]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FavaError(BeancountError):
|
|
22
|
+
"""Generic Fava-specific error."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
NO_OPERATING_CURRENCY_ERROR = FavaError(
|
|
26
|
+
None,
|
|
27
|
+
"No operating currency specified. Please add one to your beancount file.",
|
|
28
|
+
None,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FavaMisc(FavaModule):
|
|
33
|
+
"""Provides access to some miscellaneous reports."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, ledger: RustfavaLedger) -> None:
|
|
36
|
+
super().__init__(ledger)
|
|
37
|
+
#: User-chosen links to show in the sidebar.
|
|
38
|
+
self.sidebar_links: SidebarLinks = []
|
|
39
|
+
#: Upcoming events in the next few days.
|
|
40
|
+
self.upcoming_events: Sequence[Event] = []
|
|
41
|
+
|
|
42
|
+
def load_file(self) -> None: # noqa: D102
|
|
43
|
+
custom_entries = self.ledger.all_entries_by_type.Custom
|
|
44
|
+
self.sidebar_links = sidebar_links(custom_entries)
|
|
45
|
+
|
|
46
|
+
self.upcoming_events = upcoming_events(
|
|
47
|
+
self.ledger.all_entries_by_type.Event,
|
|
48
|
+
self.ledger.fava_options.upcoming_events,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def errors(self) -> Sequence[FavaError]:
|
|
53
|
+
"""An error if no operating currency is set."""
|
|
54
|
+
return (
|
|
55
|
+
[]
|
|
56
|
+
if self.ledger.options["operating_currency"]
|
|
57
|
+
else [NO_OPERATING_CURRENCY_ERROR]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def sidebar_links(custom_entries: Sequence[Custom]) -> SidebarLinks:
|
|
62
|
+
"""Parse custom entries for links.
|
|
63
|
+
|
|
64
|
+
They have the following format:
|
|
65
|
+
|
|
66
|
+
2016-04-01 custom "fava-sidebar-link" "2014" "/income_statement/?time=2014"
|
|
67
|
+
"""
|
|
68
|
+
sidebar_link_entries = [
|
|
69
|
+
entry for entry in custom_entries if entry.type == "fava-sidebar-link"
|
|
70
|
+
]
|
|
71
|
+
return [
|
|
72
|
+
(entry.values[0].value, entry.values[1].value)
|
|
73
|
+
for entry in sidebar_link_entries
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def upcoming_events(
|
|
78
|
+
events: Sequence[Event], max_delta: int
|
|
79
|
+
) -> Sequence[Event]:
|
|
80
|
+
"""Parse entries for upcoming events.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
events: A list of events.
|
|
84
|
+
max_delta: Number of days that should be considered.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
A list of the Events in entries that are less than `max_delta` days
|
|
88
|
+
away.
|
|
89
|
+
"""
|
|
90
|
+
today = local_today()
|
|
91
|
+
upcoming = []
|
|
92
|
+
|
|
93
|
+
for event in events:
|
|
94
|
+
delta = event.date - today
|
|
95
|
+
if delta.days >= 0 and delta.days < max_delta:
|
|
96
|
+
upcoming.append(event)
|
|
97
|
+
|
|
98
|
+
return upcoming
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Import align from beans.str for backwards compatibility
|
|
102
|
+
# (It was moved there to avoid circular imports)
|
|
103
|
+
from rustfava.beans.str import align
|
|
104
|
+
|
|
105
|
+
__all__ = ["FavaMisc", "FavaError", "align"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Base class for the "modules" of rustfavaLedger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
8
|
+
from rustfava.core import RustfavaLedger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FavaModule:
|
|
12
|
+
"""Base class for the "modules" of rustfavaLedger."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, ledger: RustfavaLedger) -> None:
|
|
15
|
+
self.ledger = ledger
|
|
16
|
+
|
|
17
|
+
def load_file(self) -> None:
|
|
18
|
+
"""Run when the file has been (re)loaded."""
|
rustfava/core/number.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Formatting numbers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from babel.core import Locale
|
|
11
|
+
|
|
12
|
+
from rustfava.core.module_base import FavaModule
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
15
|
+
from rustfava.core import RustfavaLedger
|
|
16
|
+
|
|
17
|
+
Formatter = Callable[[Decimal], str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_locale_format(locale: Locale | None, precision: int) -> Formatter:
|
|
21
|
+
"""Obtain formatting pattern for the given locale and precision.
|
|
22
|
+
|
|
23
|
+
Arguments:
|
|
24
|
+
locale: An optional locale.
|
|
25
|
+
precision: The precision.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
A function that renders Decimals to strings as desired.
|
|
29
|
+
"""
|
|
30
|
+
# Set a maximum precision of 14, half the default precision of Decimal
|
|
31
|
+
precision = min(precision, 14)
|
|
32
|
+
if locale is None:
|
|
33
|
+
fmt_string = "{:." + str(precision) + "f}"
|
|
34
|
+
|
|
35
|
+
def fmt(num: Decimal) -> str:
|
|
36
|
+
return fmt_string.format(num)
|
|
37
|
+
|
|
38
|
+
return fmt
|
|
39
|
+
|
|
40
|
+
pattern = copy.copy(locale.decimal_formats.get(None))
|
|
41
|
+
if not pattern: # pragma: no cover
|
|
42
|
+
msg = "Expected Locale to have a decimal format pattern"
|
|
43
|
+
raise ValueError(msg)
|
|
44
|
+
pattern.frac_prec = (precision, precision)
|
|
45
|
+
|
|
46
|
+
def locale_fmt(num: Decimal) -> str:
|
|
47
|
+
return pattern.apply(num, locale) # type: ignore[no-any-return]
|
|
48
|
+
|
|
49
|
+
return locale_fmt
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DecimalFormatModule(FavaModule):
|
|
53
|
+
"""Formatting numbers."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, ledger: RustfavaLedger) -> None:
|
|
56
|
+
super().__init__(ledger)
|
|
57
|
+
self._locale: Locale | None = None
|
|
58
|
+
self._formatters: dict[str, Formatter] = {}
|
|
59
|
+
self._default_pattern = get_locale_format(None, 2)
|
|
60
|
+
self.precisions: dict[str, int] = {}
|
|
61
|
+
|
|
62
|
+
def load_file(self) -> None: # noqa: D102
|
|
63
|
+
locale = None
|
|
64
|
+
|
|
65
|
+
locale_option = self.ledger.fava_options.locale
|
|
66
|
+
if (
|
|
67
|
+
self.ledger.options["render_commas"] and not locale_option
|
|
68
|
+
): # pragma: no cover
|
|
69
|
+
locale_option = "en"
|
|
70
|
+
self.ledger.fava_options.locale = locale_option
|
|
71
|
+
|
|
72
|
+
if locale_option:
|
|
73
|
+
locale = Locale.parse(locale_option)
|
|
74
|
+
|
|
75
|
+
dcontext = self.ledger.options["dcontext"]
|
|
76
|
+
precisions: dict[str, int] = {}
|
|
77
|
+
|
|
78
|
+
# Both beancount's DisplayContext and RLDisplayContext have ccontexts
|
|
79
|
+
for currency, ccontext in dcontext.ccontexts.items():
|
|
80
|
+
prec = ccontext.get_fractional(None)
|
|
81
|
+
if prec is not None:
|
|
82
|
+
precisions[currency] = prec
|
|
83
|
+
|
|
84
|
+
precisions.update(self.ledger.commodities.precisions)
|
|
85
|
+
|
|
86
|
+
self._locale = locale
|
|
87
|
+
self._default_pattern = get_locale_format(locale, 2)
|
|
88
|
+
self._formatters = {
|
|
89
|
+
currency: get_locale_format(locale, prec)
|
|
90
|
+
for currency, prec in precisions.items()
|
|
91
|
+
}
|
|
92
|
+
self.precisions = precisions
|
|
93
|
+
|
|
94
|
+
def __call__(self, value: Decimal, currency: str | None = None) -> str:
|
|
95
|
+
"""Format a decimal to the right number of decimal digits with locale.
|
|
96
|
+
|
|
97
|
+
Arguments:
|
|
98
|
+
value: A decimal number.
|
|
99
|
+
currency: A currency string or None.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
A string, the formatted decimal.
|
|
103
|
+
"""
|
|
104
|
+
if currency is None:
|
|
105
|
+
return self._default_pattern(value)
|
|
106
|
+
return self._formatters.get(currency, self._default_pattern)(value)
|
rustfava/core/query.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Query result types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from rustfava.rustledger.types import RLAmount
|
|
11
|
+
from rustfava.rustledger.types import RLPosition
|
|
12
|
+
from rustfava.core.conversion import UNITS
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
15
|
+
from typing import Any
|
|
16
|
+
from typing import Literal
|
|
17
|
+
from typing import TypeAlias
|
|
18
|
+
from typing import TypeVar
|
|
19
|
+
|
|
20
|
+
from rustfava.core.inventory import SimpleCounterInventory
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
QueryRowValue = Any
|
|
25
|
+
|
|
26
|
+
# This is not a complete enumeration of all possible column types but just
|
|
27
|
+
# of the ones we pass in some specific serialisation to the frontend.
|
|
28
|
+
# Everything unknown will be stringified (by ObjectColumn).
|
|
29
|
+
SerialisedQueryRowValue = (
|
|
30
|
+
bool
|
|
31
|
+
| int
|
|
32
|
+
| str
|
|
33
|
+
| datetime.date
|
|
34
|
+
| Decimal
|
|
35
|
+
| RLPosition
|
|
36
|
+
| SimpleCounterInventory
|
|
37
|
+
| None
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class QueryResultTable:
|
|
43
|
+
"""Table query result."""
|
|
44
|
+
|
|
45
|
+
types: list[BaseColumn]
|
|
46
|
+
rows: list[tuple[SerialisedQueryRowValue, ...]]
|
|
47
|
+
t: Literal["table"] = "table"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class QueryResultText:
|
|
52
|
+
"""Text query result."""
|
|
53
|
+
|
|
54
|
+
contents: str
|
|
55
|
+
t: Literal["string"] = "string"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
QueryResult: TypeAlias = QueryResultTable | QueryResultText
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class BaseColumn:
|
|
63
|
+
"""A query column."""
|
|
64
|
+
|
|
65
|
+
name: str
|
|
66
|
+
dtype: str
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def serialise(
|
|
70
|
+
val: QueryRowValue,
|
|
71
|
+
) -> SerialisedQueryRowValue:
|
|
72
|
+
"""Serialiseable version of the column value."""
|
|
73
|
+
return val # type: ignore[no-any-return]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class BoolColumn(BaseColumn):
|
|
78
|
+
"""A boolean query column."""
|
|
79
|
+
|
|
80
|
+
dtype: str = "bool"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class DecimalColumn(BaseColumn):
|
|
85
|
+
"""A Decimal query column."""
|
|
86
|
+
|
|
87
|
+
dtype: str = "Decimal"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class IntColumn(BaseColumn):
|
|
92
|
+
"""A int query column."""
|
|
93
|
+
|
|
94
|
+
dtype: str = "int"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class StrColumn(BaseColumn):
|
|
99
|
+
"""A str query column."""
|
|
100
|
+
|
|
101
|
+
dtype: str = "str"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class DateColumn(BaseColumn):
|
|
106
|
+
"""A date query column."""
|
|
107
|
+
|
|
108
|
+
dtype: str = "date"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class PositionColumn(BaseColumn):
|
|
113
|
+
"""A Position query column."""
|
|
114
|
+
|
|
115
|
+
dtype: str = "Position"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class SetColumn(BaseColumn):
|
|
120
|
+
"""A set query column."""
|
|
121
|
+
|
|
122
|
+
dtype: str = "set"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class AmountColumn(BaseColumn):
|
|
127
|
+
"""An amount query column."""
|
|
128
|
+
|
|
129
|
+
dtype: str = "Amount"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class ObjectColumn(BaseColumn):
|
|
134
|
+
"""An object query column."""
|
|
135
|
+
|
|
136
|
+
dtype: str = "object"
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def serialise(val: object) -> str:
|
|
140
|
+
"""Serialise an object of unknown type to a string."""
|
|
141
|
+
return str(val)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class InventoryColumn(BaseColumn):
|
|
146
|
+
"""An inventory query column."""
|
|
147
|
+
|
|
148
|
+
dtype: str = "Inventory"
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def serialise(
|
|
152
|
+
val: dict[str, Decimal] | None,
|
|
153
|
+
) -> SimpleCounterInventory | None:
|
|
154
|
+
"""Serialise an inventory.
|
|
155
|
+
|
|
156
|
+
Rustledger returns inventory as a dict of currency -> Decimal.
|
|
157
|
+
"""
|
|
158
|
+
if val is None:
|
|
159
|
+
return None
|
|
160
|
+
# Rustledger already converts to {currency: Decimal} format
|
|
161
|
+
if isinstance(val, dict):
|
|
162
|
+
from rustfava.core.inventory import SimpleCounterInventory
|
|
163
|
+
return SimpleCounterInventory(val)
|
|
164
|
+
# Fallback for beancount Inventory type (for backwards compat)
|
|
165
|
+
return UNITS.apply_inventory(val) if val is not None else None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
COLUMNS = {
|
|
169
|
+
RLAmount: AmountColumn,
|
|
170
|
+
Decimal: DecimalColumn,
|
|
171
|
+
dict: InventoryColumn, # Rustledger returns inventory as dict
|
|
172
|
+
RLPosition: PositionColumn,
|
|
173
|
+
object: ObjectColumn, # Fallback for Position from rustledger
|
|
174
|
+
bool: BoolColumn,
|
|
175
|
+
datetime.date: DateColumn,
|
|
176
|
+
int: IntColumn,
|
|
177
|
+
set: SetColumn,
|
|
178
|
+
frozenset: SetColumn, # Rustledger returns frozenset for sets
|
|
179
|
+
str: StrColumn,
|
|
180
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""For running BQL queries in Fava."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import shlex
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from rustfava.core.module_base import FavaModule
|
|
10
|
+
from rustfava.core.query import COLUMNS
|
|
11
|
+
from rustfava.core.query import ObjectColumn
|
|
12
|
+
from rustfava.core.query import QueryResultTable
|
|
13
|
+
from rustfava.core.query import QueryResultText
|
|
14
|
+
from rustfava.helpers import RustfavaAPIError
|
|
15
|
+
from rustfava.rustledger.query import CompilationError
|
|
16
|
+
from rustfava.rustledger.query import connect
|
|
17
|
+
from rustfava.rustledger.query import ParseError
|
|
18
|
+
from rustfava.rustledger.query import RLConnection
|
|
19
|
+
from rustfava.rustledger.query import RLCursor
|
|
20
|
+
from rustfava.util.excel import HAVE_EXCEL
|
|
21
|
+
from rustfava.util.excel import to_csv
|
|
22
|
+
from rustfava.util.excel import to_excel
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
25
|
+
from collections.abc import Sequence
|
|
26
|
+
|
|
27
|
+
from rustfava.beans.abc import Directive
|
|
28
|
+
from rustfava.core import RustfavaLedger
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FavaShellError(RustfavaAPIError):
|
|
32
|
+
"""An error in the Fava BQL shell, will be turned into a string."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class QueryNotFoundError(FavaShellError):
|
|
36
|
+
"""Query '{name}' not found."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, name: str) -> None:
|
|
39
|
+
super().__init__(f"Query '{name}' not found.")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TooManyRunArgsError(FavaShellError):
|
|
43
|
+
"""Too many args to run: '{args}'."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, args: str) -> None:
|
|
46
|
+
super().__init__(f"Too many args to run: '{args}'.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class QueryCompilationError(FavaShellError):
|
|
50
|
+
"""Query compilation error."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, err: CompilationError) -> None:
|
|
53
|
+
super().__init__(f"Query compilation error: {err!s}.")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class QueryParseError(FavaShellError):
|
|
57
|
+
"""Query parse error."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, err: ParseError) -> None:
|
|
60
|
+
super().__init__(f"Query parse error: {err!s}.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class NonExportableQueryError(FavaShellError):
|
|
64
|
+
"""Only queries that return a table can be printed to a file."""
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
super().__init__(
|
|
68
|
+
"Only queries that return a table can be printed to a file."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class FavaQueryRunner:
|
|
73
|
+
"""Runs BQL queries using rustledger."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, ledger: RustfavaLedger) -> None:
|
|
76
|
+
self.ledger = ledger
|
|
77
|
+
|
|
78
|
+
def run(
|
|
79
|
+
self, entries: Sequence[Directive], query: str
|
|
80
|
+
) -> RLCursor | str:
|
|
81
|
+
"""Run a query, returning cursor or text result."""
|
|
82
|
+
# Get the source from the ledger for queries
|
|
83
|
+
source = getattr(self.ledger, "_source", None)
|
|
84
|
+
|
|
85
|
+
# Create connection
|
|
86
|
+
conn = connect(
|
|
87
|
+
"rustledger:",
|
|
88
|
+
entries=entries,
|
|
89
|
+
errors=self.ledger.errors,
|
|
90
|
+
options=self.ledger.options,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if source:
|
|
94
|
+
conn.set_source(source)
|
|
95
|
+
|
|
96
|
+
# Parse the query to handle special commands
|
|
97
|
+
query = query.strip()
|
|
98
|
+
query_lower = query.lower()
|
|
99
|
+
|
|
100
|
+
# Handle noop commands (return fixed text)
|
|
101
|
+
noop_doc = "Doesn't do anything in rustfava's query shell."
|
|
102
|
+
if query_lower in (".exit", ".quit", "exit", "quit"):
|
|
103
|
+
return noop_doc
|
|
104
|
+
|
|
105
|
+
# Handle .run or run command
|
|
106
|
+
if query_lower.startswith((".run", "run")):
|
|
107
|
+
# Check if it's just "run" or ".run" (list queries) or "run name"
|
|
108
|
+
if query_lower in ("run", ".run") or query_lower.startswith(("run ", ".run ")):
|
|
109
|
+
return self._handle_run(query, conn)
|
|
110
|
+
|
|
111
|
+
# Handle help commands - return text
|
|
112
|
+
if query_lower.startswith((".help", "help")):
|
|
113
|
+
# ".help exit" or ".help <command>" returns noop doc
|
|
114
|
+
if " " in query_lower:
|
|
115
|
+
return noop_doc
|
|
116
|
+
return self._help_text()
|
|
117
|
+
|
|
118
|
+
# Handle .explain - return placeholder
|
|
119
|
+
if query_lower.startswith((".explain", "explain")):
|
|
120
|
+
return f"EXPLAIN: {query}"
|
|
121
|
+
|
|
122
|
+
# Handle SELECT/BALANCES/JOURNAL queries
|
|
123
|
+
try:
|
|
124
|
+
return conn.execute(query)
|
|
125
|
+
except ParseError as exc:
|
|
126
|
+
raise QueryParseError(exc) from exc
|
|
127
|
+
except CompilationError as exc:
|
|
128
|
+
raise QueryCompilationError(exc) from exc
|
|
129
|
+
|
|
130
|
+
def _handle_run(self, query: str, conn: RLConnection) -> RLCursor | str:
|
|
131
|
+
"""Handle .run command to execute stored queries."""
|
|
132
|
+
queries = self.ledger.all_entries_by_type.Query
|
|
133
|
+
|
|
134
|
+
# Parse the run command
|
|
135
|
+
parts = shlex.split(query)
|
|
136
|
+
if len(parts) == 1:
|
|
137
|
+
# Just "run" - list available queries
|
|
138
|
+
return "\n".join(q.name for q in queries)
|
|
139
|
+
|
|
140
|
+
if len(parts) > 2:
|
|
141
|
+
raise TooManyRunArgsError(query)
|
|
142
|
+
|
|
143
|
+
name = parts[1].rstrip(";")
|
|
144
|
+
query_obj = next((q for q in queries if q.name == name), None)
|
|
145
|
+
if query_obj is None:
|
|
146
|
+
raise QueryNotFoundError(name)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
return conn.execute(query_obj.query_string)
|
|
150
|
+
except ParseError as exc:
|
|
151
|
+
raise QueryParseError(exc) from exc
|
|
152
|
+
except CompilationError as exc:
|
|
153
|
+
raise QueryCompilationError(exc) from exc
|
|
154
|
+
|
|
155
|
+
def _help_text(self) -> str:
|
|
156
|
+
"""Return help text for the query shell."""
|
|
157
|
+
return """Fava Query Shell
|
|
158
|
+
|
|
159
|
+
Commands:
|
|
160
|
+
SELECT ... Run a BQL SELECT query
|
|
161
|
+
run <name> Run a stored query by name
|
|
162
|
+
run List all stored queries
|
|
163
|
+
help Show this help message
|
|
164
|
+
|
|
165
|
+
Example queries:
|
|
166
|
+
SELECT account, sum(position) GROUP BY account
|
|
167
|
+
SELECT date, narration, position WHERE account ~ "Expenses"
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class QueryShell(FavaModule):
|
|
172
|
+
"""A Fava module to run BQL queries."""
|
|
173
|
+
|
|
174
|
+
def __init__(self, ledger: RustfavaLedger) -> None:
|
|
175
|
+
super().__init__(ledger)
|
|
176
|
+
self.runner = FavaQueryRunner(ledger)
|
|
177
|
+
|
|
178
|
+
def execute_query_serialised(
|
|
179
|
+
self, entries: Sequence[Directive], query: str
|
|
180
|
+
) -> QueryResultTable | QueryResultText:
|
|
181
|
+
"""Run a query and returns its serialised result.
|
|
182
|
+
|
|
183
|
+
Arguments:
|
|
184
|
+
entries: The entries to run the query on.
|
|
185
|
+
query: A query string.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Either a table or a text result (depending on the query).
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
RustfavaAPIError: If the query response is an error.
|
|
192
|
+
"""
|
|
193
|
+
res = self.runner.run(entries, query)
|
|
194
|
+
return (
|
|
195
|
+
QueryResultText(res) if isinstance(res, str) else _serialise(res)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def query_to_file(
|
|
199
|
+
self,
|
|
200
|
+
entries: Sequence[Directive],
|
|
201
|
+
query_string: str,
|
|
202
|
+
result_format: str,
|
|
203
|
+
) -> tuple[str, io.BytesIO]:
|
|
204
|
+
"""Get query result as file.
|
|
205
|
+
|
|
206
|
+
Arguments:
|
|
207
|
+
entries: The entries to run the query on.
|
|
208
|
+
query_string: A string, the query to run.
|
|
209
|
+
result_format: The file format to save to.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
A tuple (name, data), where name is either 'query_result' or the
|
|
213
|
+
name of a custom query if the query string is 'run name_of_query'.
|
|
214
|
+
``data`` contains the file contents.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
RustfavaAPIError: If the result format is not supported or the
|
|
218
|
+
query failed.
|
|
219
|
+
"""
|
|
220
|
+
name = "query_result"
|
|
221
|
+
|
|
222
|
+
if query_string.lower().startswith((".run", "run ")):
|
|
223
|
+
parts = shlex.split(query_string)
|
|
224
|
+
if len(parts) > 2:
|
|
225
|
+
raise TooManyRunArgsError(query_string)
|
|
226
|
+
if len(parts) == 2:
|
|
227
|
+
name = parts[1].rstrip(";")
|
|
228
|
+
queries = self.ledger.all_entries_by_type.Query
|
|
229
|
+
query_obj = next((q for q in queries if q.name == name), None)
|
|
230
|
+
if query_obj is None:
|
|
231
|
+
raise QueryNotFoundError(name)
|
|
232
|
+
query_string = query_obj.query_string
|
|
233
|
+
|
|
234
|
+
res = self.runner.run(entries, query_string)
|
|
235
|
+
if isinstance(res, str):
|
|
236
|
+
raise NonExportableQueryError
|
|
237
|
+
|
|
238
|
+
rrows = res.fetchall()
|
|
239
|
+
rtypes = res.description
|
|
240
|
+
|
|
241
|
+
# Convert rows to exportable format
|
|
242
|
+
rows = _numberify_rows(rrows, rtypes)
|
|
243
|
+
|
|
244
|
+
if result_format == "csv":
|
|
245
|
+
data = to_csv(list(rtypes), rows)
|
|
246
|
+
else:
|
|
247
|
+
if not HAVE_EXCEL: # pragma: no cover
|
|
248
|
+
msg = "Result format not supported."
|
|
249
|
+
raise RustfavaAPIError(msg)
|
|
250
|
+
data = to_excel(list(rtypes), rows, result_format, query_string)
|
|
251
|
+
return name, data
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _numberify_rows(
|
|
255
|
+
rows: list[tuple[object, ...]],
|
|
256
|
+
columns: tuple[object, ...],
|
|
257
|
+
) -> list[tuple[object, ...]]:
|
|
258
|
+
"""Convert row values to exportable format.
|
|
259
|
+
|
|
260
|
+
This replaces beanquery.numberify.numberify_results for our use case.
|
|
261
|
+
"""
|
|
262
|
+
result: list[tuple[object, ...]] = []
|
|
263
|
+
for row in rows:
|
|
264
|
+
new_row: list[object] = []
|
|
265
|
+
for i, value in enumerate(row):
|
|
266
|
+
col = columns[i]
|
|
267
|
+
# Convert complex types to strings for export
|
|
268
|
+
if hasattr(value, "number") and hasattr(value, "currency"):
|
|
269
|
+
# Amount-like
|
|
270
|
+
new_row.append(f"{value.number} {value.currency}")
|
|
271
|
+
elif isinstance(value, dict):
|
|
272
|
+
# Inventory or other dict
|
|
273
|
+
if "positions" in value:
|
|
274
|
+
# Inventory
|
|
275
|
+
parts = []
|
|
276
|
+
for pos in value.get("positions", []):
|
|
277
|
+
units = pos.get("units", {})
|
|
278
|
+
parts.append(f"{units.get('number', '')} {units.get('currency', '')}")
|
|
279
|
+
new_row.append(", ".join(parts))
|
|
280
|
+
else:
|
|
281
|
+
new_row.append(str(value))
|
|
282
|
+
elif isinstance(value, (list, set, frozenset)):
|
|
283
|
+
new_row.append(", ".join(str(v) for v in value))
|
|
284
|
+
else:
|
|
285
|
+
new_row.append(value)
|
|
286
|
+
result.append(tuple(new_row))
|
|
287
|
+
return result
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _serialise(cursor: RLCursor) -> QueryResultTable:
|
|
291
|
+
"""Serialise the query result."""
|
|
292
|
+
dtypes = [
|
|
293
|
+
COLUMNS.get(c.datatype, ObjectColumn)(c.name)
|
|
294
|
+
for c in cursor.description
|
|
295
|
+
]
|
|
296
|
+
mappers = [d.serialise for d in dtypes]
|
|
297
|
+
mapped_rows = [
|
|
298
|
+
tuple(mapper(row[i]) for i, mapper in enumerate(mappers))
|
|
299
|
+
for row in cursor
|
|
300
|
+
]
|
|
301
|
+
return QueryResultTable(dtypes, mapped_rows)
|