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,273 @@
1
+ """Loader functions for rustledger - replaces beancount.loader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rustfava.rustledger.engine import RustledgerEngine
10
+ from rustfava.rustledger.options import options_from_json
11
+ from rustfava.rustledger.types import directives_from_json
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Sequence
15
+ from typing import Any
16
+
17
+ from rustfava.beans.abc import Directive
18
+ from rustfava.beans.types import BeancountOptions
19
+ from rustfava.helpers import BeancountError
20
+
21
+
22
+ def _compute_display_precision(entries_json: list[dict[str, Any]]) -> dict[str, int]:
23
+ """Compute display precision from entries.
24
+
25
+ This is a workaround until rustledger FFI returns display_precision
26
+ from load-full command.
27
+ """
28
+ # Track precision counts per currency: {currency: {precision: count}}
29
+ precision_counts: dict[str, Counter[int]] = {}
30
+
31
+ def track_amount(amt: dict[str, Any] | None) -> None:
32
+ if not amt:
33
+ return
34
+ number_str = amt.get("number", "")
35
+ currency = amt.get("currency", "")
36
+ if not number_str or not currency:
37
+ return
38
+ # Calculate precision from decimal places
39
+ if "." in str(number_str):
40
+ precision = len(str(number_str).split(".")[-1])
41
+ else:
42
+ precision = 0
43
+ if currency not in precision_counts:
44
+ precision_counts[currency] = Counter()
45
+ precision_counts[currency][precision] += 1
46
+
47
+ for entry in entries_json:
48
+ entry_type = entry.get("type", "")
49
+
50
+ if entry_type == "transaction":
51
+ for posting in entry.get("postings", []):
52
+ track_amount(posting.get("units"))
53
+ if posting.get("cost"):
54
+ cost = posting["cost"]
55
+ track_amount({"number": cost.get("number"), "currency": cost.get("currency")})
56
+ track_amount(posting.get("price"))
57
+
58
+ elif entry_type == "balance":
59
+ track_amount(entry.get("amount"))
60
+
61
+ elif entry_type == "price":
62
+ track_amount(entry.get("amount"))
63
+
64
+ # Get most common precision for each currency
65
+ result = {}
66
+ for currency, counts in precision_counts.items():
67
+ if counts:
68
+ result[currency] = counts.most_common(1)[0][0]
69
+
70
+ return result
71
+
72
+
73
+ def _errors_from_json(
74
+ errors_json: list[dict[str, Any]],
75
+ filename: str = "<unknown>",
76
+ ) -> list[BeancountError]:
77
+ """Convert rustledger errors to Fava BeancountError format."""
78
+ from rustfava.helpers import BeancountError
79
+
80
+ result = []
81
+ for err in errors_json:
82
+ # Handle both old format (source dict) and new format (line field)
83
+ if "source" in err:
84
+ source = err["source"]
85
+ err_filename = source.get("filename", filename)
86
+ err_lineno = source.get("lineno", 0)
87
+ else:
88
+ err_filename = err.get("filename", filename)
89
+ err_lineno = err.get("line", 0)
90
+
91
+ result.append(
92
+ BeancountError(
93
+ source={
94
+ "filename": err_filename,
95
+ "lineno": err_lineno,
96
+ },
97
+ message=err.get("message", "Unknown error"),
98
+ entry=None,
99
+ )
100
+ )
101
+ return result
102
+
103
+
104
+ def _run_plugins(
105
+ entries: list[Directive],
106
+ plugins: list[dict[str, Any]],
107
+ options: BeancountOptions,
108
+ ) -> tuple[list[Directive], list[BeancountError]]:
109
+ """Run Python plugins on entries.
110
+
111
+ Args:
112
+ entries: List of parsed entries
113
+ plugins: List of plugin specs from rustledger ({"name": "...", "config": "..."})
114
+ options: Beancount options dict
115
+
116
+ Returns:
117
+ Tuple of (processed entries, plugin errors)
118
+ """
119
+ import importlib
120
+
121
+ from rustfava.helpers import BeancountError
122
+
123
+ all_errors: list[BeancountError] = []
124
+
125
+ for plugin_spec in plugins:
126
+ plugin_name = plugin_spec.get("name", "")
127
+ plugin_config = plugin_spec.get("config")
128
+
129
+ if not plugin_name:
130
+ continue
131
+
132
+ # Skip beancount.plugins.auto_accounts - handled natively by rustledger
133
+ if plugin_name == "beancount.plugins.auto_accounts":
134
+ continue
135
+
136
+ try:
137
+ module = importlib.import_module(plugin_name)
138
+ except ImportError as e:
139
+ all_errors.append(
140
+ BeancountError(
141
+ source={"filename": "<plugin>", "lineno": 0},
142
+ message=f"Failed to import plugin '{plugin_name}': {e}",
143
+ entry=None,
144
+ )
145
+ )
146
+ continue
147
+
148
+ # Get plugin functions from __plugins__ attribute
149
+ plugin_funcs = getattr(module, "__plugins__", [])
150
+ if not plugin_funcs:
151
+ # Try using the module name as the function name
152
+ func_name = plugin_name.split(".")[-1]
153
+ if hasattr(module, func_name):
154
+ plugin_funcs = [func_name]
155
+
156
+ for func_name in plugin_funcs:
157
+ plugin_fn = getattr(module, func_name, None)
158
+ if plugin_fn is None:
159
+ continue
160
+
161
+ try:
162
+ # Call plugin: (entries, options) -> (entries, errors)
163
+ if plugin_config is not None:
164
+ entries, plugin_errors = plugin_fn(entries, plugin_config)
165
+ else:
166
+ entries, plugin_errors = plugin_fn(entries, options)
167
+
168
+ # Convert any plugin errors to our error format
169
+ for err in plugin_errors:
170
+ if isinstance(err, BeancountError):
171
+ all_errors.append(err)
172
+ else:
173
+ all_errors.append(
174
+ BeancountError(
175
+ source=getattr(err, "source", {"filename": "<plugin>", "lineno": 0}),
176
+ message=getattr(err, "message", str(err)),
177
+ entry=getattr(err, "entry", None),
178
+ )
179
+ )
180
+ except Exception as e:
181
+ all_errors.append(
182
+ BeancountError(
183
+ source={"filename": "<plugin>", "lineno": 0},
184
+ message=f"Plugin '{plugin_name}.{func_name}' raised: {e}",
185
+ entry=None,
186
+ )
187
+ )
188
+
189
+ return list(entries), all_errors
190
+
191
+
192
+ def load_string(
193
+ value: str,
194
+ filename: str = "<string>",
195
+ ) -> tuple[
196
+ Sequence[Directive],
197
+ Sequence[BeancountError],
198
+ BeancountOptions,
199
+ ]:
200
+ """Load a Beancount string.
201
+
202
+ Args:
203
+ value: Beancount source code
204
+ filename: Filename to use in metadata
205
+
206
+ Returns:
207
+ Tuple of (entries, errors, options)
208
+ """
209
+ engine = RustledgerEngine.get_instance()
210
+ result = engine.load(value, filename)
211
+
212
+ entries = list(directives_from_json(result.get("entries", [])))
213
+ errors = list(_errors_from_json(result.get("errors", []), filename))
214
+ options = options_from_json(result.get("options", {}))
215
+
216
+ # Run Python plugins if any are specified
217
+ plugins = result.get("plugins", [])
218
+ if plugins:
219
+ entries, plugin_errors = _run_plugins(entries, plugins, options)
220
+ errors.extend(plugin_errors)
221
+
222
+ return entries, errors, options
223
+
224
+
225
+ def load_uncached(
226
+ beancount_file_path: str,
227
+ *,
228
+ is_encrypted: bool = False,
229
+ ) -> tuple[
230
+ Sequence[Directive],
231
+ Sequence[BeancountError],
232
+ BeancountOptions,
233
+ ]:
234
+ """Load a Beancount file.
235
+
236
+ Uses rustledger's load-full command which handles:
237
+ - Include resolution with cycle detection
238
+ - Path security (prevents path traversal)
239
+ - GPG decryption for encrypted files
240
+ - Native plugin execution (auto_accounts)
241
+ - Entry sorting
242
+
243
+ Args:
244
+ beancount_file_path: Path to the main beancount file
245
+ is_encrypted: Ignored - rustledger handles GPG decryption
246
+
247
+ Returns:
248
+ Tuple of (entries, errors, options)
249
+ """
250
+ del is_encrypted # Rustledger handles GPG decryption automatically
251
+
252
+ main_path = Path(beancount_file_path)
253
+ engine = RustledgerEngine.get_instance()
254
+
255
+ # Use load_full with auto_accounts plugin for sorting and account generation
256
+ result = engine.load_full(str(main_path), plugins=["auto_accounts"])
257
+
258
+ entries_json = result.get("entries", [])
259
+
260
+ # Compute display_precision if not provided by FFI (workaround)
261
+ options_json = result.get("options", {})
262
+ if not options_json.get("display_precision"):
263
+ options_json["display_precision"] = _compute_display_precision(entries_json)
264
+
265
+ entries = directives_from_json(entries_json)
266
+ errors = list(_errors_from_json(result.get("errors", []), str(main_path)))
267
+ options = options_from_json(options_json)
268
+
269
+ # Set include list and filename in options
270
+ options["include"] = result.get("loaded_files", [str(main_path)])
271
+ options["filename"] = str(main_path)
272
+
273
+ return entries, errors, options
@@ -0,0 +1,202 @@
1
+ """Options adapter for rustledger JSON to Fava's BeancountOptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from typing import Any
10
+
11
+ from rustfava.beans.types import BeancountOptions
12
+
13
+
14
+ class _RLCurrencyContext:
15
+ """Minimal CurrencyContext for a single currency."""
16
+
17
+ def __init__(self, precision: int) -> None:
18
+ self._precision = precision
19
+
20
+ def get_fractional(self, _precision_type: Any = None) -> int:
21
+ """Return the fractional precision (beancount-compatible API)."""
22
+ return self._precision
23
+
24
+
25
+ class RLDisplayContext:
26
+ """Minimal DisplayContext implementation for rustledger.
27
+
28
+ This replaces beancount.core.display_context.DisplayContext.
29
+ Provides beancount-compatible `ccontexts` property.
30
+ """
31
+
32
+ def __init__(self, options: dict[str, Any]) -> None:
33
+ """Initialize from rustledger options."""
34
+ self._precision = options.get("display_precision", {})
35
+ self._render_commas = options.get("render_commas", True)
36
+ # Beancount-compatible ccontexts mapping
37
+ self.ccontexts = {
38
+ currency: _RLCurrencyContext(prec)
39
+ for currency, prec in self._precision.items()
40
+ }
41
+
42
+ def build(self) -> RLDisplayFormatter:
43
+ """Build a formatter from this context."""
44
+ return RLDisplayFormatter(self._precision, self._render_commas)
45
+
46
+
47
+ class RLDisplayFormatter:
48
+ """Formatter for decimal numbers."""
49
+
50
+ def __init__(
51
+ self,
52
+ precision: dict[str, int],
53
+ render_commas: bool,
54
+ ) -> None:
55
+ """Initialize formatter."""
56
+ self._precision = precision
57
+ self._render_commas = render_commas
58
+
59
+ def format(self, number: Decimal, currency: str) -> str:
60
+ """Format a decimal number for a currency."""
61
+ prec = self._precision.get(currency, 2)
62
+ formatted = f"{number:.{prec}f}"
63
+ if self._render_commas:
64
+ # Add thousand separators
65
+ parts = formatted.split(".")
66
+ parts[0] = "{:,}".format(int(parts[0]))
67
+ formatted = ".".join(parts)
68
+ return formatted
69
+
70
+ def quantize(
71
+ self,
72
+ number: Decimal,
73
+ currency: str = "__default__",
74
+ ) -> Decimal:
75
+ """Quantize a number to the precision for a currency.
76
+
77
+ This matches beancount's DisplayFormatter.quantize interface.
78
+ """
79
+ prec = self._precision.get(currency, 2)
80
+ # Create a Decimal with the right number of decimal places
81
+ quantizer = Decimal(10) ** -prec
82
+ return number.quantize(quantizer)
83
+
84
+
85
+ class RLBooking:
86
+ """Booking method enum compatible with beancount.core.data.Booking."""
87
+
88
+ STRICT = "STRICT"
89
+ FIFO = "FIFO"
90
+ LIFO = "LIFO"
91
+ HIFO = "HIFO"
92
+ AVERAGE = "AVERAGE"
93
+ NONE = "NONE"
94
+
95
+ def __init__(self, value: str) -> None:
96
+ """Initialize from string value."""
97
+ self.value = value.upper() if value else "STRICT"
98
+
99
+ def __str__(self) -> str:
100
+ """Return string representation."""
101
+ return self.value
102
+
103
+ def __eq__(self, other: object) -> bool:
104
+ """Check equality."""
105
+ if isinstance(other, str):
106
+ return self.value == other.upper()
107
+ if isinstance(other, RLBooking):
108
+ return self.value == other.value
109
+ return False
110
+
111
+
112
+ def options_from_json(data: dict[str, Any]) -> BeancountOptions:
113
+ """Convert rustledger options JSON to Fava's BeancountOptions.
114
+
115
+ Args:
116
+ data: JSON dict of options from rustledger
117
+
118
+ Returns:
119
+ BeancountOptions TypedDict
120
+ """
121
+ # Create display context
122
+ dcontext = RLDisplayContext(data)
123
+
124
+ # Parse display_precision
125
+ display_precision = {
126
+ k: Decimal(str(v))
127
+ for k, v in data.get("display_precision", {}).items()
128
+ }
129
+
130
+ # Parse inferred_tolerance_default
131
+ inferred_tolerance_default = {
132
+ k: Decimal(str(v))
133
+ for k, v in data.get("inferred_tolerance_default", {}).items()
134
+ }
135
+
136
+ options: BeancountOptions = {
137
+ "title": data.get("title", ""),
138
+ "filename": data.get("filename", ""),
139
+ # Root account names
140
+ "name_assets": data.get("name_assets", "Assets"),
141
+ "name_liabilities": data.get("name_liabilities", "Liabilities"),
142
+ "name_equity": data.get("name_equity", "Equity"),
143
+ "name_income": data.get("name_income", "Income"),
144
+ "name_expenses": data.get("name_expenses", "Expenses"),
145
+ # Special accounts
146
+ "account_current_conversions": data.get(
147
+ "account_current_conversions", "Equity:Conversions:Current"
148
+ ),
149
+ "account_current_earnings": data.get(
150
+ "account_current_earnings", "Equity:Earnings:Current"
151
+ ),
152
+ "account_previous_balances": data.get(
153
+ "account_previous_balances", "Equity:Opening-Balances"
154
+ ),
155
+ "account_previous_conversions": data.get(
156
+ "account_previous_conversions", "Equity:Conversions:Previous"
157
+ ),
158
+ "account_previous_earnings": data.get(
159
+ "account_previous_earnings", "Equity:Earnings:Previous"
160
+ ),
161
+ "account_rounding": data.get("account_rounding"),
162
+ "account_unrealized_gains": data.get(
163
+ "account_unrealized_gains", "Income:Unrealized"
164
+ ),
165
+ # Booking and commodities
166
+ "booking_method": RLBooking(data.get("booking_method", "STRICT")),
167
+ "commodities": set(data.get("commodities", [])),
168
+ "conversion_currency": data.get("conversion_currency", ""),
169
+ "dcontext": dcontext,
170
+ "display_precision": display_precision,
171
+ # File handling
172
+ "documents": list(data.get("documents", [])),
173
+ "include": list(data.get("include", [])),
174
+ # Tolerances
175
+ "infer_tolerance_from_cost": data.get("infer_tolerance_from_cost", False),
176
+ "inferred_tolerance_default": inferred_tolerance_default,
177
+ "inferred_tolerance_multiplier": Decimal(
178
+ str(data.get("inferred_tolerance_multiplier", "0.5"))
179
+ ),
180
+ "input_hash": data.get("input_hash", ""),
181
+ "insert_pythonpath": data.get("insert_pythonpath", False),
182
+ "operating_currency": list(data.get("operating_currency", [])),
183
+ # Plugins (won't work with rustledger, but keep for compatibility)
184
+ "plugin": list(
185
+ tuple(p) if isinstance(p, list) else (p, None)
186
+ for p in data.get("plugin", [])
187
+ ),
188
+ "plugin_processing_mode": data.get("plugin_processing_mode", ""),
189
+ "pythonpath": list(data.get("pythonpath", [])),
190
+ "render_commas": data.get("render_commas", False),
191
+ "tolerance_multiplier": Decimal(
192
+ str(data.get("tolerance_multiplier", "1.0"))
193
+ ),
194
+ # Deprecated options (for compatibility)
195
+ "allow_deprecated_none_for_tags_and_links": data.get(
196
+ "allow_deprecated_none_for_tags_and_links", False
197
+ ),
198
+ "allow_pipe_separator": data.get("allow_pipe_separator", False),
199
+ "long_string_maxlines": data.get("long_string_maxlines", 64),
200
+ }
201
+
202
+ return options