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/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
+ )
@@ -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())
@@ -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
+ )