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.
Files changed (187) hide show
  1. rustfava/__init__.py +30 -0
  2. rustfava/_ctx_globals_class.py +55 -0
  3. rustfava/api_models.py +36 -0
  4. rustfava/application.py +534 -0
  5. rustfava/beans/__init__.py +6 -0
  6. rustfava/beans/abc.py +327 -0
  7. rustfava/beans/account.py +79 -0
  8. rustfava/beans/create.py +377 -0
  9. rustfava/beans/flags.py +20 -0
  10. rustfava/beans/funcs.py +38 -0
  11. rustfava/beans/helpers.py +52 -0
  12. rustfava/beans/ingest.py +75 -0
  13. rustfava/beans/load.py +31 -0
  14. rustfava/beans/prices.py +151 -0
  15. rustfava/beans/protocols.py +82 -0
  16. rustfava/beans/str.py +454 -0
  17. rustfava/beans/types.py +63 -0
  18. rustfava/cli.py +187 -0
  19. rustfava/context.py +13 -0
  20. rustfava/core/__init__.py +729 -0
  21. rustfava/core/accounts.py +161 -0
  22. rustfava/core/attributes.py +145 -0
  23. rustfava/core/budgets.py +207 -0
  24. rustfava/core/charts.py +301 -0
  25. rustfava/core/commodities.py +37 -0
  26. rustfava/core/conversion.py +229 -0
  27. rustfava/core/documents.py +87 -0
  28. rustfava/core/extensions.py +132 -0
  29. rustfava/core/fava_options.py +255 -0
  30. rustfava/core/file.py +542 -0
  31. rustfava/core/filters.py +484 -0
  32. rustfava/core/group_entries.py +97 -0
  33. rustfava/core/ingest.py +509 -0
  34. rustfava/core/inventory.py +167 -0
  35. rustfava/core/misc.py +105 -0
  36. rustfava/core/module_base.py +18 -0
  37. rustfava/core/number.py +106 -0
  38. rustfava/core/query.py +180 -0
  39. rustfava/core/query_shell.py +301 -0
  40. rustfava/core/tree.py +265 -0
  41. rustfava/core/watcher.py +219 -0
  42. rustfava/ext/__init__.py +232 -0
  43. rustfava/ext/auto_commit.py +61 -0
  44. rustfava/ext/portfolio_list/PortfolioList.js +34 -0
  45. rustfava/ext/portfolio_list/__init__.py +29 -0
  46. rustfava/ext/portfolio_list/templates/PortfolioList.html +15 -0
  47. rustfava/ext/rustfava_ext_test/RustfavaExtTest.js +42 -0
  48. rustfava/ext/rustfava_ext_test/__init__.py +207 -0
  49. rustfava/ext/rustfava_ext_test/templates/RustfavaExtTest.html +45 -0
  50. rustfava/ext/rustfava_ext_test/templates/RustfavaExtTestInclude.html +1 -0
  51. rustfava/help/__init__.py +15 -0
  52. rustfava/help/_index.md +29 -0
  53. rustfava/help/beancount_syntax.md +156 -0
  54. rustfava/help/budgets.md +31 -0
  55. rustfava/help/conversion.md +29 -0
  56. rustfava/help/extensions.md +111 -0
  57. rustfava/help/features.md +179 -0
  58. rustfava/help/filters.md +103 -0
  59. rustfava/help/import.md +27 -0
  60. rustfava/help/options.md +289 -0
  61. rustfava/helpers.py +30 -0
  62. rustfava/internal_api.py +221 -0
  63. rustfava/json_api.py +952 -0
  64. rustfava/plugins/__init__.py +3 -0
  65. rustfava/plugins/link_documents.py +107 -0
  66. rustfava/plugins/tag_discovered_documents.py +44 -0
  67. rustfava/py.typed +0 -0
  68. rustfava/rustledger/__init__.py +31 -0
  69. rustfava/rustledger/constants.py +76 -0
  70. rustfava/rustledger/engine.py +485 -0
  71. rustfava/rustledger/loader.py +273 -0
  72. rustfava/rustledger/options.py +202 -0
  73. rustfava/rustledger/query.py +331 -0
  74. rustfava/rustledger/types.py +830 -0
  75. rustfava/serialisation.py +220 -0
  76. rustfava/static/app.css +2988 -0
  77. rustfava/static/app.css.map +7 -0
  78. rustfava/static/app.js +12854 -0
  79. rustfava/static/app.js.map +7 -0
  80. rustfava/static/beancount-JFV44ZVZ.css +5 -0
  81. rustfava/static/beancount-JFV44ZVZ.css.map +7 -0
  82. rustfava/static/beancount-VTTKRGSK.js +4642 -0
  83. rustfava/static/beancount-VTTKRGSK.js.map +7 -0
  84. rustfava/static/bql-MGFRUMBP.js +333 -0
  85. rustfava/static/bql-MGFRUMBP.js.map +7 -0
  86. rustfava/static/chunk-E7ZF4ASL.js +23061 -0
  87. rustfava/static/chunk-E7ZF4ASL.js.map +7 -0
  88. rustfava/static/chunk-V24TLQHT.js +12673 -0
  89. rustfava/static/chunk-V24TLQHT.js.map +7 -0
  90. rustfava/static/favicon.ico +0 -0
  91. rustfava/static/fira-mono-cyrillic-400-normal-BLAGXRCE.woff2 +0 -0
  92. rustfava/static/fira-mono-cyrillic-500-normal-EN7JUAAW.woff2 +0 -0
  93. rustfava/static/fira-mono-cyrillic-ext-400-normal-EX7VARTS.woff2 +0 -0
  94. rustfava/static/fira-mono-cyrillic-ext-500-normal-ZDPTUPRR.woff2 +0 -0
  95. rustfava/static/fira-mono-greek-400-normal-COGHKMOA.woff2 +0 -0
  96. rustfava/static/fira-mono-greek-500-normal-4EN2PKZT.woff2 +0 -0
  97. rustfava/static/fira-mono-greek-ext-400-normal-DYEQIJH7.woff2 +0 -0
  98. rustfava/static/fira-mono-greek-ext-500-normal-SG73CVKQ.woff2 +0 -0
  99. rustfava/static/fira-mono-latin-400-normal-NA3VLV7E.woff2 +0 -0
  100. rustfava/static/fira-mono-latin-500-normal-YC77GFWD.woff2 +0 -0
  101. rustfava/static/fira-mono-latin-ext-400-normal-DIKTZ5PW.woff2 +0 -0
  102. rustfava/static/fira-mono-latin-ext-500-normal-ZWY4UO4V.woff2 +0 -0
  103. rustfava/static/fira-mono-symbols2-400-normal-UITXT77Q.woff2 +0 -0
  104. rustfava/static/fira-mono-symbols2-500-normal-VWPC2EFN.woff2 +0 -0
  105. rustfava/static/fira-sans-cyrillic-400-normal-KLQMBCA6.woff2 +0 -0
  106. rustfava/static/fira-sans-cyrillic-500-normal-NFG7UD6J.woff2 +0 -0
  107. rustfava/static/fira-sans-cyrillic-ext-400-normal-GWO44OPC.woff2 +0 -0
  108. rustfava/static/fira-sans-cyrillic-ext-500-normal-SP47E5SC.woff2 +0 -0
  109. rustfava/static/fira-sans-greek-400-normal-UMQBTLC3.woff2 +0 -0
  110. rustfava/static/fira-sans-greek-500-normal-4ZKHN4FQ.woff2 +0 -0
  111. rustfava/static/fira-sans-greek-ext-400-normal-O2DVJAJZ.woff2 +0 -0
  112. rustfava/static/fira-sans-greek-ext-500-normal-SK6GNWGO.woff2 +0 -0
  113. rustfava/static/fira-sans-latin-400-normal-OYYTPMAV.woff2 +0 -0
  114. rustfava/static/fira-sans-latin-500-normal-SMQPZW5A.woff2 +0 -0
  115. rustfava/static/fira-sans-latin-ext-400-normal-OAUP3WK5.woff2 +0 -0
  116. rustfava/static/fira-sans-latin-ext-500-normal-LY3YDR5Y.woff2 +0 -0
  117. rustfava/static/fira-sans-vietnamese-400-normal-OBMQ72MR.woff2 +0 -0
  118. rustfava/static/fira-sans-vietnamese-500-normal-Y4NZR5EU.woff2 +0 -0
  119. rustfava/static/source-code-pro-cyrillic-400-normal-TO22V6M3.woff2 +0 -0
  120. rustfava/static/source-code-pro-cyrillic-500-normal-OGBWWWYW.woff2 +0 -0
  121. rustfava/static/source-code-pro-cyrillic-ext-400-normal-XH44UCIA.woff2 +0 -0
  122. rustfava/static/source-code-pro-cyrillic-ext-500-normal-3Z6MMVM6.woff2 +0 -0
  123. rustfava/static/source-code-pro-greek-400-normal-OUXXUQWK.woff2 +0 -0
  124. rustfava/static/source-code-pro-greek-500-normal-JA2Z5UXO.woff2 +0 -0
  125. rustfava/static/source-code-pro-greek-ext-400-normal-WCDKMX7U.woff2 +0 -0
  126. rustfava/static/source-code-pro-greek-ext-500-normal-ZHVI4VKW.woff2 +0 -0
  127. rustfava/static/source-code-pro-latin-400-normal-QOGTXED5.woff2 +0 -0
  128. rustfava/static/source-code-pro-latin-500-normal-X57QEOLQ.woff2 +0 -0
  129. rustfava/static/source-code-pro-latin-ext-400-normal-QXC74NBF.woff2 +0 -0
  130. rustfava/static/source-code-pro-latin-ext-500-normal-QGOY7MTT.woff2 +0 -0
  131. rustfava/static/source-code-pro-vietnamese-400-normal-NPDCDTBA.woff2 +0 -0
  132. rustfava/static/source-code-pro-vietnamese-500-normal-M6PJKTR5.woff2 +0 -0
  133. rustfava/static/tree-sitter-beancount-MLXFQBZ5.wasm +0 -0
  134. rustfava/static/web-tree-sitter-RNOQ6E74.wasm +0 -0
  135. rustfava/template_filters.py +64 -0
  136. rustfava/templates/_journal_table.html +156 -0
  137. rustfava/templates/_layout.html +26 -0
  138. rustfava/templates/_query_table.html +88 -0
  139. rustfava/templates/beancount_file +18 -0
  140. rustfava/templates/help.html +23 -0
  141. rustfava/templates/macros/_account_macros.html +5 -0
  142. rustfava/templates/macros/_commodity_macros.html +13 -0
  143. rustfava/translations/bg/LC_MESSAGES/messages.mo +0 -0
  144. rustfava/translations/bg/LC_MESSAGES/messages.po +618 -0
  145. rustfava/translations/ca/LC_MESSAGES/messages.mo +0 -0
  146. rustfava/translations/ca/LC_MESSAGES/messages.po +618 -0
  147. rustfava/translations/de/LC_MESSAGES/messages.mo +0 -0
  148. rustfava/translations/de/LC_MESSAGES/messages.po +618 -0
  149. rustfava/translations/es/LC_MESSAGES/messages.mo +0 -0
  150. rustfava/translations/es/LC_MESSAGES/messages.po +619 -0
  151. rustfava/translations/fa/LC_MESSAGES/messages.mo +0 -0
  152. rustfava/translations/fa/LC_MESSAGES/messages.po +618 -0
  153. rustfava/translations/fr/LC_MESSAGES/messages.mo +0 -0
  154. rustfava/translations/fr/LC_MESSAGES/messages.po +618 -0
  155. rustfava/translations/ja/LC_MESSAGES/messages.mo +0 -0
  156. rustfava/translations/ja/LC_MESSAGES/messages.po +618 -0
  157. rustfava/translations/nl/LC_MESSAGES/messages.mo +0 -0
  158. rustfava/translations/nl/LC_MESSAGES/messages.po +617 -0
  159. rustfava/translations/pt/LC_MESSAGES/messages.mo +0 -0
  160. rustfava/translations/pt/LC_MESSAGES/messages.po +617 -0
  161. rustfava/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
  162. rustfava/translations/pt_BR/LC_MESSAGES/messages.po +618 -0
  163. rustfava/translations/ru/LC_MESSAGES/messages.mo +0 -0
  164. rustfava/translations/ru/LC_MESSAGES/messages.po +617 -0
  165. rustfava/translations/sk/LC_MESSAGES/messages.mo +0 -0
  166. rustfava/translations/sk/LC_MESSAGES/messages.po +623 -0
  167. rustfava/translations/sv/LC_MESSAGES/messages.mo +0 -0
  168. rustfava/translations/sv/LC_MESSAGES/messages.po +618 -0
  169. rustfava/translations/uk/LC_MESSAGES/messages.mo +0 -0
  170. rustfava/translations/uk/LC_MESSAGES/messages.po +618 -0
  171. rustfava/translations/zh/LC_MESSAGES/messages.mo +0 -0
  172. rustfava/translations/zh/LC_MESSAGES/messages.po +618 -0
  173. rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.mo +0 -0
  174. rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.po +618 -0
  175. rustfava/util/__init__.py +157 -0
  176. rustfava/util/date.py +576 -0
  177. rustfava/util/excel.py +118 -0
  178. rustfava/util/ranking.py +79 -0
  179. rustfava/util/sets.py +18 -0
  180. rustfava/util/unreachable.py +20 -0
  181. rustfava-0.1.0.dist-info/METADATA +102 -0
  182. rustfava-0.1.0.dist-info/RECORD +187 -0
  183. rustfava-0.1.0.dist-info/WHEEL +5 -0
  184. rustfava-0.1.0.dist-info/entry_points.txt +2 -0
  185. rustfava-0.1.0.dist-info/licenses/AUTHORS +11 -0
  186. rustfava-0.1.0.dist-info/licenses/LICENSE +21 -0
  187. 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."""
@@ -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)