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/filters.py
ADDED
|
@@ -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()))
|