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
@@ -0,0 +1,484 @@
1
+ """Entry filters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from abc import ABC
7
+ from abc import abstractmethod
8
+ from decimal import Decimal
9
+ from typing import Any
10
+ from typing import TYPE_CHECKING
11
+
12
+ import ply.yacc # type: ignore[import-untyped]
13
+
14
+ from rustfava.beans.account import get_entry_accounts
15
+ from rustfava.helpers import RustfavaAPIError
16
+ from rustfava.util.date import DateRange
17
+ from rustfava.util.date import parse_date
18
+
19
+ if TYPE_CHECKING: # pragma: no cover
20
+ from collections.abc import Callable
21
+ from collections.abc import Iterable
22
+ from collections.abc import Sequence
23
+
24
+ from rustfava.beans.abc import Directive
25
+ from rustfava.beans.types import BeancountOptions
26
+ from rustfava.core.fava_options import RustfavaOptions
27
+
28
+
29
+ class FilterError(RustfavaAPIError):
30
+ """Filter exception."""
31
+
32
+ def __init__(self, filter_type: str, message: str) -> None:
33
+ super().__init__(message)
34
+ self.filter_type = filter_type
35
+
36
+ def __str__(self) -> str:
37
+ return self.message
38
+
39
+
40
+ class FilterParseError(FilterError):
41
+ """Filter parse error."""
42
+
43
+ def __init__(self) -> None:
44
+ super().__init__("filter", "Failed to parse filter: ")
45
+
46
+
47
+ class FilterIllegalCharError(FilterError):
48
+ """Filter illegal char error."""
49
+
50
+ def __init__(self, char: str) -> None:
51
+ super().__init__(
52
+ "filter",
53
+ f'Illegal character "{char}" in filter.',
54
+ )
55
+
56
+
57
+ class TimeFilterParseError(FilterError):
58
+ """Time filter parse error."""
59
+
60
+ def __init__(self, value: str) -> None:
61
+ super().__init__("time", f"Failed to parse date: {value}")
62
+
63
+
64
+ class Token:
65
+ """A token having a certain type and value.
66
+
67
+ The lexer attribute only exists since PLY writes to it in case of a parser
68
+ error.
69
+ """
70
+
71
+ __slots__ = ("lexer", "type", "value")
72
+
73
+ def __init__(self, type_: str, value: str) -> None:
74
+ self.type = type_
75
+ self.value = value
76
+
77
+ def __repr__(self) -> str: # pragma: no cover
78
+ return f"Token({self.type}, {self.value})"
79
+
80
+
81
+ class FilterSyntaxLexer:
82
+ """Lexer for Fava's filter syntax."""
83
+
84
+ tokens = (
85
+ "ANY",
86
+ "ALL",
87
+ "CMP_OP",
88
+ "EQ_OP",
89
+ "KEY",
90
+ "LINK",
91
+ "NUMBER",
92
+ "STRING",
93
+ "TAG",
94
+ )
95
+
96
+ RULES = (
97
+ ("LINK", r"\^[A-Za-z0-9\-_/.]+"),
98
+ ("TAG", r"\#[A-Za-z0-9\-_/.]+"),
99
+ ("ALL", r"all\("),
100
+ ("ANY", r"any\("),
101
+ ("KEY", r"[a-z][a-zA-Z0-9\-_]+(?=\s*(:|=|>=|<=|<|>))"),
102
+ ("EQ_OP", r":"),
103
+ ("CMP_OP", r"(=|>=|<=|<|>)"),
104
+ ("NUMBER", r"\d*\.?\d+"),
105
+ ("STRING", r"""\w[-\w]*|"[^"]*"|'[^']*'"""),
106
+ )
107
+
108
+ regex = re.compile(
109
+ "|".join((f"(?P<{name}>{rule})" for name, rule in RULES)),
110
+ )
111
+
112
+ def LINK(self, token: str, value: str) -> tuple[str, str]: # noqa: N802
113
+ return token, value[1:]
114
+
115
+ def TAG(self, token: str, value: str) -> tuple[str, str]: # noqa: N802
116
+ return token, value[1:]
117
+
118
+ def KEY(self, token: str, value: str) -> tuple[str, str]: # noqa: N802
119
+ return token, value
120
+
121
+ def ALL(self, token: str, _: str) -> tuple[str, str]: # noqa: N802
122
+ return token, token
123
+
124
+ def ANY(self, token: str, _: str) -> tuple[str, str]: # noqa: N802
125
+ return token, token
126
+
127
+ def EQ_OP(self, token: str, value: str) -> tuple[str, str]: # noqa: N802
128
+ return token, value
129
+
130
+ def CMP_OP(self, token: str, value: str) -> tuple[str, str]: # noqa: N802
131
+ return token, value
132
+
133
+ def NUMBER(self, token: str, value: str) -> tuple[str, Decimal]: # noqa: N802
134
+ return token, Decimal(value)
135
+
136
+ def STRING(self, token: str, value: str) -> tuple[str, str]: # noqa: N802
137
+ if value[0] in {'"', "'"}:
138
+ return token, value[1:-1]
139
+ return token, value
140
+
141
+ def lex(self, data: str) -> Iterable[Token]:
142
+ """A generator yielding all tokens in a given line.
143
+
144
+ Arguments:
145
+ data: A string, the line to lex.
146
+
147
+ Yields:
148
+ All Tokens in the line.
149
+ """
150
+ ignore = " \t"
151
+ literals = "-,()"
152
+ regex = self.regex.match
153
+
154
+ pos = 0
155
+ length = len(data)
156
+ while pos < length:
157
+ char = data[pos]
158
+ if char in ignore:
159
+ pos += 1
160
+ continue
161
+ match = regex(data, pos)
162
+ if match:
163
+ value = match.group()
164
+ pos += len(value)
165
+ token = match.lastgroup
166
+ if token is None: # pragma: no cover
167
+ msg = "Internal Error"
168
+ raise ValueError(msg)
169
+ func: Callable[[str, str], tuple[str, str]] = getattr(
170
+ self,
171
+ token,
172
+ )
173
+ ret = func(token, value)
174
+ yield Token(*ret)
175
+ elif char in literals:
176
+ yield Token(char, char)
177
+ pos += 1
178
+ else:
179
+ raise FilterIllegalCharError(char)
180
+
181
+
182
+ class Match:
183
+ """Match a string."""
184
+
185
+ __slots__ = ("match",)
186
+
187
+ match: Callable[[str], bool]
188
+
189
+ def __init__(self, search: str) -> None:
190
+ try:
191
+ match = re.compile(search, re.IGNORECASE).search
192
+ self.match = lambda s: bool(match(s))
193
+ except re.error:
194
+ self.match = lambda s: s == search
195
+
196
+ def __call__(self, obj: Any) -> bool:
197
+ return self.match(str(obj))
198
+
199
+
200
+ class MatchAmount:
201
+ """Matches an amount."""
202
+
203
+ __slots__ = ("match",)
204
+
205
+ match: Callable[[Decimal], bool]
206
+
207
+ def __init__(self, op: str, value: Decimal) -> None:
208
+ if op == "=":
209
+ self.match = lambda x: x == value
210
+ elif op == ">=":
211
+ self.match = lambda x: x >= value
212
+ elif op == "<=":
213
+ self.match = lambda x: x <= value
214
+ elif op == ">":
215
+ self.match = lambda x: x > value
216
+ else: # op == "<":
217
+ self.match = lambda x: x < value
218
+
219
+ def __call__(self, obj: Any) -> bool:
220
+ # Compare to the absolute value to simplify this filter.
221
+ number = getattr(obj, "number", None)
222
+ return self.match(abs(number)) if number is not None else False
223
+
224
+
225
+ class FilterSyntaxParser:
226
+ precedence = (("left", "AND"), ("right", "UMINUS"))
227
+ tokens = FilterSyntaxLexer.tokens
228
+
229
+ def p_error(self, _: Any) -> None:
230
+ raise FilterParseError
231
+
232
+ def p_filter(self, p: list[Any]) -> None:
233
+ """
234
+ filter : expr
235
+ """
236
+ p[0] = p[1]
237
+
238
+ def p_expr(self, p: list[Any]) -> None:
239
+ """
240
+ expr : simple_expr
241
+ """
242
+ p[0] = p[1]
243
+
244
+ def p_expr_all(self, p: list[Any]) -> None:
245
+ """
246
+ expr : ALL expr ')'
247
+ """
248
+ expr = p[2]
249
+
250
+ def _match_postings(entry: Directive) -> bool:
251
+ return all(
252
+ expr(posting) for posting in getattr(entry, "postings", [])
253
+ )
254
+
255
+ p[0] = _match_postings
256
+
257
+ def p_expr_any(self, p: list[Any]) -> None:
258
+ """
259
+ expr : ANY expr ')'
260
+ """
261
+ expr = p[2]
262
+
263
+ def _match_postings(entry: Directive) -> bool:
264
+ return any(
265
+ expr(posting) for posting in getattr(entry, "postings", [])
266
+ )
267
+
268
+ p[0] = _match_postings
269
+
270
+ def p_expr_parentheses(self, p: list[Any]) -> None:
271
+ """
272
+ expr : '(' expr ')'
273
+ """
274
+ p[0] = p[2]
275
+
276
+ def p_expr_and(self, p: list[Any]) -> None:
277
+ """
278
+ expr : expr expr %prec AND
279
+ """
280
+ left, right = p[1], p[2]
281
+
282
+ def _and(entry: Directive) -> bool:
283
+ return left(entry) and right(entry) # type: ignore[no-any-return]
284
+
285
+ p[0] = _and
286
+
287
+ def p_expr_or(self, p: list[Any]) -> None:
288
+ """
289
+ expr : expr ',' expr
290
+ """
291
+ left, right = p[1], p[3]
292
+
293
+ def _or(entry: Directive) -> bool:
294
+ return left(entry) or right(entry) # type: ignore[no-any-return]
295
+
296
+ p[0] = _or
297
+
298
+ def p_expr_negated(self, p: list[Any]) -> None:
299
+ """
300
+ expr : '-' expr %prec UMINUS
301
+ """
302
+ func = p[2]
303
+
304
+ def _neg(entry: Directive) -> bool:
305
+ return not func(entry)
306
+
307
+ p[0] = _neg
308
+
309
+ def p_simple_expr_TAG(self, p: list[Any]) -> None: # noqa: N802
310
+ """
311
+ simple_expr : TAG
312
+ """
313
+ tag = p[1]
314
+
315
+ def _tag(entry: Directive) -> bool:
316
+ tags = getattr(entry, "tags", None)
317
+ return (tag in tags) if tags is not None else False
318
+
319
+ p[0] = _tag
320
+
321
+ def p_simple_expr_LINK(self, p: list[Any]) -> None: # noqa: N802
322
+ """
323
+ simple_expr : LINK
324
+ """
325
+ link = p[1]
326
+
327
+ def _link(entry: Directive) -> bool:
328
+ links = getattr(entry, "links", None)
329
+ return (link in links) if links is not None else False
330
+
331
+ p[0] = _link
332
+
333
+ def p_simple_expr_STRING(self, p: list[Any]) -> None: # noqa: N802
334
+ """
335
+ simple_expr : STRING
336
+ """
337
+ string = p[1]
338
+ match = Match(string)
339
+
340
+ def _string(entry: Directive) -> bool:
341
+ for name in ("narration", "payee", "comment"):
342
+ value = getattr(entry, name, "")
343
+ if value and match(value):
344
+ return True
345
+ return False
346
+
347
+ p[0] = _string
348
+
349
+ def p_simple_expr_key(self, p: list[Any]) -> None:
350
+ """
351
+ simple_expr : KEY EQ_OP STRING
352
+ | KEY CMP_OP NUMBER
353
+ """
354
+ key, op, value = p[1], p[2], p[3]
355
+ match: Match | MatchAmount = (
356
+ Match(value) if op == ":" else MatchAmount(op, value)
357
+ )
358
+
359
+ def _key(entry: Directive) -> bool:
360
+ if hasattr(entry, key):
361
+ return match(getattr(entry, key) or "")
362
+ if entry.meta is not None and key in entry.meta:
363
+ return match(entry.meta.get(key))
364
+ return False
365
+
366
+ p[0] = _key
367
+
368
+ def p_simple_expr_units(self, p: list[Any]) -> None:
369
+ """
370
+ simple_expr : CMP_OP NUMBER
371
+ """
372
+ op, value = p[1], p[2]
373
+ match = MatchAmount(op, value)
374
+
375
+ def _range(entry: Directive) -> bool:
376
+ return any(
377
+ match(posting.units)
378
+ for posting in getattr(entry, "postings", [])
379
+ )
380
+
381
+ p[0] = _range
382
+
383
+
384
+ class EntryFilter(ABC):
385
+ """Filters a list of entries."""
386
+
387
+ @abstractmethod
388
+ def apply(self, entries: Sequence[Directive]) -> Sequence[Directive]:
389
+ """Filter a list of directives."""
390
+
391
+
392
+ def _has_component(account_name: str, component: str) -> bool:
393
+ """Check if account name contains a specific component."""
394
+ return component in account_name.split(":")
395
+
396
+
397
+ class TimeFilter(EntryFilter):
398
+ """Filter by dates."""
399
+
400
+ __slots__ = ("date_range",)
401
+
402
+ def __init__(
403
+ self,
404
+ options: BeancountOptions,
405
+ fava_options: RustfavaOptions,
406
+ value: str,
407
+ ) -> None:
408
+ del options # unused
409
+ begin, end = parse_date(value, fava_options.fiscal_year_end)
410
+ if not begin or not end:
411
+ raise TimeFilterParseError(value)
412
+ self.date_range = DateRange(begin, end)
413
+
414
+ def apply(self, entries: Sequence[Directive]) -> Sequence[Directive]:
415
+ from rustfava.rustledger.engine import RustledgerEngine
416
+ from rustfava.rustledger.types import directives_from_json
417
+ from rustfava.rustledger.types import directives_to_json
418
+
419
+ # Use native rustledger clamp_entries
420
+ engine = RustledgerEngine.get_instance()
421
+ entries_json = directives_to_json(list(entries))
422
+ result = engine.clamp_entries(
423
+ entries_json,
424
+ str(self.date_range.begin),
425
+ str(self.date_range.end),
426
+ )
427
+ return directives_from_json(result.get("entries", []))
428
+
429
+
430
+ LEXER = FilterSyntaxLexer()
431
+ PARSE = ply.yacc.yacc(
432
+ errorlog=ply.yacc.NullLogger(),
433
+ write_tables=False,
434
+ debug=False,
435
+ module=FilterSyntaxParser(),
436
+ ).parse
437
+
438
+
439
+ class AdvancedFilter(EntryFilter):
440
+ """Filter by tags and links and keys."""
441
+
442
+ __slots__ = ("_include",)
443
+
444
+ def __init__(self, value: str) -> None:
445
+ try:
446
+ tokens = LEXER.lex(value)
447
+ self._include = PARSE(
448
+ lexer="NONE",
449
+ tokenfunc=lambda toks=tokens: next(toks, None), # ty:ignore[invalid-argument-type]
450
+ )
451
+ except FilterError as exception:
452
+ exception.message += value
453
+ raise
454
+
455
+ def apply(self, entries: Sequence[Directive]) -> Sequence[Directive]:
456
+ include = self._include
457
+ return [entry for entry in entries if include(entry)]
458
+
459
+
460
+ class AccountFilter(EntryFilter):
461
+ """Filter by account.
462
+
463
+ The filter string can either be a regular expression or a parent account.
464
+ """
465
+
466
+ __slots__ = ("_match", "_value")
467
+
468
+ def __init__(self, value: str) -> None:
469
+ self._value = value
470
+ self._match = Match(value)
471
+
472
+ def apply(self, entries: Sequence[Directive]) -> Sequence[Directive]:
473
+ value = self._value
474
+ if not value:
475
+ return entries
476
+ match = self._match
477
+ return [
478
+ entry
479
+ for entry in entries
480
+ if any(
481
+ _has_component(name, value) or match(name)
482
+ for name in get_entry_accounts(entry)
483
+ )
484
+ ]
@@ -0,0 +1,97 @@
1
+ """Entries grouped by type."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from typing import NamedTuple
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rustfava.beans import abc
10
+ from rustfava.beans.account import get_entry_accounts
11
+
12
+ if TYPE_CHECKING: # pragma: no cover
13
+ from collections.abc import Mapping
14
+ from collections.abc import Sequence
15
+
16
+
17
+ class EntriesByType(NamedTuple):
18
+ """Entries grouped by type."""
19
+
20
+ Balance: Sequence[abc.Balance]
21
+ Close: Sequence[abc.Close]
22
+ Commodity: Sequence[abc.Commodity]
23
+ Custom: Sequence[abc.Custom]
24
+ Document: Sequence[abc.Document]
25
+ Event: Sequence[abc.Event]
26
+ Note: Sequence[abc.Note]
27
+ Open: Sequence[abc.Open]
28
+ Pad: Sequence[abc.Pad]
29
+ Price: Sequence[abc.Price]
30
+ Query: Sequence[abc.Query]
31
+ Transaction: Sequence[abc.Transaction]
32
+
33
+
34
+ def group_entries_by_type(entries: Sequence[abc.Directive]) -> EntriesByType:
35
+ """Group entries by type.
36
+
37
+ Arguments:
38
+ entries: A list of entries to group.
39
+
40
+ Returns:
41
+ A namedtuple containing the grouped lists of entries.
42
+ """
43
+ entries_by_type = EntriesByType(
44
+ [],
45
+ [],
46
+ [],
47
+ [],
48
+ [],
49
+ [],
50
+ [],
51
+ [],
52
+ [],
53
+ [],
54
+ [],
55
+ [],
56
+ )
57
+ for entry in entries:
58
+ # Handle both beancount types (e.g., "Transaction") and
59
+ # rustledger types (e.g., "RLTransaction")
60
+ type_name = entry.__class__.__name__
61
+ if type_name.startswith("RL"):
62
+ type_name = type_name[2:] # Strip "RL" prefix
63
+ getattr(entries_by_type, type_name).append(entry)
64
+ return entries_by_type
65
+
66
+
67
+ class TransactionPosting(NamedTuple):
68
+ """Pair of a transaction and a posting."""
69
+
70
+ transaction: abc.Transaction
71
+ posting: abc.Posting
72
+
73
+
74
+ def group_entries_by_account(
75
+ entries: Sequence[abc.Directive],
76
+ ) -> Mapping[str, Sequence[abc.Directive | TransactionPosting]]:
77
+ """Group entries by account.
78
+
79
+ Arguments:
80
+ entries: A list of entries.
81
+
82
+ Returns:
83
+ A dict mapping account names to their entries.
84
+ """
85
+ res: dict[str, list[abc.Directive | TransactionPosting]] = defaultdict(
86
+ list,
87
+ )
88
+
89
+ for entry in entries:
90
+ if isinstance(entry, abc.Transaction):
91
+ for posting in entry.postings:
92
+ res[posting.account].append(TransactionPosting(entry, posting))
93
+ else:
94
+ for account in get_entry_accounts(entry):
95
+ res[account].append(entry)
96
+
97
+ return dict(sorted(res.items()))