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,485 @@
1
+ """Rustledger WASM engine using wasmtime CLI.
2
+
3
+ This module provides a Python interface to rustledger-wasi via the wasmtime
4
+ CLI. The WASM module uses stdin/stdout for I/O with JSON serialization.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import shutil
11
+ import subprocess
12
+ import urllib.request
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from typing import Any
18
+
19
+
20
+ # Supported API version prefix
21
+ SUPPORTED_API_VERSION = "1."
22
+
23
+ # Rustledger release to download
24
+ RUSTLEDGER_VERSION = "v0.7.0"
25
+ RUSTLEDGER_WASM_URL = (
26
+ f"https://github.com/rustledger/rustledger/releases/download/"
27
+ f"{RUSTLEDGER_VERSION}/rustledger-ffi-wasi-{RUSTLEDGER_VERSION}.wasm"
28
+ )
29
+
30
+
31
+ class RustledgerError(Exception):
32
+ """Error from rustledger execution."""
33
+
34
+
35
+ class RustledgerAPIVersionError(RustledgerError):
36
+ """Incompatible API version from rustledger."""
37
+
38
+
39
+ class RustledgerEngine:
40
+ """Interface to rustledger WASM module via wasmtime CLI.
41
+
42
+ The rustledger-wasi module is a CLI that reads from stdin and writes
43
+ JSON to stdout. Commands:
44
+ - load [filename]: Parse source → entries + errors + options
45
+ - query <bql>: Execute BQL → columns + rows + errors
46
+ - validate: Parse + validate → valid + errors
47
+ - version: → version string
48
+ """
49
+
50
+ _instance: RustledgerEngine | None = None
51
+
52
+ def __init__(self, wasm_path: Path | None = None) -> None:
53
+ """Initialize the rustledger engine.
54
+
55
+ Args:
56
+ wasm_path: Path to rustledger-wasi.wasm. If None, uses default path.
57
+ """
58
+ if wasm_path is None:
59
+ wasm_path = Path(__file__).parent / "rustledger-wasi.wasm"
60
+
61
+ if not wasm_path.exists():
62
+ self._download_wasm(wasm_path)
63
+
64
+ self._wasm_path = wasm_path
65
+
66
+ # Find wasmtime binary
67
+ self._wasmtime = shutil.which("wasmtime")
68
+ if self._wasmtime is None:
69
+ msg = "wasmtime not found in PATH. Install with: cargo install wasmtime-cli"
70
+ raise RuntimeError(msg)
71
+
72
+ @staticmethod
73
+ def _download_wasm(wasm_path: Path) -> None:
74
+ """Download the rustledger WASM module."""
75
+ import sys
76
+
77
+ print( # noqa: T201
78
+ f"Downloading rustledger WASM ({RUSTLEDGER_VERSION})...",
79
+ file=sys.stderr,
80
+ )
81
+ try:
82
+ wasm_path.parent.mkdir(parents=True, exist_ok=True)
83
+ urllib.request.urlretrieve(RUSTLEDGER_WASM_URL, wasm_path) # noqa: S310
84
+ print("Done.", file=sys.stderr) # noqa: T201
85
+ except Exception as e:
86
+ msg = f"Failed to download rustledger WASM: {e}"
87
+ raise RuntimeError(msg) from e
88
+
89
+ @classmethod
90
+ def get_instance(cls, wasm_path: Path | None = None) -> RustledgerEngine:
91
+ """Get singleton engine instance."""
92
+ if cls._instance is None:
93
+ cls._instance = cls(wasm_path)
94
+ return cls._instance
95
+
96
+ def _run(
97
+ self,
98
+ args: list[str],
99
+ stdin_data: str | None = None,
100
+ *,
101
+ allow_dir: str | None = None,
102
+ ) -> str:
103
+ """Run rustledger-wasi with given arguments.
104
+
105
+ Args:
106
+ args: Command arguments (e.g., ["load"], ["query", "SELECT ..."])
107
+ stdin_data: Data to pass via stdin
108
+ allow_dir: Directory to allow WASM access to (for file operations)
109
+
110
+ Returns:
111
+ stdout output as string
112
+
113
+ Raises:
114
+ RustledgerError: If the command fails
115
+
116
+ Exit codes:
117
+ 0 = Success (stdout has valid JSON)
118
+ 1 = User error (stderr has error message, not JSON)
119
+ 2 = Internal error (serialization failures)
120
+ """
121
+ # Assert wasmtime is set (checked in __init__)
122
+ assert self._wasmtime is not None
123
+ cmd: list[str] = [
124
+ self._wasmtime,
125
+ "run",
126
+ ]
127
+ if allow_dir:
128
+ cmd.extend(["--dir", allow_dir])
129
+ cmd.extend([
130
+ str(self._wasm_path),
131
+ *args,
132
+ ])
133
+
134
+ try:
135
+ result = subprocess.run(
136
+ cmd,
137
+ input=stdin_data,
138
+ capture_output=True,
139
+ text=True,
140
+ timeout=60, # 60 second timeout
141
+ check=False,
142
+ )
143
+ except subprocess.TimeoutExpired as e:
144
+ msg = f"Rustledger timed out: {e}"
145
+ raise RustledgerError(msg) from e
146
+ except OSError as e:
147
+ msg = f"Failed to run wasmtime: {e}"
148
+ raise RustledgerError(msg) from e
149
+
150
+ # Handle exit codes per rustledger FFI spec
151
+ if result.returncode == 1:
152
+ # User error - stderr has the error message (not JSON)
153
+ error_msg = result.stderr.strip() or "Unknown user error"
154
+ raise RustledgerError(error_msg)
155
+ if result.returncode == 2:
156
+ # Internal error
157
+ error_msg = result.stderr.strip() or "Internal rustledger error"
158
+ raise RustledgerError(f"Internal error: {error_msg}")
159
+ if result.returncode != 0:
160
+ # Other non-zero exit code
161
+ error_msg = result.stderr.strip() or f"Exit code {result.returncode}"
162
+ raise RustledgerError(error_msg)
163
+
164
+ return str(result.stdout)
165
+
166
+ def _parse_response(self, json_str: str) -> dict[str, Any]:
167
+ """Parse JSON response and check API version.
168
+
169
+ Args:
170
+ json_str: JSON string from rustledger
171
+
172
+ Returns:
173
+ Parsed JSON dict
174
+
175
+ Raises:
176
+ RustledgerAPIVersionError: If API version is incompatible
177
+ """
178
+ data = json.loads(json_str)
179
+ api_version = data.get("api_version")
180
+
181
+ # Accept responses without api_version (legacy/pre-1.0 format)
182
+ # Once rustledger 1.0 is deployed, we can make this stricter
183
+ if api_version is not None and not api_version.startswith(
184
+ SUPPORTED_API_VERSION
185
+ ):
186
+ msg = (
187
+ f"Incompatible rustledger API version: {api_version}. "
188
+ f"Expected {SUPPORTED_API_VERSION}x"
189
+ )
190
+ raise RustledgerAPIVersionError(msg)
191
+ return dict(data)
192
+
193
+ def load(self, source: str, filename: str = "<stdin>") -> dict[str, Any]:
194
+ """Load/parse beancount source and return entries, errors, options.
195
+
196
+ Args:
197
+ source: Beancount source code
198
+ filename: Filename to use in metadata (for error reporting)
199
+
200
+ Returns:
201
+ Dict with keys: api_version, entries, errors, options
202
+ """
203
+ # Pass filename as argument if provided
204
+ args = ["load"]
205
+ if filename != "<stdin>":
206
+ args.append(filename)
207
+
208
+ result_json = self._run(args, stdin_data=source)
209
+ return self._parse_response(result_json)
210
+
211
+ def query(self, source: str, query_string: str) -> dict[str, Any]:
212
+ """Run a BQL query against beancount source.
213
+
214
+ Args:
215
+ source: Beancount source code
216
+ query_string: BQL query
217
+
218
+ Returns:
219
+ Dict with keys: api_version, columns, rows, errors
220
+ """
221
+ result_json = self._run(["query", query_string], stdin_data=source)
222
+ return self._parse_response(result_json)
223
+
224
+ def validate(self, source: str) -> dict[str, Any]:
225
+ """Validate beancount source.
226
+
227
+ Args:
228
+ source: Beancount source code
229
+
230
+ Returns:
231
+ Dict with keys: api_version, valid, errors
232
+ """
233
+ result_json = self._run(["validate"], stdin_data=source)
234
+ return self._parse_response(result_json)
235
+
236
+ def version(self) -> str:
237
+ """Get rustledger version string."""
238
+ result_json = self._run(["version"])
239
+ data = self._parse_response(result_json)
240
+ return str(data.get("version", "unknown"))
241
+
242
+ def format_entries(self, source: str) -> str:
243
+ """Format beancount source to canonical form.
244
+
245
+ Args:
246
+ source: Beancount source code
247
+
248
+ Returns:
249
+ Formatted beancount source
250
+ """
251
+ result_json = self._run(["format"], stdin_data=source)
252
+ data = self._parse_response(result_json)
253
+ return str(data.get("formatted", ""))
254
+
255
+ def is_encrypted(self, filepath: str) -> bool:
256
+ """Check if a file is GPG encrypted.
257
+
258
+ Args:
259
+ filepath: Path to file
260
+
261
+ Returns:
262
+ True if file is encrypted
263
+ """
264
+ # Resolve to absolute path for WASM file access
265
+ file_path = Path(filepath).resolve()
266
+ allow_dir = str(file_path.parent)
267
+ result_json = self._run(
268
+ ["is-encrypted", str(file_path)],
269
+ allow_dir=allow_dir,
270
+ )
271
+ data = self._parse_response(result_json)
272
+ return bool(data.get("encrypted", False))
273
+
274
+ def get_account_type(self, account: str) -> str:
275
+ """Get the type of an account (Assets, Liabilities, etc).
276
+
277
+ Args:
278
+ account: Account name
279
+
280
+ Returns:
281
+ Account type string
282
+ """
283
+ result_json = self._run(["get-account-type", account])
284
+ data = self._parse_response(result_json)
285
+ return str(data.get("account_type", ""))
286
+
287
+ def clamp(
288
+ self,
289
+ source: str,
290
+ begin_date: str,
291
+ end_date: str,
292
+ ) -> dict[str, Any]:
293
+ """Filter entries to date range with opening balances.
294
+
295
+ Args:
296
+ source: Beancount source code
297
+ begin_date: Start date (ISO format)
298
+ end_date: End date (ISO format)
299
+
300
+ Returns:
301
+ Dict with keys: api_version, entries, errors
302
+ """
303
+ result_json = self._run(
304
+ ["clamp", begin_date, end_date],
305
+ stdin_data=source,
306
+ )
307
+ return self._parse_response(result_json)
308
+
309
+ def clamp_entries(
310
+ self,
311
+ entries_json: list[dict[str, Any]],
312
+ begin_date: str,
313
+ end_date: str,
314
+ ) -> dict[str, Any]:
315
+ """Filter entries to date range with opening balances.
316
+
317
+ Unlike clamp(), this operates on already-parsed entries JSON,
318
+ avoiding the need to re-parse source code.
319
+
320
+ Args:
321
+ entries_json: List of entry dicts (same format as load output)
322
+ begin_date: Start date (ISO format)
323
+ end_date: End date (ISO format)
324
+
325
+ Returns:
326
+ Dict with keys: api_version, entries, errors
327
+ """
328
+ input_data = json.dumps({
329
+ "entries": entries_json,
330
+ "begin_date": begin_date,
331
+ "end_date": end_date,
332
+ })
333
+ result_json = self._run(
334
+ ["clamp-entries"],
335
+ stdin_data=input_data,
336
+ )
337
+ return self._parse_response(result_json)
338
+
339
+ def types(self) -> dict[str, Any]:
340
+ """Get type constants (MISSING, Booking, ALL_DIRECTIVES).
341
+
342
+ Returns:
343
+ Dict with keys: api_version, all_directives, booking_methods, ...
344
+ """
345
+ result_json = self._run(["types"])
346
+ return self._parse_response(result_json)
347
+
348
+ def format_entry(self, entry_json: dict[str, Any]) -> str:
349
+ """Format a single entry to beancount string.
350
+
351
+ Args:
352
+ entry_json: Entry as JSON dict (same format as load output)
353
+
354
+ Returns:
355
+ Formatted beancount string
356
+ """
357
+ result_json = self._run(
358
+ ["format-entry"],
359
+ stdin_data=json.dumps(entry_json),
360
+ )
361
+ data = self._parse_response(result_json)
362
+ return str(data.get("formatted", ""))
363
+
364
+ def format_entries_json(self, entries_json: list[dict[str, Any]]) -> str:
365
+ """Format multiple entries to beancount string.
366
+
367
+ Args:
368
+ entries_json: List of entries as JSON dicts
369
+
370
+ Returns:
371
+ Formatted beancount string (concatenated)
372
+ """
373
+ result_json = self._run(
374
+ ["format-entries"],
375
+ stdin_data=json.dumps(entries_json),
376
+ )
377
+ data = self._parse_response(result_json)
378
+ return str(data.get("formatted", ""))
379
+
380
+ def create_entry(self, entry_json: dict[str, Any]) -> dict[str, Any]:
381
+ """Create an entry with hash from JSON.
382
+
383
+ Args:
384
+ entry_json: Entry specification (type, date, postings, etc.)
385
+
386
+ Returns:
387
+ Complete entry dict with meta and hash
388
+ """
389
+ result_json = self._run(
390
+ ["create-entry"],
391
+ stdin_data=json.dumps(entry_json),
392
+ )
393
+ data = self._parse_response(result_json)
394
+ return dict(data.get("entry", {}))
395
+
396
+ def create_entries(
397
+ self, entries_json: list[dict[str, Any]]
398
+ ) -> list[dict[str, Any]]:
399
+ """Create multiple entries with hashes from JSON.
400
+
401
+ Args:
402
+ entries_json: List of entry specifications
403
+
404
+ Returns:
405
+ List of complete entry dicts with meta and hashes
406
+ """
407
+ result_json = self._run(
408
+ ["create-entries"],
409
+ stdin_data=json.dumps(entries_json),
410
+ )
411
+ data = self._parse_response(result_json)
412
+ return list(data.get("entries", []))
413
+
414
+ def load_full(
415
+ self,
416
+ filepath: str,
417
+ plugins: list[str] | None = None,
418
+ ) -> dict[str, Any]:
419
+ """Load a beancount file with full processing.
420
+
421
+ This uses rustledger-loader for:
422
+ - Include resolution with cycle detection
423
+ - Path security (prevents path traversal)
424
+ - GPG decryption for encrypted files
425
+ - Native plugin execution
426
+
427
+ Args:
428
+ filepath: Path to the main beancount file
429
+ plugins: Optional list of plugin names to run (e.g., ["auto_accounts"])
430
+
431
+ Returns:
432
+ Dict with keys:
433
+ - api_version: API version string
434
+ - entries: List of directive dicts (sorted, with hashes)
435
+ - errors: List of error dicts
436
+ - options: Options dict
437
+ - plugins: List of plugin directives from file
438
+ - loaded_files: List of all resolved include files
439
+ """
440
+ file_path = Path(filepath).resolve()
441
+ allow_dir = str(file_path.parent)
442
+
443
+ # Build command args
444
+ args = ["load-full", str(file_path)]
445
+ if plugins:
446
+ args.extend(plugins)
447
+
448
+ result_json = self._run(args, allow_dir=allow_dir)
449
+ return self._parse_response(result_json)
450
+
451
+ def filter_entries(
452
+ self,
453
+ entries_json: list[dict[str, Any]],
454
+ begin_date: str,
455
+ end_date: str,
456
+ ) -> dict[str, Any]:
457
+ """Filter entries by date range.
458
+
459
+ This filters already-parsed entries without re-parsing source.
460
+ Useful for Fava's date navigation.
461
+
462
+ Filtering rules (matching beancount behavior):
463
+ - Open: Include if date < end_date (still active)
464
+ - Close: Include if date >= begin_date
465
+ - Commodity: Always exclude
466
+ - All others: Include if begin_date <= date < end_date
467
+
468
+ Args:
469
+ entries_json: List of entry dicts (same format as load output)
470
+ begin_date: Start date (ISO format, inclusive)
471
+ end_date: End date (ISO format, exclusive)
472
+
473
+ Returns:
474
+ Dict with keys: api_version, entries, errors
475
+ """
476
+ input_data = {
477
+ "entries": entries_json,
478
+ "begin_date": begin_date,
479
+ "end_date": end_date,
480
+ }
481
+ result_json = self._run(
482
+ ["filter-entries"],
483
+ stdin_data=json.dumps(input_data),
484
+ )
485
+ return self._parse_response(result_json)