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,830 @@
1
+ """Type adapters for rustledger JSON to Fava-compatible Python objects.
2
+
3
+ This module provides concrete implementations of Fava's ABC types that
4
+ can be constructed from rustledger's JSON output.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime
10
+ from dataclasses import asdict
11
+ from dataclasses import dataclass
12
+ from dataclasses import fields
13
+ from decimal import Decimal
14
+ from typing import Any
15
+ from typing import TYPE_CHECKING
16
+
17
+ from rustfava.beans import abc
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Mapping
21
+ from collections.abc import Sequence
22
+
23
+
24
+ class AsDictMixin:
25
+ """Mixin that provides _asdict() method for compatibility with beancount's named tuples."""
26
+
27
+ def _asdict(self) -> dict[str, Any]:
28
+ """Return a dict of the dataclass fields, like named tuple _asdict()."""
29
+ return {f.name: getattr(self, f.name) for f in fields(self)} # type: ignore[arg-type]
30
+
31
+
32
+ class FrozenDict(dict[str, Any]):
33
+ """A hashable dict for use in frozen dataclasses.
34
+
35
+ This allows our directive types to be hashable while still
36
+ providing dict-like access to metadata.
37
+ """
38
+
39
+ def __hash__(self) -> int: # type: ignore[override]
40
+ """Return hash based on sorted items, handling nested unhashable types."""
41
+ def make_hashable(v: Any) -> Any:
42
+ if isinstance(v, dict):
43
+ return tuple(sorted((k, make_hashable(val)) for k, val in v.items()))
44
+ if isinstance(v, list):
45
+ return tuple(make_hashable(item) for item in v)
46
+ return v
47
+
48
+ return hash(tuple(sorted((k, make_hashable(v)) for k, v in self.items())))
49
+
50
+ def __setitem__(self, key: Any, value: Any) -> None:
51
+ """Prevent modification."""
52
+ msg = "FrozenDict is immutable"
53
+ raise TypeError(msg)
54
+
55
+ def __delitem__(self, key: Any) -> None:
56
+ """Prevent modification."""
57
+ msg = "FrozenDict is immutable"
58
+ raise TypeError(msg)
59
+
60
+ def __copy__(self) -> dict[str, Any]:
61
+ """Return a regular mutable dict copy."""
62
+ return dict(self)
63
+
64
+ def __deepcopy__(self, memo: dict[int, Any]) -> dict[str, Any]:
65
+ """Return a regular mutable dict deep copy."""
66
+ import copy
67
+
68
+ return {copy.deepcopy(k, memo): copy.deepcopy(v, memo) for k, v in self.items()}
69
+
70
+
71
+ # Register our types with Fava's ABCs
72
+ # This allows isinstance() checks to work with our types
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class RLAmount:
77
+ """Rustledger Amount type."""
78
+
79
+ number: Decimal
80
+ currency: str
81
+
82
+ @classmethod
83
+ def from_json(cls, data: dict[str, Any] | None) -> RLAmount | None:
84
+ """Create from JSON dict."""
85
+ if data is None:
86
+ return None
87
+ return cls(
88
+ number=Decimal(data["number"]),
89
+ currency=data["currency"],
90
+ )
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class RLCost:
95
+ """Rustledger Cost type."""
96
+
97
+ number: Decimal | None
98
+ currency: str
99
+ date: datetime.date | None
100
+ label: str | None
101
+ number_total: Decimal | None = None
102
+
103
+ @classmethod
104
+ def from_json(
105
+ cls,
106
+ data: dict[str, Any] | None,
107
+ default_date: datetime.date | None = None,
108
+ units_number: Decimal | None = None,
109
+ ) -> RLCost | None:
110
+ """Create from JSON dict.
111
+
112
+ Args:
113
+ data: JSON dict with cost data
114
+ default_date: Date to use if cost has no date (e.g., transaction date).
115
+ This matches beancount's behavior of filling in missing
116
+ cost dates with the transaction date.
117
+ units_number: Number of units (for computing per-unit cost from total)
118
+ """
119
+ if data is None or not data:
120
+ return None
121
+ # Must have at least a currency to be a valid cost
122
+ currency = data.get("currency")
123
+ if not currency:
124
+ return None
125
+ # Use explicit date if provided, otherwise fall back to default_date
126
+ cost_date = (
127
+ datetime.date.fromisoformat(data["date"])
128
+ if data.get("date")
129
+ else default_date
130
+ )
131
+ # Handle both per-unit (number) and total cost (number_total)
132
+ number = Decimal(data["number"]) if data.get("number") else None
133
+ number_total = (
134
+ Decimal(data["number_total"]) if data.get("number_total") else None
135
+ )
136
+ # If we have total cost but not per-unit, compute per-unit
137
+ if number is None and number_total is not None and units_number:
138
+ number = number_total / abs(units_number)
139
+ return cls(
140
+ number=number,
141
+ currency=currency,
142
+ date=cost_date,
143
+ label=data.get("label"),
144
+ number_total=number_total,
145
+ )
146
+
147
+
148
+ @dataclass(frozen=True, slots=True)
149
+ class RLPosition:
150
+ """Rustledger Position type."""
151
+
152
+ units: RLAmount
153
+ cost: RLCost | None
154
+
155
+ @classmethod
156
+ def from_json(cls, data: dict[str, Any]) -> RLPosition:
157
+ """Create from JSON dict."""
158
+ units = RLAmount.from_json(data["units"])
159
+ if units is None:
160
+ msg = "RLPosition requires units"
161
+ raise ValueError(msg)
162
+ return cls(
163
+ units=units,
164
+ cost=RLCost.from_json(
165
+ data.get("cost"),
166
+ units_number=units.number,
167
+ ),
168
+ )
169
+
170
+
171
+ abc.Position.register(RLPosition)
172
+
173
+
174
+ @dataclass(frozen=True, slots=True)
175
+ class RLPosting:
176
+ """Rustledger Posting type."""
177
+
178
+ account: str
179
+ units: RLAmount | None
180
+ cost: RLCost | None
181
+ price: RLAmount | None
182
+ flag: str | None
183
+ meta: FrozenDict | None
184
+
185
+ @classmethod
186
+ def from_json(
187
+ cls,
188
+ data: dict[str, Any],
189
+ transaction_date: datetime.date | None = None,
190
+ ) -> RLPosting:
191
+ """Create from JSON dict.
192
+
193
+ Args:
194
+ data: JSON dict with posting data
195
+ transaction_date: The date of the parent transaction. Used to fill in
196
+ missing cost dates (beancount behavior).
197
+ """
198
+ meta = data.get("meta")
199
+ units = RLAmount.from_json(data.get("units"))
200
+ return cls(
201
+ account=data["account"],
202
+ units=units,
203
+ cost=RLCost.from_json(
204
+ data.get("cost"),
205
+ default_date=transaction_date,
206
+ units_number=units.number if units else None,
207
+ ),
208
+ price=RLAmount.from_json(data.get("price")),
209
+ flag=data.get("flag"),
210
+ meta=FrozenDict(meta) if meta else None,
211
+ )
212
+
213
+
214
+ abc.Posting.register(RLPosting)
215
+
216
+
217
+ def _parse_date(date_str: str) -> datetime.date:
218
+ """Parse ISO date string."""
219
+ return datetime.date.fromisoformat(date_str)
220
+
221
+
222
+ def _parse_meta(data: dict[str, Any]) -> FrozenDict:
223
+ """Parse metadata dict, converting date strings."""
224
+ meta = dict(data.get("meta", {}))
225
+ # Ensure filename and lineno are present with correct types
226
+ if "filename" not in meta:
227
+ meta["filename"] = "<unknown>"
228
+ if "lineno" not in meta:
229
+ meta["lineno"] = 0
230
+ else:
231
+ # Ensure lineno is int (FFI may return it as string)
232
+ meta["lineno"] = int(meta["lineno"])
233
+ return FrozenDict(meta)
234
+
235
+
236
+ @dataclass(frozen=True, slots=True)
237
+ class RLTransaction(AsDictMixin):
238
+ """Rustledger Transaction type."""
239
+
240
+ meta: Mapping[str, Any]
241
+ date: datetime.date
242
+ flag: str
243
+ payee: str | None
244
+ narration: str
245
+ tags: frozenset[str]
246
+ links: frozenset[str]
247
+ postings: Sequence[RLPosting]
248
+
249
+ @classmethod
250
+ def from_json(cls, data: dict[str, Any]) -> RLTransaction:
251
+ """Create from JSON dict."""
252
+ txn_date = _parse_date(data["date"])
253
+ return cls(
254
+ meta=_parse_meta(data),
255
+ date=txn_date,
256
+ flag=data.get("flag", "*"),
257
+ payee=data.get("payee"),
258
+ narration=data.get("narration", ""),
259
+ tags=frozenset(data.get("tags", [])),
260
+ links=frozenset(data.get("links", [])),
261
+ postings=tuple(
262
+ RLPosting.from_json(p, transaction_date=txn_date)
263
+ for p in data.get("postings", [])
264
+ ),
265
+ )
266
+
267
+
268
+ abc.Transaction.register(RLTransaction)
269
+
270
+
271
+ @dataclass(frozen=True, slots=True)
272
+ class RLBalance(AsDictMixin):
273
+ """Rustledger Balance type."""
274
+
275
+ meta: Mapping[str, Any]
276
+ date: datetime.date
277
+ account: str
278
+ amount: RLAmount
279
+ tolerance: Decimal | None
280
+ diff_amount: RLAmount | None
281
+
282
+ @classmethod
283
+ def from_json(cls, data: dict[str, Any]) -> RLBalance:
284
+ """Create from JSON dict."""
285
+ return cls(
286
+ meta=_parse_meta(data),
287
+ date=_parse_date(data["date"]),
288
+ account=data["account"],
289
+ amount=RLAmount.from_json(data["amount"]), # type: ignore[arg-type]
290
+ tolerance=(
291
+ Decimal(data["tolerance"]) if data.get("tolerance") else None
292
+ ),
293
+ diff_amount=RLAmount.from_json(data.get("diff_amount")),
294
+ )
295
+
296
+
297
+ abc.Balance.register(RLBalance)
298
+
299
+
300
+ @dataclass(frozen=True, slots=True)
301
+ class RLOpen(AsDictMixin):
302
+ """Rustledger Open type."""
303
+
304
+ meta: Mapping[str, Any]
305
+ date: datetime.date
306
+ account: str
307
+ currencies: Sequence[str]
308
+ booking: str | None # Could be enum: FIFO, LIFO, HIFO, AVERAGE, STRICT, NONE
309
+
310
+ @classmethod
311
+ def from_json(cls, data: dict[str, Any]) -> RLOpen:
312
+ """Create from JSON dict."""
313
+ return cls(
314
+ meta=_parse_meta(data),
315
+ date=_parse_date(data["date"]),
316
+ account=data["account"],
317
+ currencies=tuple(data.get("currencies", [])),
318
+ booking=data.get("booking"),
319
+ )
320
+
321
+
322
+ abc.Open.register(RLOpen)
323
+
324
+
325
+ @dataclass(frozen=True, slots=True)
326
+ class RLClose(AsDictMixin):
327
+ """Rustledger Close type."""
328
+
329
+ meta: Mapping[str, Any]
330
+ date: datetime.date
331
+ account: str
332
+
333
+ @classmethod
334
+ def from_json(cls, data: dict[str, Any]) -> RLClose:
335
+ """Create from JSON dict."""
336
+ return cls(
337
+ meta=_parse_meta(data),
338
+ date=_parse_date(data["date"]),
339
+ account=data["account"],
340
+ )
341
+
342
+
343
+ abc.Close.register(RLClose)
344
+
345
+
346
+ @dataclass(frozen=True, slots=True)
347
+ class RLPrice(AsDictMixin):
348
+ """Rustledger Price type."""
349
+
350
+ meta: Mapping[str, Any]
351
+ date: datetime.date
352
+ currency: str
353
+ amount: RLAmount
354
+
355
+ @classmethod
356
+ def from_json(cls, data: dict[str, Any]) -> RLPrice:
357
+ """Create from JSON dict."""
358
+ return cls(
359
+ meta=_parse_meta(data),
360
+ date=_parse_date(data["date"]),
361
+ currency=data["currency"],
362
+ amount=RLAmount.from_json(data["amount"]), # type: ignore[arg-type]
363
+ )
364
+
365
+
366
+ abc.Price.register(RLPrice)
367
+
368
+
369
+ @dataclass(frozen=True, slots=True)
370
+ class RLEvent(AsDictMixin):
371
+ """Rustledger Event type."""
372
+
373
+ meta: Mapping[str, Any]
374
+ date: datetime.date
375
+ type: str # event_type
376
+ description: str
377
+
378
+ # Fava's Event ABC expects 'account' property but events don't have accounts
379
+ # This is a quirk in Fava's ABC definition
380
+ @property
381
+ def account(self) -> str:
382
+ """Event type (mapped to account for ABC compatibility)."""
383
+ return self.type
384
+
385
+ @classmethod
386
+ def from_json(cls, data: dict[str, Any]) -> RLEvent:
387
+ """Create from JSON dict."""
388
+ return cls(
389
+ meta=_parse_meta(data),
390
+ date=_parse_date(data["date"]),
391
+ type=data.get("event_type", ""),
392
+ description=data.get("description", data.get("value", "")),
393
+ )
394
+
395
+
396
+ abc.Event.register(RLEvent)
397
+
398
+
399
+ @dataclass(frozen=True, slots=True)
400
+ class RLNote(AsDictMixin):
401
+ """Rustledger Note type."""
402
+
403
+ meta: Mapping[str, Any]
404
+ date: datetime.date
405
+ account: str
406
+ comment: str
407
+ tags: frozenset[str]
408
+ links: frozenset[str]
409
+
410
+ @classmethod
411
+ def from_json(cls, data: dict[str, Any]) -> RLNote:
412
+ """Create from JSON dict."""
413
+ return cls(
414
+ meta=_parse_meta(data),
415
+ date=_parse_date(data["date"]),
416
+ account=data["account"],
417
+ comment=data.get("comment", ""),
418
+ tags=frozenset(data.get("tags", [])),
419
+ links=frozenset(data.get("links", [])),
420
+ )
421
+
422
+
423
+ abc.Note.register(RLNote)
424
+
425
+
426
+ @dataclass(frozen=True, slots=True)
427
+ class RLDocument(AsDictMixin):
428
+ """Rustledger Document type."""
429
+
430
+ meta: Mapping[str, Any]
431
+ date: datetime.date
432
+ account: str
433
+ filename: str
434
+ tags: frozenset[str]
435
+ links: frozenset[str]
436
+
437
+ @classmethod
438
+ def from_json(cls, data: dict[str, Any]) -> RLDocument:
439
+ """Create from JSON dict."""
440
+ return cls(
441
+ meta=_parse_meta(data),
442
+ date=_parse_date(data["date"]),
443
+ account=data["account"],
444
+ filename=data.get("filename", data.get("path", "")),
445
+ tags=frozenset(data.get("tags", [])),
446
+ links=frozenset(data.get("links", [])),
447
+ )
448
+
449
+
450
+ abc.Document.register(RLDocument)
451
+
452
+
453
+ @dataclass(frozen=True, slots=True)
454
+ class RLPad(AsDictMixin):
455
+ """Rustledger Pad type."""
456
+
457
+ meta: Mapping[str, Any]
458
+ date: datetime.date
459
+ account: str
460
+ source_account: str
461
+
462
+ @classmethod
463
+ def from_json(cls, data: dict[str, Any]) -> RLPad:
464
+ """Create from JSON dict."""
465
+ return cls(
466
+ meta=_parse_meta(data),
467
+ date=_parse_date(data["date"]),
468
+ account=data["account"],
469
+ source_account=data["source_account"],
470
+ )
471
+
472
+
473
+ abc.Pad.register(RLPad)
474
+
475
+
476
+ @dataclass(frozen=True, slots=True)
477
+ class RLCommodity(AsDictMixin):
478
+ """Rustledger Commodity type."""
479
+
480
+ meta: Mapping[str, Any]
481
+ date: datetime.date
482
+ currency: str
483
+
484
+ @classmethod
485
+ def from_json(cls, data: dict[str, Any]) -> RLCommodity:
486
+ """Create from JSON dict."""
487
+ return cls(
488
+ meta=_parse_meta(data),
489
+ date=_parse_date(data["date"]),
490
+ currency=data["currency"],
491
+ )
492
+
493
+
494
+ abc.Commodity.register(RLCommodity)
495
+
496
+
497
+ @dataclass(frozen=True, slots=True)
498
+ class RLQuery(AsDictMixin):
499
+ """Rustledger Query (stored query) type."""
500
+
501
+ meta: Mapping[str, Any]
502
+ date: datetime.date
503
+ name: str
504
+ query_string: str
505
+
506
+ @classmethod
507
+ def from_json(cls, data: dict[str, Any]) -> RLQuery:
508
+ """Create from JSON dict."""
509
+ return cls(
510
+ meta=_parse_meta(data),
511
+ date=_parse_date(data["date"]),
512
+ name=data["name"],
513
+ query_string=data["query_string"],
514
+ )
515
+
516
+
517
+ abc.Query.register(RLQuery)
518
+
519
+
520
+ @dataclass(frozen=True, slots=True)
521
+ class RLCustomValue:
522
+ """Wrapper for custom directive values to match beancount's interface."""
523
+
524
+ value: Any
525
+ dtype: type = str # Default to str type (not account)
526
+
527
+ def __str__(self) -> str:
528
+ """String representation."""
529
+ return str(self.value)
530
+
531
+ @classmethod
532
+ def from_raw(cls, raw_value: Any) -> RLCustomValue:
533
+ """Create from raw value, parsing different value types.
534
+
535
+ Rustledger outputs custom directive values as typed objects:
536
+ - {"type": "string", "value": "text"} -> string
537
+ - {"type": "number", "value": "10"} -> Decimal('10')
538
+ - {"type": "bool", "value": true} -> bool
539
+ - {"type": "amount", "value": {"number": "20.00", "currency": "EUR"}} -> RLAmount
540
+ - {"type": "account", "value": "Expenses:Books"} -> string (account)
541
+ - {"type": "date", "value": "2024-01-01"} -> datetime.date
542
+
543
+ For backwards compatibility, also handles raw strings.
544
+ """
545
+ # Handle new typed format from rustledger
546
+ if isinstance(raw_value, dict) and "type" in raw_value:
547
+ val_type = raw_value["type"]
548
+ val = raw_value.get("value")
549
+
550
+ if val_type == "string":
551
+ return cls(val, dtype=str)
552
+ if val_type == "number":
553
+ return cls(Decimal(str(val)), dtype=Decimal)
554
+ if val_type == "bool":
555
+ return cls(val, dtype=bool)
556
+ if val_type == "amount":
557
+ # Amount is a nested object with number and currency
558
+ if isinstance(val, dict):
559
+ return cls(RLAmount.from_json(val))
560
+ # Or pre-parsed string "20.00 EUR"
561
+ parts = str(val).split()
562
+ if len(parts) == 2:
563
+ return cls(RLAmount(number=Decimal(parts[0]), currency=parts[1]))
564
+ return cls(val)
565
+ if val_type == "account":
566
+ return cls(val, dtype=str)
567
+ if val_type == "date":
568
+ if val is None:
569
+ return cls(None, dtype=datetime.date)
570
+ return cls(datetime.date.fromisoformat(str(val)), dtype=datetime.date)
571
+ # Unknown type, return as-is
572
+ return cls(val)
573
+
574
+ # Backwards compatibility: handle raw values without type info
575
+ if not isinstance(raw_value, str):
576
+ # Already typed (e.g., int, Decimal)
577
+ return cls(raw_value)
578
+
579
+ # Try to parse as Amount (number + currency)
580
+ # Format: "20.00 EUR" or "-100.50 USD"
581
+ parts = raw_value.split()
582
+ if len(parts) == 2:
583
+ try:
584
+ number = Decimal(parts[0])
585
+ currency = parts[1]
586
+ # Verify currency looks like a currency (uppercase letters)
587
+ if currency.isalpha() and currency.isupper():
588
+ return cls(RLAmount(number=number, currency=currency))
589
+ except Exception: # noqa: BLE001
590
+ pass
591
+
592
+ # Keep strings as strings - don't try to convert to numbers
593
+ # When rustledger has typed values, numbers will come through
594
+ # the dict branch above. For backwards compat, treat all
595
+ # untyped strings as strings.
596
+ return cls(raw_value)
597
+
598
+
599
+ @dataclass(frozen=True, slots=True)
600
+ class RLCustom(AsDictMixin):
601
+ """Rustledger Custom type."""
602
+
603
+ meta: Mapping[str, Any]
604
+ date: datetime.date
605
+ type: str # custom_type
606
+ values: Sequence[RLCustomValue]
607
+
608
+ @classmethod
609
+ def from_json(cls, data: dict[str, Any]) -> RLCustom:
610
+ """Create from JSON dict."""
611
+ # Wrap values to match beancount's interface where values[i].value exists
612
+ raw_values = data.get("values", [])
613
+ wrapped_values = tuple(RLCustomValue.from_raw(v) for v in raw_values)
614
+ return cls(
615
+ meta=_parse_meta(data),
616
+ date=_parse_date(data["date"]),
617
+ type=data.get("custom_type", ""),
618
+ values=wrapped_values,
619
+ )
620
+
621
+
622
+ abc.Custom.register(RLCustom)
623
+
624
+
625
+ # Type mapping from JSON "type" field to constructor
626
+ DIRECTIVE_TYPES: dict[str, type] = {
627
+ "transaction": RLTransaction,
628
+ "balance": RLBalance,
629
+ "open": RLOpen,
630
+ "close": RLClose,
631
+ "price": RLPrice,
632
+ "event": RLEvent,
633
+ "note": RLNote,
634
+ "document": RLDocument,
635
+ "pad": RLPad,
636
+ "commodity": RLCommodity,
637
+ "query": RLQuery,
638
+ "custom": RLCustom,
639
+ }
640
+
641
+
642
+ def directive_from_json(data: dict[str, Any]) -> abc.Directive:
643
+ """Convert a JSON directive to a Fava-compatible directive object.
644
+
645
+ Args:
646
+ data: JSON dict with 'type' field indicating directive type
647
+
648
+ Returns:
649
+ A directive instance registered with Fava's ABCs
650
+
651
+ Raises:
652
+ ValueError: If the directive type is unknown
653
+ """
654
+ directive_type = data.get("type", "").lower()
655
+ cls = DIRECTIVE_TYPES.get(directive_type)
656
+ if cls is None:
657
+ msg = f"Unknown directive type: {directive_type}"
658
+ raise ValueError(msg)
659
+ # All types in DIRECTIVE_TYPES have from_json class method
660
+ from_json = getattr(cls, "from_json")
661
+ result: abc.Directive = from_json(data)
662
+ return result
663
+
664
+
665
+ def directives_from_json(data: list[dict[str, Any]]) -> list[abc.Directive]:
666
+ """Convert a list of JSON directives to Fava-compatible objects."""
667
+ return [directive_from_json(d) for d in data]
668
+
669
+
670
+ # Reverse mapping from class to type name
671
+ _TYPE_NAMES: dict[type, str] = {v: k for k, v in DIRECTIVE_TYPES.items()}
672
+
673
+
674
+ def _amount_to_json(amt: RLAmount | None) -> dict[str, Any] | None:
675
+ """Convert RLAmount to JSON dict."""
676
+ if amt is None:
677
+ return None
678
+ return {"number": str(amt.number), "currency": amt.currency}
679
+
680
+
681
+ def _cost_to_json(cost: RLCost | None) -> dict[str, Any] | None:
682
+ """Convert RLCostSpec to JSON dict."""
683
+ if cost is None:
684
+ return None
685
+ result: dict[str, Any] = {}
686
+ if cost.number is not None:
687
+ result["number"] = str(cost.number)
688
+ if cost.currency is not None:
689
+ result["currency"] = cost.currency
690
+ if cost.date is not None:
691
+ result["date"] = str(cost.date)
692
+ if cost.label is not None:
693
+ result["label"] = cost.label
694
+ return result if result else None
695
+
696
+
697
+ def _posting_to_json(posting: RLPosting) -> dict[str, Any]:
698
+ """Convert RLPosting to JSON dict."""
699
+ result: dict[str, Any] = {"account": posting.account}
700
+ if posting.units is not None:
701
+ result["units"] = _amount_to_json(posting.units)
702
+ if posting.cost is not None:
703
+ result["cost"] = _cost_to_json(posting.cost)
704
+ if posting.price is not None:
705
+ result["price"] = _amount_to_json(posting.price)
706
+ if posting.flag:
707
+ result["flag"] = posting.flag
708
+ if posting.meta:
709
+ result["meta"] = dict(posting.meta)
710
+ return result
711
+
712
+
713
+ def directive_to_json(directive: abc.Directive) -> dict[str, Any]:
714
+ """Convert a directive to JSON dict for rustledger.
715
+
716
+ Args:
717
+ directive: A Fava directive object
718
+
719
+ Returns:
720
+ JSON dict with 'type' field indicating directive type
721
+ """
722
+ cls = type(directive)
723
+ type_name = _TYPE_NAMES.get(cls)
724
+
725
+ if type_name is None:
726
+ # Handle beancount types by checking class name
727
+ cls_name = cls.__name__
728
+ type_name = cls_name.lower().removeprefix("rl")
729
+ if type_name not in DIRECTIVE_TYPES:
730
+ msg = f"Unknown directive type: {cls}"
731
+ raise ValueError(msg)
732
+
733
+ result: dict[str, Any] = {
734
+ "type": type_name,
735
+ "date": str(directive.date),
736
+ }
737
+
738
+ # Add meta if present
739
+ if hasattr(directive, "meta") and directive.meta:
740
+ result["meta"] = dict(directive.meta)
741
+
742
+ # Type-specific fields - use getattr since directive type varies
743
+ if type_name == "transaction":
744
+ result["flag"] = getattr(directive, "flag", "*")
745
+ result["payee"] = getattr(directive, "payee", None)
746
+ result["narration"] = getattr(directive, "narration", "")
747
+ result["tags"] = list(getattr(directive, "tags", []))
748
+ result["links"] = list(getattr(directive, "links", []))
749
+ result["postings"] = [_posting_to_json(p) for p in getattr(directive, "postings", [])]
750
+
751
+ elif type_name == "balance":
752
+ result["account"] = getattr(directive, "account", "")
753
+ result["amount"] = _amount_to_json(getattr(directive, "amount", None))
754
+ tolerance = getattr(directive, "tolerance", None)
755
+ if tolerance is not None:
756
+ result["tolerance"] = str(tolerance)
757
+ diff_amount = getattr(directive, "diff_amount", None)
758
+ if diff_amount is not None:
759
+ result["diff_amount"] = _amount_to_json(diff_amount)
760
+
761
+ elif type_name == "open":
762
+ result["account"] = getattr(directive, "account", "")
763
+ currencies = getattr(directive, "currencies", None)
764
+ result["currencies"] = list(currencies) if currencies else []
765
+ result["booking"] = getattr(directive, "booking", None)
766
+
767
+ elif type_name == "close":
768
+ result["account"] = getattr(directive, "account", "")
769
+
770
+ elif type_name == "price":
771
+ result["currency"] = getattr(directive, "currency", "")
772
+ result["amount"] = _amount_to_json(getattr(directive, "amount", None))
773
+
774
+ elif type_name == "event":
775
+ result["event_type"] = getattr(directive, "type", "")
776
+ result["description"] = getattr(directive, "description", "")
777
+
778
+ elif type_name == "note":
779
+ result["account"] = getattr(directive, "account", "")
780
+ result["comment"] = getattr(directive, "comment", "")
781
+ result["tags"] = list(getattr(directive, "tags", []))
782
+ result["links"] = list(getattr(directive, "links", []))
783
+
784
+ elif type_name == "document":
785
+ result["account"] = getattr(directive, "account", "")
786
+ result["filename"] = getattr(directive, "filename", "")
787
+ result["tags"] = list(getattr(directive, "tags", []))
788
+ result["links"] = list(getattr(directive, "links", []))
789
+
790
+ elif type_name == "pad":
791
+ result["account"] = getattr(directive, "account", "")
792
+ result["source_account"] = getattr(directive, "source_account", "")
793
+
794
+ elif type_name == "commodity":
795
+ result["currency"] = getattr(directive, "currency", "")
796
+
797
+ elif type_name == "query":
798
+ result["name"] = getattr(directive, "name", "")
799
+ result["query_string"] = getattr(directive, "query_string", "")
800
+
801
+ elif type_name == "custom":
802
+ result["custom_type"] = getattr(directive, "type", "")
803
+ # Custom values need special handling
804
+ values = []
805
+ for v in getattr(directive, "values", []):
806
+ if isinstance(v, RLCustomValue):
807
+ # Convert RLCustomValue to rustledger's typed format
808
+ if v.dtype == str:
809
+ values.append({"type": "string", "value": str(v.value)})
810
+ elif hasattr(v.value, "number") and hasattr(v.value, "currency"):
811
+ # Amount type
812
+ values.append({
813
+ "type": "amount",
814
+ "number": str(v.value.number),
815
+ "currency": v.value.currency,
816
+ })
817
+ else:
818
+ values.append({"type": "string", "value": str(v.value)})
819
+ elif hasattr(v, "_asdict"):
820
+ values.append(v._asdict())
821
+ else:
822
+ values.append(v)
823
+ result["values"] = values
824
+
825
+ return result
826
+
827
+
828
+ def directives_to_json(directives: list[abc.Directive]) -> list[dict[str, Any]]:
829
+ """Convert a list of directives to JSON dicts for rustledger."""
830
+ return [directive_to_json(d) for d in directives]