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,377 @@
1
+ """Helpers to create entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from decimal import Decimal
7
+ from typing import overload
8
+ from typing import TYPE_CHECKING
9
+
10
+ from rustfava.rustledger.types import FrozenDict
11
+ from rustfava.rustledger.types import RLAmount
12
+ from rustfava.rustledger.types import RLBalance
13
+ from rustfava.rustledger.types import RLClose
14
+ from rustfava.rustledger.types import RLCost
15
+ from rustfava.rustledger.types import RLDocument
16
+ from rustfava.rustledger.types import RLNote
17
+ from rustfava.rustledger.types import RLOpen
18
+ from rustfava.rustledger.types import RLPosting
19
+ from rustfava.rustledger.types import RLPosition
20
+ from rustfava.rustledger.types import RLTransaction
21
+
22
+ if TYPE_CHECKING: # pragma: no cover
23
+ import datetime
24
+
25
+ from rustfava.beans.abc import Balance
26
+ from rustfava.beans.abc import Close
27
+ from rustfava.beans.abc import Document
28
+ from rustfava.beans.abc import Meta
29
+ from rustfava.beans.abc import Note
30
+ from rustfava.beans.abc import Open
31
+ from rustfava.beans.abc import Position
32
+ from rustfava.beans.abc import Posting
33
+ from rustfava.beans.abc import Transaction
34
+ from rustfava.beans.flags import Flag
35
+ from rustfava.beans.protocols import Amount
36
+ from rustfava.beans.protocols import Cost
37
+
38
+
39
+ # Pattern to match amount strings like "100 USD", "-10.50 EUR", "1,000.00 CHF"
40
+ _AMOUNT_RE = re.compile(r"^\s*([-+]?\s*[\d,]+(?:\.\d*)?)\s+([A-Z][A-Z0-9'._-]*[A-Z0-9]?)\s*$")
41
+
42
+
43
+ def _parse_amount_string(amt_str: str) -> RLAmount:
44
+ """Parse an amount string like '100 USD' into an RLAmount."""
45
+ match = _AMOUNT_RE.match(amt_str)
46
+ if not match:
47
+ msg = f"Invalid amount string: {amt_str}"
48
+ raise ValueError(msg)
49
+ number_str, currency = match.groups()
50
+ # Remove commas and spaces from number
51
+ number_str = number_str.replace(",", "").replace(" ", "")
52
+ return RLAmount(number=Decimal(number_str), currency=currency)
53
+
54
+
55
+ @overload
56
+ def amount(amt: Amount) -> Amount: ... # pragma: no cover
57
+
58
+
59
+ @overload
60
+ def amount(amt: str) -> Amount: ... # pragma: no cover
61
+
62
+
63
+ @overload
64
+ def amount(amt: Decimal, currency: str) -> Amount: ... # pragma: no cover
65
+
66
+
67
+ def amount(amt: Amount | Decimal | str, currency: str | None = None) -> Amount:
68
+ """Amount from a string or tuple."""
69
+ if isinstance(amt, str):
70
+ return _parse_amount_string(amt)
71
+ if hasattr(amt, "number") and hasattr(amt, "currency"):
72
+ return amt # Already an Amount-like object
73
+ if not isinstance(currency, str): # pragma: no cover
74
+ raise TypeError
75
+ return RLAmount(amt, currency)
76
+
77
+
78
+ _amount = amount
79
+
80
+
81
+ def cost(
82
+ number: Decimal,
83
+ currency: str,
84
+ date: datetime.date,
85
+ label: str | None = None,
86
+ ) -> Cost:
87
+ """Create a Cost."""
88
+ return RLCost(number, currency, date, label) # type: ignore[return-value]
89
+
90
+
91
+ def position(units: Amount, cost: Cost | None) -> Position:
92
+ """Create a Position."""
93
+ # Convert units to RLAmount if needed
94
+ if isinstance(units, str):
95
+ units = _parse_amount_string(units)
96
+ elif not isinstance(units, RLAmount):
97
+ units = RLAmount(units.number, units.currency)
98
+
99
+ # Convert cost to RLCost if needed
100
+ rl_cost: RLCost | None = None
101
+ if cost is not None:
102
+ if isinstance(cost, RLCost):
103
+ rl_cost = cost
104
+ else:
105
+ rl_cost = RLCost(
106
+ number=cost.number,
107
+ currency=cost.currency,
108
+ date=cost.date,
109
+ label=cost.label,
110
+ )
111
+
112
+ return RLPosition(units=units, cost=rl_cost) # type: ignore[return-value]
113
+
114
+
115
+ def posting(
116
+ account: str,
117
+ units: Amount | str,
118
+ cost: Cost | None = None,
119
+ price: Amount | str | None = None,
120
+ flag: str | None = None,
121
+ meta: Meta | None = None,
122
+ ) -> Posting:
123
+ """Create a Posting."""
124
+ # Convert units
125
+ rl_units: RLAmount | None = None
126
+ if units is not None:
127
+ if isinstance(units, str):
128
+ rl_units = _parse_amount_string(units)
129
+ elif isinstance(units, RLAmount):
130
+ rl_units = units
131
+ else:
132
+ rl_units = RLAmount(units.number, units.currency)
133
+
134
+ # Convert cost
135
+ rl_cost: RLCost | None = None
136
+ if cost is not None:
137
+ if isinstance(cost, RLCost):
138
+ rl_cost = cost
139
+ elif hasattr(cost, "number_per"):
140
+ # CostSpec - convert to RLCost
141
+ number = getattr(cost, "number_per", None)
142
+ currency = getattr(cost, "currency", None)
143
+ date = getattr(cost, "date", None)
144
+ label = getattr(cost, "label", None)
145
+ # Handle MISSING sentinels - MISSING is a class used as a value
146
+ if number is not None and isinstance(number, type) and number.__name__ == "MISSING":
147
+ number = None
148
+ if currency is not None and isinstance(currency, type) and currency.__name__ == "MISSING":
149
+ currency = None
150
+ # Only create cost if we have a valid currency
151
+ if currency is not None:
152
+ rl_cost = RLCost(
153
+ number=number,
154
+ currency=currency,
155
+ date=date,
156
+ label=label,
157
+ )
158
+ # If both number and currency are MISSING, we still want to represent
159
+ # an empty cost spec as "{}" in output - set cost to a sentinel
160
+ elif number is None:
161
+ # CostSpec with all MISSING - will be rendered as "{}"
162
+ rl_cost = None # Empty cost will be handled specially
163
+ else:
164
+ rl_cost = RLCost(
165
+ number=cost.number,
166
+ currency=cost.currency,
167
+ date=cost.date,
168
+ label=cost.label,
169
+ )
170
+
171
+ # Convert price
172
+ rl_price: RLAmount | None = None
173
+ if price is not None:
174
+ if isinstance(price, str):
175
+ rl_price = _parse_amount_string(price)
176
+ elif isinstance(price, RLAmount):
177
+ rl_price = price
178
+ else:
179
+ rl_price = RLAmount(price.number, price.currency)
180
+
181
+ # Convert meta
182
+ rl_meta: FrozenDict | None = None
183
+ if meta is not None:
184
+ rl_meta = FrozenDict(dict(meta))
185
+
186
+ return RLPosting( # type: ignore[return-value]
187
+ account=account,
188
+ units=rl_units,
189
+ cost=rl_cost,
190
+ price=rl_price,
191
+ flag=flag,
192
+ meta=rl_meta,
193
+ )
194
+
195
+
196
+ _EMPTY_SET: frozenset[str] = frozenset()
197
+
198
+
199
+ def _make_meta(meta: Meta) -> FrozenDict:
200
+ """Convert meta dict to FrozenDict."""
201
+ return FrozenDict(dict(meta))
202
+
203
+
204
+ def transaction(
205
+ meta: Meta,
206
+ date: datetime.date,
207
+ flag: Flag,
208
+ payee: str | None,
209
+ narration: str,
210
+ tags: frozenset[str] | None = None,
211
+ links: frozenset[str] | None = None,
212
+ postings: list[Posting] | None = None,
213
+ ) -> Transaction:
214
+ """Create a Transaction."""
215
+ # Convert postings to RLPosting if needed
216
+ rl_postings: tuple[RLPosting, ...] = ()
217
+ if postings:
218
+ converted = []
219
+ for p in postings:
220
+ if isinstance(p, RLPosting):
221
+ converted.append(p)
222
+ else:
223
+ # Convert from other posting type
224
+ rl_units: RLAmount | None = None
225
+ if p.units is not None:
226
+ if isinstance(p.units, RLAmount):
227
+ rl_units = p.units
228
+ else:
229
+ rl_units = RLAmount(p.units.number, p.units.currency)
230
+
231
+ rl_cost: RLCost | None = None
232
+ if p.cost is not None:
233
+ if isinstance(p.cost, RLCost):
234
+ rl_cost = p.cost
235
+ else:
236
+ rl_cost = RLCost(
237
+ number=p.cost.number,
238
+ currency=p.cost.currency,
239
+ date=p.cost.date,
240
+ label=p.cost.label,
241
+ )
242
+
243
+ rl_price: RLAmount | None = None
244
+ if p.price is not None:
245
+ if isinstance(p.price, RLAmount):
246
+ rl_price = p.price
247
+ else:
248
+ rl_price = RLAmount(p.price.number, p.price.currency)
249
+
250
+ rl_meta: FrozenDict | None = None
251
+ if p.meta:
252
+ rl_meta = FrozenDict(dict(p.meta))
253
+
254
+ converted.append(RLPosting(
255
+ account=p.account,
256
+ units=rl_units,
257
+ cost=rl_cost,
258
+ price=rl_price,
259
+ flag=p.flag,
260
+ meta=rl_meta,
261
+ ))
262
+ rl_postings = tuple(converted)
263
+
264
+ return RLTransaction( # type: ignore[return-value]
265
+ meta=_make_meta(meta),
266
+ date=date,
267
+ flag=flag or "*",
268
+ payee=payee,
269
+ narration=narration,
270
+ tags=tags if tags is not None else _EMPTY_SET,
271
+ links=links if links is not None else _EMPTY_SET,
272
+ postings=rl_postings,
273
+ )
274
+
275
+
276
+ def balance(
277
+ meta: Meta,
278
+ date: datetime.date,
279
+ account: str,
280
+ amount: Amount | str,
281
+ tolerance: Decimal | None = None,
282
+ diff_amount: Amount | None = None,
283
+ ) -> Balance:
284
+ """Create a Balance."""
285
+ # Convert amount
286
+ rl_amount: RLAmount
287
+ if isinstance(amount, str):
288
+ rl_amount = _parse_amount_string(amount)
289
+ elif isinstance(amount, RLAmount):
290
+ rl_amount = amount
291
+ else:
292
+ rl_amount = RLAmount(amount.number, amount.currency)
293
+
294
+ # Convert diff_amount
295
+ rl_diff: RLAmount | None = None
296
+ if diff_amount is not None:
297
+ if isinstance(diff_amount, RLAmount):
298
+ rl_diff = diff_amount
299
+ else:
300
+ rl_diff = RLAmount(diff_amount.number, diff_amount.currency)
301
+
302
+ return RLBalance( # type: ignore[return-value]
303
+ meta=_make_meta(meta),
304
+ date=date,
305
+ account=account,
306
+ amount=rl_amount,
307
+ tolerance=tolerance,
308
+ diff_amount=rl_diff,
309
+ )
310
+
311
+
312
+ def close(
313
+ meta: Meta,
314
+ date: datetime.date,
315
+ account: str,
316
+ ) -> Close:
317
+ """Create a Close."""
318
+ return RLClose( # type: ignore[return-value]
319
+ meta=_make_meta(meta),
320
+ date=date,
321
+ account=account,
322
+ )
323
+
324
+
325
+ def document(
326
+ meta: Meta,
327
+ date: datetime.date,
328
+ account: str,
329
+ filename: str,
330
+ tags: frozenset[str] | None = None,
331
+ links: frozenset[str] | None = None,
332
+ ) -> Document:
333
+ """Create a Document."""
334
+ return RLDocument( # type: ignore[return-value]
335
+ meta=_make_meta(meta),
336
+ date=date,
337
+ account=account,
338
+ filename=filename,
339
+ tags=tags if tags is not None else _EMPTY_SET,
340
+ links=links if links is not None else _EMPTY_SET,
341
+ )
342
+
343
+
344
+ def note(
345
+ meta: Meta,
346
+ date: datetime.date,
347
+ account: str,
348
+ comment: str,
349
+ tags: frozenset[str] | None = None,
350
+ links: frozenset[str] | None = None,
351
+ ) -> Note:
352
+ """Create a Note."""
353
+ return RLNote( # type: ignore[return-value]
354
+ meta=_make_meta(meta),
355
+ date=date,
356
+ account=account,
357
+ comment=comment,
358
+ tags=tags if tags is not None else _EMPTY_SET,
359
+ links=links if links is not None else _EMPTY_SET,
360
+ )
361
+
362
+
363
+ def open( # noqa: A001
364
+ meta: Meta,
365
+ date: datetime.date,
366
+ account: str,
367
+ currencies: list[str],
368
+ booking: str | None = None,
369
+ ) -> Open:
370
+ """Create an Open."""
371
+ return RLOpen( # type: ignore[return-value]
372
+ meta=_make_meta(meta),
373
+ date=date,
374
+ account=account,
375
+ currencies=tuple(currencies),
376
+ booking=booking,
377
+ )
@@ -0,0 +1,20 @@
1
+ """Beancount entry flags."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING: # pragma: no cover
8
+ from typing import TypeAlias
9
+
10
+ Flag: TypeAlias = str
11
+
12
+ FLAG_CONVERSIONS = "C"
13
+ FLAG_MERGING = "M"
14
+ FLAG_OKAY = "*"
15
+ FLAG_PADDING = "P"
16
+ FLAG_RETURNS = "R"
17
+ FLAG_SUMMARIZE = "S"
18
+ FLAG_TRANSFER = "T"
19
+ FLAG_UNREALIZED = "U"
20
+ FLAG_WARNING = "!"
@@ -0,0 +1,38 @@
1
+ """Various functions to deal with Beancount data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING: # pragma: no cover
9
+ from rustfava.beans.abc import Directive
10
+
11
+
12
+ def hash_entry(entry: Directive) -> str:
13
+ """Hash an entry."""
14
+ # Rustledger provides pre-computed hash in meta
15
+ meta = getattr(entry, "meta", None)
16
+ if meta and isinstance(meta, dict) and "hash" in meta:
17
+ return str(meta["hash"])
18
+ # Rustledger dataclass (for plugin-generated entries without hash)
19
+ if hasattr(entry, "__dataclass_fields__"):
20
+ content = f"{type(entry).__name__}|{entry.date}|{getattr(entry, 'account', '')}"
21
+ return hashlib.sha256(content.encode()).hexdigest()[:16]
22
+ # Beancount namedtuple (for entries created by create module)
23
+ if hasattr(entry, "_fields"):
24
+ content = f"{type(entry).__name__}|{entry.date}|{getattr(entry, 'narration', '')}"
25
+ return hashlib.sha256(content.encode()).hexdigest()[:16]
26
+ # Fallback for other types
27
+ return hashlib.sha256(str(entry).encode()).hexdigest()[:16]
28
+
29
+
30
+ def get_position(entry: Directive) -> tuple[str, int]:
31
+ """Get the filename and position from the entry metadata."""
32
+ meta = entry.meta
33
+ filename = meta["filename"]
34
+ lineno = meta["lineno"]
35
+ if isinstance(filename, str) and isinstance(lineno, int):
36
+ return (filename, lineno)
37
+ msg = "Invalid filename or lineno in entry metadata."
38
+ raise ValueError(msg)
@@ -0,0 +1,52 @@
1
+ """Helpers for Beancount entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from bisect import bisect_left
6
+ from dataclasses import replace as dataclass_replace
7
+ from operator import attrgetter
8
+ from typing import Any
9
+ from typing import TYPE_CHECKING
10
+ from typing import TypeVar
11
+
12
+ if TYPE_CHECKING: # pragma: no cover
13
+ import datetime
14
+ from collections.abc import Sequence
15
+
16
+ from rustfava.beans.abc import Directive
17
+ from rustfava.beans.abc import Posting
18
+
19
+ T = TypeVar("T", bound=Directive | Posting)
20
+
21
+
22
+ def replace(entry: T, **kwargs: Any) -> T:
23
+ """Create a copy of the given directive, replacing some arguments."""
24
+ # Beancount namedtuple
25
+ if hasattr(entry, "_replace"):
26
+ return entry._replace(**kwargs) # type: ignore[no-any-return]
27
+ # Rustledger dataclass
28
+ if hasattr(entry, "__dataclass_fields__"):
29
+ return dataclass_replace(entry, **kwargs) # type: ignore[type-var]
30
+ msg = f"Could not replace attribute in type {type(entry)}"
31
+ raise TypeError(msg)
32
+
33
+
34
+ _get_date = attrgetter("date")
35
+
36
+
37
+ def slice_entry_dates(
38
+ entries: Sequence[T], begin: datetime.date, end: datetime.date
39
+ ) -> Sequence[T]:
40
+ """Get slice of entries in a date window.
41
+
42
+ Args:
43
+ entries: A date-sorted list of dated directives.
44
+ begin: The first date to include.
45
+ end: One day beyond the last date.
46
+
47
+ Returns:
48
+ The slice between the given dates.
49
+ """
50
+ index_begin = bisect_left(entries, begin, key=_get_date)
51
+ index_end = bisect_left(entries, end, key=_get_date)
52
+ return entries[index_begin:index_end]
@@ -0,0 +1,75 @@
1
+ """Types for Beancount importers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+ from typing import runtime_checkable
7
+ from typing import TYPE_CHECKING
8
+ from typing import TypeVar
9
+
10
+ if TYPE_CHECKING: # pragma: no cover
11
+ import datetime
12
+ from collections.abc import Callable
13
+ from collections.abc import Sequence
14
+
15
+ from rustfava.beans.abc import Directive
16
+
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ class FileMemo(Protocol):
22
+ """The file with caching support that is passed to importers."""
23
+
24
+ name: str
25
+
26
+ def convert(self, converter_func: Callable[[str], T]) -> T:
27
+ """Run a conversion function for the file."""
28
+
29
+ def mimetype(self) -> str:
30
+ """Get the mimetype of the file."""
31
+
32
+ def contents(self) -> str:
33
+ """Get the file contents."""
34
+
35
+
36
+ @runtime_checkable
37
+ class BeanImporterProtocol(Protocol):
38
+ """Interface for Beancount importers.
39
+
40
+ typing.Protocol version of beancount.ingest.importer.ImporterProtocol
41
+
42
+ Importers can subclass from this one instead of the Beancount one to
43
+ get type checking for the methods.
44
+ """
45
+
46
+ def name(self) -> str:
47
+ """Return a unique id/name for this importer."""
48
+ cls = self.__class__
49
+ return f"{cls.__module__}.{cls.__name__}"
50
+
51
+ def identify(self, file: FileMemo) -> bool:
52
+ """Return true if this importer matches the given file."""
53
+
54
+ def extract(
55
+ self,
56
+ file: FileMemo, # noqa: ARG002
57
+ *,
58
+ existing_entries: Sequence[Directive] | None = None, # noqa: ARG002
59
+ ) -> list[Directive] | None: # pragma: no cover
60
+ """Extract transactions from a file."""
61
+ return None
62
+
63
+ def file_account(self, file: FileMemo) -> str:
64
+ """Return an account associated with the given file."""
65
+
66
+ def file_name(self, file: FileMemo) -> str | None: # noqa: ARG002
67
+ """A filter that optionally renames a file before filing."""
68
+ return None
69
+
70
+ def file_date(
71
+ self,
72
+ file: FileMemo, # noqa: ARG002
73
+ ) -> datetime.date | None:
74
+ """Attempt to obtain a date that corresponds to the given file."""
75
+ return None
rustfava/beans/load.py ADDED
@@ -0,0 +1,31 @@
1
+ """Load Beancount files and strings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from rustfava.rustledger.loader import load_string as rl_load_string
8
+ from rustfava.rustledger.loader import load_uncached as rl_load_uncached
9
+
10
+ if TYPE_CHECKING: # pragma: no cover
11
+ from rustfava.beans.types import LoaderResult
12
+
13
+
14
+ def load_string(value: str) -> LoaderResult:
15
+ """Load a Beancount string."""
16
+ return rl_load_string(value)
17
+
18
+
19
+ def load_uncached(
20
+ beancount_file_path: str,
21
+ *,
22
+ is_encrypted: bool,
23
+ ) -> LoaderResult:
24
+ """Load a Beancount file."""
25
+ # Encrypted files use beancount (rustledger doesn't support GPG decryption)
26
+ if is_encrypted: # pragma: no cover
27
+ from beancount import loader
28
+
29
+ return loader.load_file(beancount_file_path) # type: ignore[return-value]
30
+
31
+ return rl_load_uncached(beancount_file_path, is_encrypted=is_encrypted)