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/tree.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Account balance trees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from operator import attrgetter
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from rustfava.beans.abc import Open
|
|
11
|
+
from rustfava.beans.account import parent as account_parent
|
|
12
|
+
from rustfava.context import g
|
|
13
|
+
from rustfava.core.conversion import AT_COST
|
|
14
|
+
from rustfava.core.conversion import AT_VALUE
|
|
15
|
+
from rustfava.core.inventory import CounterInventory
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
18
|
+
import datetime
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
|
|
22
|
+
from beancount.core import data
|
|
23
|
+
|
|
24
|
+
from rustfava.beans.abc import Directive
|
|
25
|
+
from rustfava.beans.prices import RustfavaPriceMap
|
|
26
|
+
from rustfava.beans.types import BeancountOptions
|
|
27
|
+
from rustfava.core.conversion import Conversion
|
|
28
|
+
from rustfava.core.inventory import SimpleCounterInventory
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class SerialisedTreeNode:
|
|
33
|
+
"""A serialised TreeNode."""
|
|
34
|
+
|
|
35
|
+
account: str
|
|
36
|
+
balance: SimpleCounterInventory
|
|
37
|
+
balance_children: SimpleCounterInventory
|
|
38
|
+
children: Sequence[SerialisedTreeNode]
|
|
39
|
+
has_txns: bool
|
|
40
|
+
cost: SimpleCounterInventory | None = None
|
|
41
|
+
cost_children: SimpleCounterInventory | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TreeNode:
|
|
45
|
+
"""A node in the account tree."""
|
|
46
|
+
|
|
47
|
+
__slots__ = ("balance", "balance_children", "children", "has_txns", "name")
|
|
48
|
+
|
|
49
|
+
def __init__(self, name: str) -> None:
|
|
50
|
+
#: Account name.
|
|
51
|
+
self.name: str = name
|
|
52
|
+
#: A list of :class:`.TreeNode`, its children.
|
|
53
|
+
self.children: list[TreeNode] = []
|
|
54
|
+
#: The cumulative account balance.
|
|
55
|
+
self.balance_children = CounterInventory()
|
|
56
|
+
#: The account balance.
|
|
57
|
+
self.balance = CounterInventory()
|
|
58
|
+
#: Whether the account has any transactions.
|
|
59
|
+
self.has_txns = False
|
|
60
|
+
|
|
61
|
+
def serialise(
|
|
62
|
+
self,
|
|
63
|
+
conversion: Conversion,
|
|
64
|
+
prices: RustfavaPriceMap,
|
|
65
|
+
end: datetime.date | None,
|
|
66
|
+
*,
|
|
67
|
+
with_cost: bool = False,
|
|
68
|
+
) -> SerialisedTreeNode:
|
|
69
|
+
"""Serialise the account.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
conversion: The conversion to use.
|
|
73
|
+
prices: The price map to use.
|
|
74
|
+
end: A date to use for cost conversions.
|
|
75
|
+
with_cost: Additionally convert to cost.
|
|
76
|
+
"""
|
|
77
|
+
children = [
|
|
78
|
+
child.serialise(conversion, prices, end, with_cost=with_cost)
|
|
79
|
+
for child in sorted(self.children, key=attrgetter("name"))
|
|
80
|
+
]
|
|
81
|
+
return (
|
|
82
|
+
SerialisedTreeNode(
|
|
83
|
+
self.name,
|
|
84
|
+
conversion.apply(self.balance, prices, end),
|
|
85
|
+
conversion.apply(self.balance_children, prices, end),
|
|
86
|
+
children,
|
|
87
|
+
self.has_txns,
|
|
88
|
+
AT_COST.apply(self.balance),
|
|
89
|
+
AT_COST.apply(self.balance_children),
|
|
90
|
+
)
|
|
91
|
+
if with_cost
|
|
92
|
+
else SerialisedTreeNode(
|
|
93
|
+
self.name,
|
|
94
|
+
conversion.apply(self.balance, prices, end),
|
|
95
|
+
conversion.apply(self.balance_children, prices, end),
|
|
96
|
+
children,
|
|
97
|
+
self.has_txns,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def serialise_with_context(self) -> SerialisedTreeNode:
|
|
102
|
+
"""Serialise, getting all parameters from Flask context."""
|
|
103
|
+
return self.serialise(
|
|
104
|
+
g.conv,
|
|
105
|
+
g.ledger.prices,
|
|
106
|
+
g.filtered.end_date,
|
|
107
|
+
with_cost=g.conv == AT_VALUE,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Tree(dict[str, TreeNode]):
|
|
112
|
+
"""Account tree.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
entries: A list of entries to compute balances from.
|
|
116
|
+
create_accounts: A list of accounts that the tree should contain.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
entries: Iterable[Directive | data.Directive] | None = None,
|
|
122
|
+
create_accounts: list[str] | None = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
super().__init__(self)
|
|
125
|
+
self.get("", insert=True)
|
|
126
|
+
if create_accounts:
|
|
127
|
+
for account in create_accounts:
|
|
128
|
+
self.get(account, insert=True)
|
|
129
|
+
if entries:
|
|
130
|
+
account_balances: dict[str, CounterInventory]
|
|
131
|
+
account_balances = defaultdict(CounterInventory)
|
|
132
|
+
for entry in entries:
|
|
133
|
+
if isinstance(entry, Open):
|
|
134
|
+
self.get(entry.account, insert=True)
|
|
135
|
+
for posting in getattr(entry, "postings", []):
|
|
136
|
+
account_balances[posting.account].add_position(posting)
|
|
137
|
+
|
|
138
|
+
for name, balance in sorted(account_balances.items()):
|
|
139
|
+
self.insert(name, balance)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def accounts(self) -> list[str]:
|
|
143
|
+
"""The accounts in this tree."""
|
|
144
|
+
return sorted(self.keys())
|
|
145
|
+
|
|
146
|
+
def ancestors(self, name: str) -> Iterable[TreeNode]:
|
|
147
|
+
"""Ancestors of an account.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
name: An account name.
|
|
151
|
+
|
|
152
|
+
Yields:
|
|
153
|
+
The ancestors of the given account from the bottom up.
|
|
154
|
+
"""
|
|
155
|
+
while name:
|
|
156
|
+
name = account_parent(name) or ""
|
|
157
|
+
yield self.get(name)
|
|
158
|
+
|
|
159
|
+
def insert(self, name: str, balance: CounterInventory) -> None:
|
|
160
|
+
"""Insert account with a balance.
|
|
161
|
+
|
|
162
|
+
Insert account and update its balance and the balances of its
|
|
163
|
+
ancestors.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
name: An account name.
|
|
167
|
+
balance: The balance of the account.
|
|
168
|
+
"""
|
|
169
|
+
node = self.get(name, insert=True)
|
|
170
|
+
node.balance.add_inventory(balance)
|
|
171
|
+
node.balance_children.add_inventory(balance)
|
|
172
|
+
node.has_txns = True
|
|
173
|
+
for parent_node in self.ancestors(name):
|
|
174
|
+
parent_node.balance_children.add_inventory(balance)
|
|
175
|
+
|
|
176
|
+
def get( # type: ignore[override]
|
|
177
|
+
self,
|
|
178
|
+
name: str,
|
|
179
|
+
*,
|
|
180
|
+
insert: bool = False,
|
|
181
|
+
) -> TreeNode:
|
|
182
|
+
"""Get an account.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
name: An account name.
|
|
186
|
+
insert: If True, insert the name into the tree if it does not
|
|
187
|
+
exist.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
TreeNode: The account of that name or an empty account if the
|
|
191
|
+
account is not in the tree.
|
|
192
|
+
"""
|
|
193
|
+
try:
|
|
194
|
+
return self[name]
|
|
195
|
+
except KeyError:
|
|
196
|
+
node = TreeNode(name)
|
|
197
|
+
if insert:
|
|
198
|
+
if name:
|
|
199
|
+
parent = self.get(account_parent(name) or "", insert=True)
|
|
200
|
+
parent.children.append(node)
|
|
201
|
+
self[name] = node
|
|
202
|
+
return node
|
|
203
|
+
|
|
204
|
+
def net_profit(
|
|
205
|
+
self,
|
|
206
|
+
options: BeancountOptions,
|
|
207
|
+
account_name: str,
|
|
208
|
+
) -> TreeNode:
|
|
209
|
+
"""Calculate the net profit.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
options: The Beancount options.
|
|
213
|
+
account_name: The name to use for the account containing the net
|
|
214
|
+
profit.
|
|
215
|
+
"""
|
|
216
|
+
income = self.get(options["name_income"])
|
|
217
|
+
expenses = self.get(options["name_expenses"])
|
|
218
|
+
|
|
219
|
+
net_profit = Tree()
|
|
220
|
+
net_profit.insert(
|
|
221
|
+
account_name,
|
|
222
|
+
income.balance_children + expenses.balance_children,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return net_profit.get(account_name)
|
|
226
|
+
|
|
227
|
+
def cap(self, options: BeancountOptions, unrealized_account: str) -> None:
|
|
228
|
+
"""Transfer Income and Expenses, add conversions and unrealized gains.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
options: The Beancount options.
|
|
232
|
+
unrealized_account: The name of the account to post unrealized
|
|
233
|
+
gains to (as a subaccount of Equity).
|
|
234
|
+
"""
|
|
235
|
+
equity = options["name_equity"]
|
|
236
|
+
conversions = CounterInventory(
|
|
237
|
+
{
|
|
238
|
+
(currency, None): -number
|
|
239
|
+
for currency, number in AT_COST.apply(
|
|
240
|
+
self.get("").balance_children
|
|
241
|
+
).items()
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Add conversions
|
|
246
|
+
self.insert(
|
|
247
|
+
equity + ":" + options["account_current_conversions"],
|
|
248
|
+
conversions,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Insert unrealized gains.
|
|
252
|
+
self.insert(
|
|
253
|
+
equity + ":" + unrealized_account,
|
|
254
|
+
-self.get("").balance_children,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Transfer Income and Expenses
|
|
258
|
+
self.insert(
|
|
259
|
+
equity + ":" + options["account_current_earnings"],
|
|
260
|
+
self.get(options["name_income"]).balance_children,
|
|
261
|
+
)
|
|
262
|
+
self.insert(
|
|
263
|
+
equity + ":" + options["account_current_earnings"],
|
|
264
|
+
self.get(options["name_expenses"]).balance_children,
|
|
265
|
+
)
|
rustfava/core/watcher.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""A simple file and folder watcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import abc
|
|
6
|
+
import atexit
|
|
7
|
+
import logging
|
|
8
|
+
import threading
|
|
9
|
+
from os import walk
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from watchfiles import Change
|
|
14
|
+
from watchfiles import DefaultFilter
|
|
15
|
+
from watchfiles import watch
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
18
|
+
import types
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from collections.abc import Iterable
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _WatchfilesThread(threading.Thread):
|
|
28
|
+
"""Class for the watchfiles watcher threads.
|
|
29
|
+
|
|
30
|
+
We use two separated threads since we want to recursively watch directories
|
|
31
|
+
and for paths, we need to watch the parent directory (to check changes done
|
|
32
|
+
by file replacements by some editors) non-recursively (for performance).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
paths: set[Path],
|
|
38
|
+
mtime: int,
|
|
39
|
+
*,
|
|
40
|
+
is_relevant: Callable[[Change, str], bool] | None = None,
|
|
41
|
+
recursive: bool = False,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(daemon=True)
|
|
44
|
+
self.paths = paths
|
|
45
|
+
self.mtime = mtime
|
|
46
|
+
self._is_relevant = is_relevant or DefaultFilter()
|
|
47
|
+
self._recursive = recursive
|
|
48
|
+
self._stop_event = threading.Event()
|
|
49
|
+
|
|
50
|
+
def stop(self) -> None:
|
|
51
|
+
"""Set the stop event for watchfiles and join the thread."""
|
|
52
|
+
self._stop_event.set()
|
|
53
|
+
self.join()
|
|
54
|
+
|
|
55
|
+
def run(self) -> None:
|
|
56
|
+
"""Watch for changes."""
|
|
57
|
+
atexit.register(self.stop)
|
|
58
|
+
|
|
59
|
+
for changes in watch(
|
|
60
|
+
*self.paths,
|
|
61
|
+
recursive=self._recursive,
|
|
62
|
+
stop_event=self._stop_event,
|
|
63
|
+
ignore_permission_denied=True,
|
|
64
|
+
watch_filter=self._is_relevant,
|
|
65
|
+
):
|
|
66
|
+
for change_type, path_str in changes:
|
|
67
|
+
path = Path(path_str)
|
|
68
|
+
# move up the tree to an existing path
|
|
69
|
+
while not path.exists():
|
|
70
|
+
path = path.parent
|
|
71
|
+
change_mtime = path.stat().st_mtime_ns
|
|
72
|
+
if change_type is Change.added:
|
|
73
|
+
# check parent to get possibly newer timestamp of addition
|
|
74
|
+
change_mtime = max(
|
|
75
|
+
change_mtime, Path(path_str).parent.stat().st_mtime_ns
|
|
76
|
+
)
|
|
77
|
+
self.mtime = max(change_mtime, self.mtime)
|
|
78
|
+
log.debug("new mtime: %s", self.mtime)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _FilesWatchfilesThread(_WatchfilesThread):
|
|
82
|
+
def __init__(self, files: set[Path], mtime: int) -> None:
|
|
83
|
+
paths = {f.parent for f in files}
|
|
84
|
+
|
|
85
|
+
def is_relevant(_c: Change, path: str) -> bool:
|
|
86
|
+
return Path(path) in files
|
|
87
|
+
|
|
88
|
+
super().__init__(
|
|
89
|
+
paths, mtime, is_relevant=is_relevant, recursive=False
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class WatcherBase(abc.ABC):
|
|
94
|
+
"""ABC for rustfava ledger file watchers."""
|
|
95
|
+
|
|
96
|
+
last_checked: int
|
|
97
|
+
"""Timestamp of the latest change noticed by the file watcher."""
|
|
98
|
+
|
|
99
|
+
last_notified: int
|
|
100
|
+
"""Timestamp of the latest change that the watcher was notified of."""
|
|
101
|
+
|
|
102
|
+
@abc.abstractmethod
|
|
103
|
+
def update(self, files: Iterable[Path], folders: Iterable[Path]) -> None:
|
|
104
|
+
"""Update the folders/files to watch.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
files: A list of file paths.
|
|
108
|
+
folders: A list of paths to folders.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def check(self) -> bool:
|
|
112
|
+
"""Check for changes.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
`True` if there was a file change in one of the files or folders,
|
|
116
|
+
`False` otherwise.
|
|
117
|
+
"""
|
|
118
|
+
latest_mtime = max(self._get_latest_mtime(), self.last_notified)
|
|
119
|
+
has_higher_mtime = latest_mtime > self.last_checked
|
|
120
|
+
if has_higher_mtime:
|
|
121
|
+
self.last_checked = latest_mtime
|
|
122
|
+
return has_higher_mtime
|
|
123
|
+
|
|
124
|
+
def notify(self, path: Path) -> None:
|
|
125
|
+
"""Notify the watcher of a change to a path."""
|
|
126
|
+
try:
|
|
127
|
+
change_mtime = Path(path).stat().st_mtime_ns
|
|
128
|
+
except FileNotFoundError:
|
|
129
|
+
change_mtime = max(self.last_notified, self.last_checked) + 1
|
|
130
|
+
self.last_notified = max(self.last_notified, change_mtime)
|
|
131
|
+
|
|
132
|
+
@abc.abstractmethod
|
|
133
|
+
def _get_latest_mtime(self) -> int:
|
|
134
|
+
"""Get the latest change mtime."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class WatchfilesWatcher(WatcherBase):
|
|
138
|
+
"""A file and folder watcher using the watchfiles library."""
|
|
139
|
+
|
|
140
|
+
def __init__(self) -> None:
|
|
141
|
+
self.last_checked = 0
|
|
142
|
+
self.last_notified = 0
|
|
143
|
+
self._paths: tuple[set[Path], set[Path]] | None = None
|
|
144
|
+
self._watchers: tuple[_WatchfilesThread, _WatchfilesThread] | None = (
|
|
145
|
+
None
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def update(self, files: Iterable[Path], folders: Iterable[Path]) -> None:
|
|
149
|
+
"""Update the folders/files to watch."""
|
|
150
|
+
files_set = {p.absolute() for p in files if p.exists()}
|
|
151
|
+
folders_set = {p.absolute() for p in folders if p.is_dir()}
|
|
152
|
+
new_paths = (files_set, folders_set)
|
|
153
|
+
if self._watchers and new_paths == self._paths:
|
|
154
|
+
self.check()
|
|
155
|
+
return
|
|
156
|
+
self._paths = new_paths
|
|
157
|
+
if self._watchers:
|
|
158
|
+
self._watchers[0].stop()
|
|
159
|
+
self._watchers[1].stop()
|
|
160
|
+
self._watchers = (
|
|
161
|
+
_FilesWatchfilesThread(files_set, self.last_checked),
|
|
162
|
+
_WatchfilesThread(folders_set, self.last_checked, recursive=True),
|
|
163
|
+
)
|
|
164
|
+
self._watchers[0].start()
|
|
165
|
+
self._watchers[1].start()
|
|
166
|
+
self.check()
|
|
167
|
+
|
|
168
|
+
def __enter__(self) -> None:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
def __exit__(
|
|
172
|
+
self,
|
|
173
|
+
exc_type: type[BaseException] | None,
|
|
174
|
+
exc_value: BaseException | None,
|
|
175
|
+
traceback: types.TracebackType | None,
|
|
176
|
+
) -> None:
|
|
177
|
+
if self._watchers:
|
|
178
|
+
self._watchers[0].stop()
|
|
179
|
+
self._watchers[1].stop()
|
|
180
|
+
|
|
181
|
+
def _get_latest_mtime(self) -> int:
|
|
182
|
+
return (
|
|
183
|
+
max(self._watchers[0].mtime, self._watchers[1].mtime)
|
|
184
|
+
if self._watchers
|
|
185
|
+
else 0
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Watcher(WatcherBase):
|
|
190
|
+
"""A simple file and folder watcher.
|
|
191
|
+
|
|
192
|
+
For folders, only checks mtime of the folder and all subdirectories.
|
|
193
|
+
So a file change won't be noticed, but only new/deleted files.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def __init__(self) -> None:
|
|
197
|
+
self.last_checked = 0
|
|
198
|
+
self.last_notified = 0
|
|
199
|
+
self._files: Sequence[Path] = []
|
|
200
|
+
self._folders: Sequence[Path] = []
|
|
201
|
+
|
|
202
|
+
def update(self, files: Iterable[Path], folders: Iterable[Path]) -> None:
|
|
203
|
+
"""Update the folders/files to watch."""
|
|
204
|
+
self._files = list(files)
|
|
205
|
+
self._folders = list(folders)
|
|
206
|
+
self.check()
|
|
207
|
+
|
|
208
|
+
def _mtimes(self) -> Iterable[int]:
|
|
209
|
+
for path in self._files:
|
|
210
|
+
try:
|
|
211
|
+
yield path.stat().st_mtime_ns
|
|
212
|
+
except FileNotFoundError:
|
|
213
|
+
yield max(self.last_notified, self.last_checked) + 1
|
|
214
|
+
for path in self._folders:
|
|
215
|
+
for dirpath, _, _ in walk(path):
|
|
216
|
+
yield Path(dirpath).stat().st_mtime_ns
|
|
217
|
+
|
|
218
|
+
def _get_latest_mtime(self) -> int:
|
|
219
|
+
return max(self._mtimes())
|
rustfava/ext/__init__.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""rustfava's extension system."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import importlib
|
|
7
|
+
import inspect
|
|
8
|
+
import sys
|
|
9
|
+
from functools import cached_property
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import jinja2
|
|
15
|
+
from flask import current_app
|
|
16
|
+
|
|
17
|
+
from rustfava.helpers import BeancountError
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from typing import TypeVar
|
|
22
|
+
|
|
23
|
+
from flask.wrappers import Response
|
|
24
|
+
|
|
25
|
+
from rustfava.beans.abc import Directive
|
|
26
|
+
from rustfava.core import RustfavaLedger
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RustfavaExtensionError(BeancountError):
|
|
30
|
+
"""Error in one of Fava's extensions."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class JinjaLoaderMissingError(ValueError): # noqa: D101
|
|
34
|
+
def __init__(self) -> None: # pragma: no cover
|
|
35
|
+
super().__init__("Expected Flask app to have jinja_loader.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ExtensionConfigError(ValueError): # noqa: D101
|
|
39
|
+
def __init__(self, error: SyntaxError, config: str) -> None:
|
|
40
|
+
super().__init__(
|
|
41
|
+
f"Could not load extension config: {error} in '{config}'."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RustfavaExtensionBase:
|
|
46
|
+
"""Base class for extensions for Fava.
|
|
47
|
+
|
|
48
|
+
Any extension should inherit from this class. :func:`find_extension` will
|
|
49
|
+
discover all subclasses of this class in the specified modules.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
#: Name for a HTML report for this extension.
|
|
53
|
+
report_title: str | None = None
|
|
54
|
+
|
|
55
|
+
#: Whether this extension includes a Javascript module.
|
|
56
|
+
has_js_module: bool = False
|
|
57
|
+
|
|
58
|
+
config: Any
|
|
59
|
+
|
|
60
|
+
endpoints: dict[tuple[str, str], Callable[[RustfavaExtensionBase], Any]]
|
|
61
|
+
|
|
62
|
+
def __init__(self, ledger: RustfavaLedger, config: str | None = None) -> None:
|
|
63
|
+
"""Initialise extension.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
ledger: Input ledger file.
|
|
67
|
+
config: Configuration options string passed from the
|
|
68
|
+
beancount file's 'fava-extension' line.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ExtensionConfigError: If the config cannot be parsed.
|
|
72
|
+
"""
|
|
73
|
+
self.endpoints = {}
|
|
74
|
+
self.config = None
|
|
75
|
+
|
|
76
|
+
# Go through each of the subclass's functions to find the ones
|
|
77
|
+
# marked as endpoints by @extension_endpoint
|
|
78
|
+
for _, func in inspect.getmembers(self.__class__, inspect.isfunction):
|
|
79
|
+
if hasattr(func, "endpoint_key"):
|
|
80
|
+
name, methods = func.endpoint_key
|
|
81
|
+
for method in methods:
|
|
82
|
+
self.endpoints[name, method] = func
|
|
83
|
+
|
|
84
|
+
self.ledger = ledger
|
|
85
|
+
if config:
|
|
86
|
+
try:
|
|
87
|
+
self.config = ast.literal_eval(config)
|
|
88
|
+
except SyntaxError as error:
|
|
89
|
+
raise ExtensionConfigError(error, config) from error
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def name(self) -> str:
|
|
93
|
+
"""Unique name of this extension."""
|
|
94
|
+
return self.__class__.__qualname__
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def extension_dir(self) -> Path:
|
|
98
|
+
"""Directory to look for templates directory and Javascript code."""
|
|
99
|
+
return Path(inspect.getfile(self.__class__)).parent
|
|
100
|
+
|
|
101
|
+
@cached_property
|
|
102
|
+
def jinja_env(self) -> jinja2.Environment:
|
|
103
|
+
"""Jinja env for this extension."""
|
|
104
|
+
if not current_app.jinja_loader: # pragma: no cover
|
|
105
|
+
raise JinjaLoaderMissingError
|
|
106
|
+
ext_loader = jinja2.FileSystemLoader(self.extension_dir / "templates")
|
|
107
|
+
loader = jinja2.ChoiceLoader([ext_loader, current_app.jinja_loader])
|
|
108
|
+
return current_app.jinja_env.overlay(loader=loader)
|
|
109
|
+
|
|
110
|
+
def after_load_file(self) -> None:
|
|
111
|
+
"""Run after a ledger file has been loaded."""
|
|
112
|
+
|
|
113
|
+
def before_request(self) -> None:
|
|
114
|
+
"""Run before each client request."""
|
|
115
|
+
|
|
116
|
+
def after_entry_modified(self, entry: Directive, new_lines: str) -> None:
|
|
117
|
+
"""Run after an `entry` has been modified."""
|
|
118
|
+
|
|
119
|
+
def after_insert_entry(self, entry: Directive) -> None:
|
|
120
|
+
"""Run after an `entry` has been inserted."""
|
|
121
|
+
|
|
122
|
+
def after_delete_entry(self, entry: Directive) -> None:
|
|
123
|
+
"""Run after an `entry` has been deleted."""
|
|
124
|
+
|
|
125
|
+
def after_insert_metadata(
|
|
126
|
+
self,
|
|
127
|
+
entry: Directive,
|
|
128
|
+
key: str,
|
|
129
|
+
value: str,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Run after metadata (key: value) was added to an entry."""
|
|
132
|
+
|
|
133
|
+
def after_write_source(self, path: str, source: str) -> None:
|
|
134
|
+
"""Run after `source` has been written to path."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def find_extensions(
|
|
138
|
+
base_path: Path,
|
|
139
|
+
name: str,
|
|
140
|
+
) -> tuple[list[type[RustfavaExtensionBase]], list[RustfavaExtensionError]]:
|
|
141
|
+
"""Find extensions in a module.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
base_path: The module can be relative to this path.
|
|
145
|
+
name: The name of the module containing the extensions.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
A tuple (classes, errors) where classes is a list of subclasses of
|
|
149
|
+
:class:`RustfavaExtensionBase` found in ``name``.
|
|
150
|
+
"""
|
|
151
|
+
classes = []
|
|
152
|
+
|
|
153
|
+
sys.path.insert(0, str(base_path))
|
|
154
|
+
try:
|
|
155
|
+
module = importlib.import_module(name)
|
|
156
|
+
except ImportError as err:
|
|
157
|
+
error = RustfavaExtensionError(
|
|
158
|
+
None,
|
|
159
|
+
f'Importing module "{name}" failed.\nError: "{err.msg}"',
|
|
160
|
+
None,
|
|
161
|
+
)
|
|
162
|
+
return (
|
|
163
|
+
[],
|
|
164
|
+
[error],
|
|
165
|
+
)
|
|
166
|
+
for _, obj in inspect.getmembers(module, inspect.isclass):
|
|
167
|
+
if issubclass(obj, RustfavaExtensionBase) and obj != RustfavaExtensionBase:
|
|
168
|
+
classes.append(obj)
|
|
169
|
+
sys.path.pop(0)
|
|
170
|
+
|
|
171
|
+
if not classes:
|
|
172
|
+
error = RustfavaExtensionError(
|
|
173
|
+
None,
|
|
174
|
+
f'Module "{name}" contains no extensions.',
|
|
175
|
+
None,
|
|
176
|
+
)
|
|
177
|
+
return (
|
|
178
|
+
[],
|
|
179
|
+
[error],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return classes, []
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
186
|
+
T = TypeVar("T", bound=RustfavaExtensionBase)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def extension_endpoint(
|
|
190
|
+
func_or_endpoint_name: (Callable[[T], Any] | str | None) = None,
|
|
191
|
+
methods: list[str] | None = None,
|
|
192
|
+
) -> (
|
|
193
|
+
Callable[[T], Response]
|
|
194
|
+
| Callable[
|
|
195
|
+
[Callable[[T], Response]],
|
|
196
|
+
Callable[[T], Response],
|
|
197
|
+
]
|
|
198
|
+
):
|
|
199
|
+
"""Decorator to mark a function as an endpoint.
|
|
200
|
+
|
|
201
|
+
Can be used as `@extension_endpoint` or
|
|
202
|
+
`@extension_endpoint(endpoint_name, methods)`.
|
|
203
|
+
|
|
204
|
+
When used as @extension_endpoint, the endpoint name is the name of the
|
|
205
|
+
function and methods is "GET".
|
|
206
|
+
|
|
207
|
+
When used as @extension_endpoint(endpoint_name, methods), the given
|
|
208
|
+
endpoint name and methods are used, but both are optional. If
|
|
209
|
+
endpoint_name is None, default to the function name, and if methods
|
|
210
|
+
is None, default to "GET".
|
|
211
|
+
"""
|
|
212
|
+
endpoint_name = (
|
|
213
|
+
func_or_endpoint_name
|
|
214
|
+
if isinstance(func_or_endpoint_name, str)
|
|
215
|
+
else None
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def decorator(
|
|
219
|
+
func: Callable[[T], Response],
|
|
220
|
+
) -> Callable[[T], Response]:
|
|
221
|
+
f: Any = func
|
|
222
|
+
f.endpoint_key = ( # ty:ignore[unresolved-attribute]
|
|
223
|
+
endpoint_name or func.__name__, # ty:ignore[unresolved-attribute]
|
|
224
|
+
methods or ["GET"],
|
|
225
|
+
)
|
|
226
|
+
return func
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
decorator(func_or_endpoint_name) # ty:ignore[invalid-argument-type]
|
|
230
|
+
if callable(func_or_endpoint_name)
|
|
231
|
+
else decorator
|
|
232
|
+
)
|