rustfava 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. rustfava/__init__.py +30 -0
  2. rustfava/_ctx_globals_class.py +55 -0
  3. rustfava/api_models.py +36 -0
  4. rustfava/application.py +534 -0
  5. rustfava/beans/__init__.py +6 -0
  6. rustfava/beans/abc.py +327 -0
  7. rustfava/beans/account.py +79 -0
  8. rustfava/beans/create.py +377 -0
  9. rustfava/beans/flags.py +20 -0
  10. rustfava/beans/funcs.py +38 -0
  11. rustfava/beans/helpers.py +52 -0
  12. rustfava/beans/ingest.py +75 -0
  13. rustfava/beans/load.py +31 -0
  14. rustfava/beans/prices.py +151 -0
  15. rustfava/beans/protocols.py +82 -0
  16. rustfava/beans/str.py +454 -0
  17. rustfava/beans/types.py +63 -0
  18. rustfava/cli.py +187 -0
  19. rustfava/context.py +13 -0
  20. rustfava/core/__init__.py +729 -0
  21. rustfava/core/accounts.py +161 -0
  22. rustfava/core/attributes.py +145 -0
  23. rustfava/core/budgets.py +207 -0
  24. rustfava/core/charts.py +301 -0
  25. rustfava/core/commodities.py +37 -0
  26. rustfava/core/conversion.py +229 -0
  27. rustfava/core/documents.py +87 -0
  28. rustfava/core/extensions.py +132 -0
  29. rustfava/core/fava_options.py +255 -0
  30. rustfava/core/file.py +542 -0
  31. rustfava/core/filters.py +484 -0
  32. rustfava/core/group_entries.py +97 -0
  33. rustfava/core/ingest.py +509 -0
  34. rustfava/core/inventory.py +167 -0
  35. rustfava/core/misc.py +105 -0
  36. rustfava/core/module_base.py +18 -0
  37. rustfava/core/number.py +106 -0
  38. rustfava/core/query.py +180 -0
  39. rustfava/core/query_shell.py +301 -0
  40. rustfava/core/tree.py +265 -0
  41. rustfava/core/watcher.py +219 -0
  42. rustfava/ext/__init__.py +232 -0
  43. rustfava/ext/auto_commit.py +61 -0
  44. rustfava/ext/portfolio_list/PortfolioList.js +34 -0
  45. rustfava/ext/portfolio_list/__init__.py +29 -0
  46. rustfava/ext/portfolio_list/templates/PortfolioList.html +15 -0
  47. rustfava/ext/rustfava_ext_test/RustfavaExtTest.js +42 -0
  48. rustfava/ext/rustfava_ext_test/__init__.py +207 -0
  49. rustfava/ext/rustfava_ext_test/templates/RustfavaExtTest.html +45 -0
  50. rustfava/ext/rustfava_ext_test/templates/RustfavaExtTestInclude.html +1 -0
  51. rustfava/help/__init__.py +15 -0
  52. rustfava/help/_index.md +29 -0
  53. rustfava/help/beancount_syntax.md +156 -0
  54. rustfava/help/budgets.md +31 -0
  55. rustfava/help/conversion.md +29 -0
  56. rustfava/help/extensions.md +111 -0
  57. rustfava/help/features.md +179 -0
  58. rustfava/help/filters.md +103 -0
  59. rustfava/help/import.md +27 -0
  60. rustfava/help/options.md +289 -0
  61. rustfava/helpers.py +30 -0
  62. rustfava/internal_api.py +221 -0
  63. rustfava/json_api.py +952 -0
  64. rustfava/plugins/__init__.py +3 -0
  65. rustfava/plugins/link_documents.py +107 -0
  66. rustfava/plugins/tag_discovered_documents.py +44 -0
  67. rustfava/py.typed +0 -0
  68. rustfava/rustledger/__init__.py +31 -0
  69. rustfava/rustledger/constants.py +76 -0
  70. rustfava/rustledger/engine.py +485 -0
  71. rustfava/rustledger/loader.py +273 -0
  72. rustfava/rustledger/options.py +202 -0
  73. rustfava/rustledger/query.py +331 -0
  74. rustfava/rustledger/types.py +830 -0
  75. rustfava/serialisation.py +220 -0
  76. rustfava/static/app.css +2988 -0
  77. rustfava/static/app.css.map +7 -0
  78. rustfava/static/app.js +12854 -0
  79. rustfava/static/app.js.map +7 -0
  80. rustfava/static/beancount-JFV44ZVZ.css +5 -0
  81. rustfava/static/beancount-JFV44ZVZ.css.map +7 -0
  82. rustfava/static/beancount-VTTKRGSK.js +4642 -0
  83. rustfava/static/beancount-VTTKRGSK.js.map +7 -0
  84. rustfava/static/bql-MGFRUMBP.js +333 -0
  85. rustfava/static/bql-MGFRUMBP.js.map +7 -0
  86. rustfava/static/chunk-E7ZF4ASL.js +23061 -0
  87. rustfava/static/chunk-E7ZF4ASL.js.map +7 -0
  88. rustfava/static/chunk-V24TLQHT.js +12673 -0
  89. rustfava/static/chunk-V24TLQHT.js.map +7 -0
  90. rustfava/static/favicon.ico +0 -0
  91. rustfava/static/fira-mono-cyrillic-400-normal-BLAGXRCE.woff2 +0 -0
  92. rustfava/static/fira-mono-cyrillic-500-normal-EN7JUAAW.woff2 +0 -0
  93. rustfava/static/fira-mono-cyrillic-ext-400-normal-EX7VARTS.woff2 +0 -0
  94. rustfava/static/fira-mono-cyrillic-ext-500-normal-ZDPTUPRR.woff2 +0 -0
  95. rustfava/static/fira-mono-greek-400-normal-COGHKMOA.woff2 +0 -0
  96. rustfava/static/fira-mono-greek-500-normal-4EN2PKZT.woff2 +0 -0
  97. rustfava/static/fira-mono-greek-ext-400-normal-DYEQIJH7.woff2 +0 -0
  98. rustfava/static/fira-mono-greek-ext-500-normal-SG73CVKQ.woff2 +0 -0
  99. rustfava/static/fira-mono-latin-400-normal-NA3VLV7E.woff2 +0 -0
  100. rustfava/static/fira-mono-latin-500-normal-YC77GFWD.woff2 +0 -0
  101. rustfava/static/fira-mono-latin-ext-400-normal-DIKTZ5PW.woff2 +0 -0
  102. rustfava/static/fira-mono-latin-ext-500-normal-ZWY4UO4V.woff2 +0 -0
  103. rustfava/static/fira-mono-symbols2-400-normal-UITXT77Q.woff2 +0 -0
  104. rustfava/static/fira-mono-symbols2-500-normal-VWPC2EFN.woff2 +0 -0
  105. rustfava/static/fira-sans-cyrillic-400-normal-KLQMBCA6.woff2 +0 -0
  106. rustfava/static/fira-sans-cyrillic-500-normal-NFG7UD6J.woff2 +0 -0
  107. rustfava/static/fira-sans-cyrillic-ext-400-normal-GWO44OPC.woff2 +0 -0
  108. rustfava/static/fira-sans-cyrillic-ext-500-normal-SP47E5SC.woff2 +0 -0
  109. rustfava/static/fira-sans-greek-400-normal-UMQBTLC3.woff2 +0 -0
  110. rustfava/static/fira-sans-greek-500-normal-4ZKHN4FQ.woff2 +0 -0
  111. rustfava/static/fira-sans-greek-ext-400-normal-O2DVJAJZ.woff2 +0 -0
  112. rustfava/static/fira-sans-greek-ext-500-normal-SK6GNWGO.woff2 +0 -0
  113. rustfava/static/fira-sans-latin-400-normal-OYYTPMAV.woff2 +0 -0
  114. rustfava/static/fira-sans-latin-500-normal-SMQPZW5A.woff2 +0 -0
  115. rustfava/static/fira-sans-latin-ext-400-normal-OAUP3WK5.woff2 +0 -0
  116. rustfava/static/fira-sans-latin-ext-500-normal-LY3YDR5Y.woff2 +0 -0
  117. rustfava/static/fira-sans-vietnamese-400-normal-OBMQ72MR.woff2 +0 -0
  118. rustfava/static/fira-sans-vietnamese-500-normal-Y4NZR5EU.woff2 +0 -0
  119. rustfava/static/source-code-pro-cyrillic-400-normal-TO22V6M3.woff2 +0 -0
  120. rustfava/static/source-code-pro-cyrillic-500-normal-OGBWWWYW.woff2 +0 -0
  121. rustfava/static/source-code-pro-cyrillic-ext-400-normal-XH44UCIA.woff2 +0 -0
  122. rustfava/static/source-code-pro-cyrillic-ext-500-normal-3Z6MMVM6.woff2 +0 -0
  123. rustfava/static/source-code-pro-greek-400-normal-OUXXUQWK.woff2 +0 -0
  124. rustfava/static/source-code-pro-greek-500-normal-JA2Z5UXO.woff2 +0 -0
  125. rustfava/static/source-code-pro-greek-ext-400-normal-WCDKMX7U.woff2 +0 -0
  126. rustfava/static/source-code-pro-greek-ext-500-normal-ZHVI4VKW.woff2 +0 -0
  127. rustfava/static/source-code-pro-latin-400-normal-QOGTXED5.woff2 +0 -0
  128. rustfava/static/source-code-pro-latin-500-normal-X57QEOLQ.woff2 +0 -0
  129. rustfava/static/source-code-pro-latin-ext-400-normal-QXC74NBF.woff2 +0 -0
  130. rustfava/static/source-code-pro-latin-ext-500-normal-QGOY7MTT.woff2 +0 -0
  131. rustfava/static/source-code-pro-vietnamese-400-normal-NPDCDTBA.woff2 +0 -0
  132. rustfava/static/source-code-pro-vietnamese-500-normal-M6PJKTR5.woff2 +0 -0
  133. rustfava/static/tree-sitter-beancount-MLXFQBZ5.wasm +0 -0
  134. rustfava/static/web-tree-sitter-RNOQ6E74.wasm +0 -0
  135. rustfava/template_filters.py +64 -0
  136. rustfava/templates/_journal_table.html +156 -0
  137. rustfava/templates/_layout.html +26 -0
  138. rustfava/templates/_query_table.html +88 -0
  139. rustfava/templates/beancount_file +18 -0
  140. rustfava/templates/help.html +23 -0
  141. rustfava/templates/macros/_account_macros.html +5 -0
  142. rustfava/templates/macros/_commodity_macros.html +13 -0
  143. rustfava/translations/bg/LC_MESSAGES/messages.mo +0 -0
  144. rustfava/translations/bg/LC_MESSAGES/messages.po +618 -0
  145. rustfava/translations/ca/LC_MESSAGES/messages.mo +0 -0
  146. rustfava/translations/ca/LC_MESSAGES/messages.po +618 -0
  147. rustfava/translations/de/LC_MESSAGES/messages.mo +0 -0
  148. rustfava/translations/de/LC_MESSAGES/messages.po +618 -0
  149. rustfava/translations/es/LC_MESSAGES/messages.mo +0 -0
  150. rustfava/translations/es/LC_MESSAGES/messages.po +619 -0
  151. rustfava/translations/fa/LC_MESSAGES/messages.mo +0 -0
  152. rustfava/translations/fa/LC_MESSAGES/messages.po +618 -0
  153. rustfava/translations/fr/LC_MESSAGES/messages.mo +0 -0
  154. rustfava/translations/fr/LC_MESSAGES/messages.po +618 -0
  155. rustfava/translations/ja/LC_MESSAGES/messages.mo +0 -0
  156. rustfava/translations/ja/LC_MESSAGES/messages.po +618 -0
  157. rustfava/translations/nl/LC_MESSAGES/messages.mo +0 -0
  158. rustfava/translations/nl/LC_MESSAGES/messages.po +617 -0
  159. rustfava/translations/pt/LC_MESSAGES/messages.mo +0 -0
  160. rustfava/translations/pt/LC_MESSAGES/messages.po +617 -0
  161. rustfava/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
  162. rustfava/translations/pt_BR/LC_MESSAGES/messages.po +618 -0
  163. rustfava/translations/ru/LC_MESSAGES/messages.mo +0 -0
  164. rustfava/translations/ru/LC_MESSAGES/messages.po +617 -0
  165. rustfava/translations/sk/LC_MESSAGES/messages.mo +0 -0
  166. rustfava/translations/sk/LC_MESSAGES/messages.po +623 -0
  167. rustfava/translations/sv/LC_MESSAGES/messages.mo +0 -0
  168. rustfava/translations/sv/LC_MESSAGES/messages.po +618 -0
  169. rustfava/translations/uk/LC_MESSAGES/messages.mo +0 -0
  170. rustfava/translations/uk/LC_MESSAGES/messages.po +618 -0
  171. rustfava/translations/zh/LC_MESSAGES/messages.mo +0 -0
  172. rustfava/translations/zh/LC_MESSAGES/messages.po +618 -0
  173. rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.mo +0 -0
  174. rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.po +618 -0
  175. rustfava/util/__init__.py +157 -0
  176. rustfava/util/date.py +576 -0
  177. rustfava/util/excel.py +118 -0
  178. rustfava/util/ranking.py +79 -0
  179. rustfava/util/sets.py +18 -0
  180. rustfava/util/unreachable.py +20 -0
  181. rustfava-0.1.0.dist-info/METADATA +102 -0
  182. rustfava-0.1.0.dist-info/RECORD +187 -0
  183. rustfava-0.1.0.dist-info/WHEEL +5 -0
  184. rustfava-0.1.0.dist-info/entry_points.txt +2 -0
  185. rustfava-0.1.0.dist-info/licenses/AUTHORS +11 -0
  186. rustfava-0.1.0.dist-info/licenses/LICENSE +21 -0
  187. rustfava-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,301 @@
1
+ """Provide data suitable for rustfava's charts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass
7
+ from dataclasses import fields
8
+ from dataclasses import is_dataclass
9
+ from datetime import date
10
+ from decimal import Decimal
11
+ from re import Pattern
12
+ from typing import Any
13
+ from typing import TYPE_CHECKING
14
+
15
+ import json
16
+
17
+ from flask.json.provider import JSONProvider
18
+
19
+ from rustfava.beans.abc import Position
20
+ from rustfava.rustledger.constants import Booking
21
+ from rustfava.rustledger.constants import MISSING
22
+ from rustfava.beans.abc import Transaction
23
+ from rustfava.beans.account import account_tester
24
+ from rustfava.beans.flags import FLAG_UNREALIZED
25
+ from rustfava.beans.helpers import slice_entry_dates
26
+ from rustfava.core.conversion import conversion_from_str
27
+ from rustfava.core.inventory import CounterInventory
28
+ from rustfava.core.module_base import FavaModule
29
+ from rustfava.util import listify
30
+
31
+ if TYPE_CHECKING: # pragma: no cover
32
+ from collections.abc import Iterable
33
+ from collections.abc import Mapping
34
+
35
+ from rustfava.core import FilteredLedger
36
+ from rustfava.core.conversion import Conversion
37
+ from rustfava.core.inventory import SimpleCounterInventory
38
+ from rustfava.core.tree import SerialisedTreeNode
39
+ from rustfava.util.date import Interval
40
+
41
+
42
+ ZERO = Decimal()
43
+
44
+
45
+ def _json_default(o: Any) -> Any:
46
+ """Specific serialisation for some data types."""
47
+ if isinstance(o, Decimal):
48
+ return float(o)
49
+ if isinstance(o, (date, Booking, Position)):
50
+ return str(o)
51
+ if isinstance(o, (set, frozenset)):
52
+ return list(o)
53
+ if isinstance(o, Pattern):
54
+ return o.pattern
55
+ if is_dataclass(o):
56
+ return {field.name: getattr(o, field.name) for field in fields(o)}
57
+ if o is MISSING: # pragma: no cover
58
+ return None
59
+ raise TypeError # pragma: no cover
60
+
61
+
62
+ def dumps(obj: Any, **_kwargs: Any) -> str:
63
+ """Dump as a JSON string."""
64
+ return json.dumps(
65
+ obj, sort_keys=True, separators=(",", ":"), default=_json_default
66
+ )
67
+
68
+
69
+ def loads(s: str | bytes) -> Any:
70
+ """Load a JSON string."""
71
+ return json.loads(s)
72
+
73
+
74
+ class RustfavaJSONProvider(JSONProvider):
75
+ """Use custom JSON encoder and decoder."""
76
+
77
+ def dumps(self, obj: Any, **_kwargs: Any) -> str: # noqa: D102
78
+ return json.dumps(
79
+ obj, sort_keys=True, separators=(",", ":"), default=_json_default
80
+ )
81
+
82
+ def loads(self, s: str | bytes, **_kwargs: Any) -> Any: # noqa: D102
83
+ return json.loads(s)
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class DateAndBalance:
88
+ """Balance at a date."""
89
+
90
+ date: date
91
+ balance: SimpleCounterInventory
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class DateAndBalanceWithBudget:
96
+ """Balance at a date with a budget."""
97
+
98
+ date: date
99
+ balance: SimpleCounterInventory
100
+ account_balances: Mapping[str, SimpleCounterInventory]
101
+ budgets: Mapping[str, Decimal]
102
+
103
+
104
+ class ChartModule(FavaModule):
105
+ """Return data for the various charts in rustfava."""
106
+
107
+ def hierarchy(
108
+ self,
109
+ filtered: FilteredLedger,
110
+ account_name: str,
111
+ conversion: Conversion,
112
+ ) -> SerialisedTreeNode:
113
+ """Render an account tree."""
114
+ tree = filtered.root_tree
115
+ return tree.get(account_name).serialise(
116
+ conversion, self.ledger.prices, filtered.end_date
117
+ )
118
+
119
+ @listify
120
+ def interval_totals(
121
+ self,
122
+ filtered: FilteredLedger,
123
+ interval: Interval,
124
+ accounts: str | tuple[str, ...],
125
+ conversion: str | Conversion,
126
+ *,
127
+ invert: bool = False,
128
+ ) -> Iterable[DateAndBalanceWithBudget]:
129
+ """Render totals for account (or accounts) in the intervals.
130
+
131
+ Args:
132
+ filtered: The filtered ledger.
133
+ interval: An interval.
134
+ accounts: A single account (str) or a tuple of accounts.
135
+ conversion: The conversion to use.
136
+ invert: invert all numbers.
137
+
138
+ Yields:
139
+ The balances and budgets for the intervals.
140
+ """
141
+ conv = conversion_from_str(conversion)
142
+ prices = self.ledger.prices
143
+
144
+ # limit the bar charts to 100 intervals
145
+ intervals = filtered.interval_ranges(interval)[-100:]
146
+
147
+ for date_range in intervals:
148
+ inventory = CounterInventory()
149
+ entries = slice_entry_dates(
150
+ filtered.entries, date_range.begin, date_range.end
151
+ )
152
+ account_inventories: dict[str, CounterInventory] = defaultdict(
153
+ CounterInventory,
154
+ )
155
+ for entry in entries:
156
+ for posting in getattr(entry, "postings", []):
157
+ if posting.account.startswith(accounts):
158
+ account_inventories[posting.account].add_position(
159
+ posting,
160
+ )
161
+ inventory.add_position(posting)
162
+ balance = conv.apply(
163
+ inventory,
164
+ prices,
165
+ date_range.end_inclusive,
166
+ )
167
+ account_balances = {
168
+ account: conv.apply(
169
+ acct_value,
170
+ prices,
171
+ date_range.end_inclusive,
172
+ )
173
+ for account, acct_value in account_inventories.items()
174
+ }
175
+ budgets = (
176
+ self.ledger.budgets.calculate_children(
177
+ accounts,
178
+ date_range.begin,
179
+ date_range.end,
180
+ )
181
+ if isinstance(accounts, str)
182
+ else {}
183
+ )
184
+
185
+ if invert:
186
+ balance = -balance
187
+ budgets = {k: -v for k, v in budgets.items()}
188
+ account_balances = {k: -v for k, v in account_balances.items()}
189
+
190
+ yield DateAndBalanceWithBudget(
191
+ date_range.end_inclusive,
192
+ balance,
193
+ account_balances,
194
+ budgets,
195
+ )
196
+
197
+ @listify
198
+ def linechart(
199
+ self,
200
+ filtered: FilteredLedger,
201
+ account_name: str,
202
+ conversion: str | Conversion,
203
+ ) -> Iterable[DateAndBalance]:
204
+ """Get the balance of an account as a line chart.
205
+
206
+ Args:
207
+ filtered: The filtered ledger.
208
+ account_name: A string.
209
+ conversion: The conversion to use.
210
+
211
+ Yields:
212
+ Dicts for all dates on which the balance of the given
213
+ account has changed containing the balance (in units) of the
214
+ account at that date.
215
+ """
216
+ conv = conversion_from_str(conversion)
217
+
218
+ def _balances() -> Iterable[tuple[date, CounterInventory]]:
219
+ last_date = None
220
+ running_balance = CounterInventory()
221
+ is_child_account = account_tester(account_name, with_children=True)
222
+
223
+ for entry in filtered.entries:
224
+ for posting in getattr(entry, "postings", []):
225
+ if is_child_account(posting.account):
226
+ new_date = entry.date
227
+ if last_date is not None and new_date > last_date:
228
+ yield (last_date, running_balance)
229
+ running_balance.add_position(posting)
230
+ last_date = new_date
231
+
232
+ if last_date is not None:
233
+ yield (last_date, running_balance)
234
+
235
+ # When the balance for a commodity just went to zero, it will be
236
+ # missing from the 'balance' so keep track of currencies that last had
237
+ # a balance.
238
+ last_currencies = None
239
+ prices = self.ledger.prices
240
+
241
+ for d, running_bal in _balances():
242
+ balance = conv.apply(running_bal, prices, d)
243
+ currencies = set(balance.keys())
244
+ if last_currencies:
245
+ for currency in last_currencies - currencies:
246
+ balance[currency] = ZERO
247
+ last_currencies = currencies
248
+ yield DateAndBalance(d, balance)
249
+
250
+ @listify
251
+ def net_worth(
252
+ self,
253
+ filtered: FilteredLedger,
254
+ interval: Interval,
255
+ conversion: str | Conversion,
256
+ ) -> Iterable[DateAndBalance]:
257
+ """Compute net worth.
258
+
259
+ Args:
260
+ filtered: The filtered ledger.
261
+ interval: A string for the interval.
262
+ conversion: The conversion to use.
263
+
264
+ Yields:
265
+ Dicts for all ends of the given interval containing the
266
+ net worth (Assets + Liabilities) separately converted to all
267
+ operating currencies.
268
+ """
269
+ conv = conversion_from_str(conversion)
270
+ transactions = (
271
+ entry
272
+ for entry in filtered.entries
273
+ if (
274
+ isinstance(entry, Transaction)
275
+ and entry.flag != FLAG_UNREALIZED
276
+ )
277
+ )
278
+
279
+ types = (
280
+ self.ledger.options["name_assets"],
281
+ self.ledger.options["name_liabilities"],
282
+ )
283
+
284
+ txn = next(transactions, None)
285
+ inventory = CounterInventory()
286
+
287
+ prices = self.ledger.prices
288
+ for date_range in filtered.interval_ranges(interval):
289
+ while txn and txn.date < date_range.end:
290
+ for posting in txn.postings:
291
+ if posting.account.startswith(types):
292
+ inventory.add_position(posting)
293
+ txn = next(transactions, None)
294
+ yield DateAndBalance(
295
+ date_range.end_inclusive,
296
+ conv.apply(
297
+ inventory,
298
+ prices,
299
+ date_range.end_inclusive,
300
+ ),
301
+ )
@@ -0,0 +1,37 @@
1
+ """Attributes for auto-completion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import suppress
6
+ from decimal import Decimal
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rustfava.core.module_base import FavaModule
10
+
11
+ if TYPE_CHECKING: # pragma: no cover
12
+ from rustfava.core import RustfavaLedger
13
+
14
+
15
+ class CommoditiesModule(FavaModule):
16
+ """Details about the currencies and commodities."""
17
+
18
+ def __init__(self, ledger: RustfavaLedger) -> None:
19
+ super().__init__(ledger)
20
+ self.names: dict[str, str] = {}
21
+ self.precisions: dict[str, int] = {}
22
+
23
+ def load_file(self) -> None: # noqa: D102
24
+ self.names = {}
25
+ self.precisions = {}
26
+ for commodity in self.ledger.all_entries_by_type.Commodity:
27
+ name = commodity.meta.get("name")
28
+ if name:
29
+ self.names[commodity.currency] = str(name)
30
+ precision = commodity.meta.get("precision")
31
+ if isinstance(precision, (str, int, Decimal)):
32
+ with suppress(ValueError):
33
+ self.precisions[commodity.currency] = int(precision)
34
+
35
+ def name(self, commodity: str) -> str:
36
+ """Get the name of a commodity (or the commodity itself if not set)."""
37
+ return self.names.get(commodity, commodity)
@@ -0,0 +1,229 @@
1
+ """Commodity conversion helpers for Fava.
2
+
3
+ All functions in this module will be automatically added as template filters.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from abc import ABC
9
+ from abc import abstractmethod
10
+ from typing import TYPE_CHECKING
11
+
12
+ from rustfava.core.inventory import _Amount
13
+ from rustfava.core.inventory import SimpleCounterInventory
14
+
15
+ try:
16
+ from typing import override
17
+ except ImportError: # pragma: no cover
18
+ from typing import override
19
+
20
+ if TYPE_CHECKING: # pragma: no cover
21
+ import datetime
22
+
23
+ from beancount.core.inventory import Inventory
24
+
25
+ from rustfava.beans.prices import RustfavaPriceMap
26
+ from rustfava.beans.protocols import Amount
27
+ from rustfava.beans.protocols import Position
28
+ from rustfava.core.inventory import CounterInventory
29
+
30
+
31
+ def get_cost(pos: Position) -> Amount:
32
+ """Return the total cost of a Position."""
33
+ cost_ = pos.cost
34
+ return (
35
+ _Amount(cost_.number * pos.units.number, cost_.currency)
36
+ if cost_ is not None
37
+ else pos.units
38
+ )
39
+
40
+
41
+ def get_market_value(
42
+ pos: Position,
43
+ prices: RustfavaPriceMap,
44
+ date: datetime.date | None = None,
45
+ ) -> Amount:
46
+ """Get the market value of a Position.
47
+
48
+ This differs from the convert.get_value function in Beancount by returning
49
+ the cost value if no price can be found.
50
+
51
+ Args:
52
+ pos: A Position.
53
+ prices: A rustfavaPriceMap
54
+ date: A datetime.date instance to evaluate the value at, or None.
55
+
56
+ Returns:
57
+ An Amount, with value converted or if the conversion failed just the
58
+ cost value (or the units if the position has no cost).
59
+ """
60
+ units_ = pos.units
61
+ cost_ = pos.cost
62
+
63
+ if cost_ is not None:
64
+ value_currency = cost_.currency
65
+ base_quote = (units_.currency, value_currency)
66
+ price_number = prices.get_price(base_quote, date)
67
+ if price_number is not None:
68
+ return _Amount(
69
+ units_.number * price_number,
70
+ value_currency,
71
+ )
72
+ return _Amount(units_.number * cost_.number, value_currency)
73
+ return units_
74
+
75
+
76
+ def convert_position(
77
+ pos: Position,
78
+ target_currency: str,
79
+ prices: RustfavaPriceMap,
80
+ date: datetime.date | None = None,
81
+ ) -> Amount:
82
+ """Get the value of a Position in a particular currency.
83
+
84
+ Args:
85
+ pos: A Position.
86
+ target_currency: The target currency to convert to.
87
+ prices: A rustfavaPriceMap
88
+ date: A datetime.date instance to evaluate the value at, or None.
89
+
90
+ Returns:
91
+ An Amount, with value converted or if the conversion failed just the
92
+ cost value (or the units if the position has no cost).
93
+ """
94
+ units_ = pos.units
95
+
96
+ # try the direct conversion
97
+ base_quote = (units_.currency, target_currency)
98
+ price_number = prices.get_price(base_quote, date)
99
+ if price_number is not None:
100
+ return _Amount(units_.number * price_number, target_currency)
101
+
102
+ cost_ = pos.cost
103
+ if cost_ is not None:
104
+ cost_currency = cost_.currency
105
+ if cost_currency != target_currency:
106
+ base_quote1 = (units_.currency, cost_currency)
107
+ rate1 = prices.get_price(base_quote1, date)
108
+ if rate1 is not None:
109
+ base_quote2 = (cost_currency, target_currency)
110
+ rate2 = prices.get_price(base_quote2, date)
111
+ if rate2 is not None:
112
+ return _Amount(
113
+ units_.number * rate1 * rate2,
114
+ target_currency,
115
+ )
116
+ return units_
117
+
118
+
119
+ class Conversion(ABC):
120
+ """A conversion."""
121
+
122
+ @abstractmethod
123
+ def apply(
124
+ self,
125
+ inventory: CounterInventory,
126
+ prices: RustfavaPriceMap,
127
+ date: datetime.date | None = None,
128
+ ) -> SimpleCounterInventory:
129
+ """Apply the conversion to an inventory (CounterInventory)."""
130
+
131
+
132
+ class _AtCostConversion(Conversion):
133
+ @override
134
+ def apply(
135
+ self,
136
+ inventory: CounterInventory,
137
+ prices: RustfavaPriceMap | None = None,
138
+ date: datetime.date | None = None,
139
+ ) -> SimpleCounterInventory:
140
+ return inventory.reduce(get_cost)
141
+
142
+
143
+ class _AtValueConversion(Conversion):
144
+ @override
145
+ def apply(
146
+ self,
147
+ inventory: CounterInventory,
148
+ prices: RustfavaPriceMap,
149
+ date: datetime.date | None = None,
150
+ ) -> SimpleCounterInventory:
151
+ return inventory.reduce(get_market_value, prices, date)
152
+
153
+
154
+ class _UnitsConversion(Conversion):
155
+ @override
156
+ def apply(
157
+ self,
158
+ inventory: CounterInventory,
159
+ prices: RustfavaPriceMap | None = None,
160
+ date: datetime.date | None = None,
161
+ ) -> SimpleCounterInventory:
162
+ counter = SimpleCounterInventory()
163
+ for (currency, _cost), number in inventory.items():
164
+ counter.add(currency, number)
165
+ return counter
166
+
167
+ def apply_inventory(
168
+ self,
169
+ inventory: Inventory,
170
+ ) -> SimpleCounterInventory:
171
+ """Apply the conversion to an Beancount Inventory."""
172
+ counter = SimpleCounterInventory()
173
+ for pos in inventory:
174
+ counter.add(pos.units.currency, pos.units.number)
175
+ return counter
176
+
177
+
178
+ class _CurrencyConversion(Conversion):
179
+ """Conversion to a list of currencies."""
180
+
181
+ def __init__(self, value: str) -> None:
182
+ self._currencies = tuple(value.split(","))
183
+
184
+ @override
185
+ def apply(
186
+ self,
187
+ inventory: CounterInventory,
188
+ prices: RustfavaPriceMap,
189
+ date: datetime.date | None = None,
190
+ ) -> SimpleCounterInventory:
191
+ currencies = iter(self._currencies)
192
+ currency = next(currencies)
193
+ res = inventory.reduce(convert_position, currency, prices, date)
194
+ for currency in currencies:
195
+ res = res.reduce(convert_position, currency, prices, date)
196
+ return res
197
+
198
+
199
+ #: Convert position to its total cost.
200
+ AT_COST = _AtCostConversion()
201
+ #: Convert position to its market value.
202
+ AT_VALUE = _AtValueConversion()
203
+ #: Convert position to its units.
204
+ UNITS = _UnitsConversion()
205
+
206
+
207
+ def conversion_from_str(value: str | Conversion) -> Conversion:
208
+ """Parse a conversion string."""
209
+ if not isinstance(value, str):
210
+ return value
211
+ if value == "at_cost":
212
+ return AT_COST
213
+ if value == "at_value":
214
+ return AT_VALUE
215
+ if value == "units":
216
+ return UNITS
217
+
218
+ return _CurrencyConversion(value)
219
+
220
+
221
+ def cost_or_value(
222
+ inventory: CounterInventory,
223
+ conversion: str | Conversion,
224
+ prices: RustfavaPriceMap,
225
+ date: datetime.date | None = None,
226
+ ) -> SimpleCounterInventory:
227
+ """Get the cost or value of an inventory."""
228
+ conversion = conversion_from_str(conversion)
229
+ return conversion.apply(inventory, prices, date)
@@ -0,0 +1,87 @@
1
+ """Document path related helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from os import altsep
6
+ from os import sep
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from rustfava.helpers import RustfavaAPIError
11
+
12
+ if TYPE_CHECKING: # pragma: no cover
13
+ from rustfava.core import RustfavaLedger
14
+
15
+
16
+ class NotADocumentsFolderError(RustfavaAPIError):
17
+ """Not a documents folder."""
18
+
19
+ def __init__(self, folder: str) -> None:
20
+ super().__init__(f"Not a documents folder: {folder}.")
21
+
22
+
23
+ class NotAValidAccountError(RustfavaAPIError):
24
+ """Not a valid account."""
25
+
26
+ def __init__(self, account: str) -> None:
27
+ super().__init__(f"Not a valid account: '{account}'")
28
+
29
+
30
+ def is_document_or_import_file(filename: str, ledger: RustfavaLedger) -> bool:
31
+ """Check whether the filename is a document or in an import directory.
32
+
33
+ This is a security validation function that prevents path traversal.
34
+
35
+ Args:
36
+ filename: The filename to check.
37
+ ledger: The RustfavaLedger.
38
+
39
+ Returns:
40
+ Whether this is one of the documents or a path in an import dir.
41
+ """
42
+ # Check if it's an exact match for a known document
43
+ if any(
44
+ filename == d.filename for d in ledger.all_entries_by_type.Document
45
+ ):
46
+ return True
47
+ # Check if resolved path is within an import directory (prevents path traversal)
48
+ file_path = Path(filename).resolve()
49
+ for import_dir in ledger.fava_options.import_dirs:
50
+ resolved_dir = ledger.join_path(import_dir).resolve()
51
+ if file_path.is_relative_to(resolved_dir):
52
+ return True
53
+ return False
54
+
55
+
56
+ def filepath_in_document_folder(
57
+ documents_folder: str,
58
+ account: str,
59
+ filename: str,
60
+ ledger: RustfavaLedger,
61
+ ) -> Path:
62
+ """File path for a document in the folder for an account.
63
+
64
+ Args:
65
+ documents_folder: The documents folder.
66
+ account: The account to choose the subfolder for.
67
+ filename: The filename of the document.
68
+ ledger: The RustfavaLedger.
69
+
70
+ Returns:
71
+ The path that the document should be saved at.
72
+ """
73
+ if documents_folder not in ledger.options["documents"]:
74
+ raise NotADocumentsFolderError(documents_folder)
75
+
76
+ if account not in ledger.attributes.accounts:
77
+ raise NotAValidAccountError(account)
78
+
79
+ filename = filename.replace(sep, " ")
80
+ if altsep: # pragma: no cover
81
+ filename = filename.replace(altsep, " ")
82
+
83
+ return ledger.join_path(
84
+ documents_folder,
85
+ *account.split(":"),
86
+ filename,
87
+ )