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.
- rustfava/__init__.py +30 -0
- rustfava/_ctx_globals_class.py +55 -0
- rustfava/api_models.py +36 -0
- rustfava/application.py +534 -0
- rustfava/beans/__init__.py +6 -0
- rustfava/beans/abc.py +327 -0
- rustfava/beans/account.py +79 -0
- rustfava/beans/create.py +377 -0
- rustfava/beans/flags.py +20 -0
- rustfava/beans/funcs.py +38 -0
- rustfava/beans/helpers.py +52 -0
- rustfava/beans/ingest.py +75 -0
- rustfava/beans/load.py +31 -0
- rustfava/beans/prices.py +151 -0
- rustfava/beans/protocols.py +82 -0
- rustfava/beans/str.py +454 -0
- rustfava/beans/types.py +63 -0
- rustfava/cli.py +187 -0
- rustfava/context.py +13 -0
- rustfava/core/__init__.py +729 -0
- rustfava/core/accounts.py +161 -0
- rustfava/core/attributes.py +145 -0
- rustfava/core/budgets.py +207 -0
- rustfava/core/charts.py +301 -0
- rustfava/core/commodities.py +37 -0
- rustfava/core/conversion.py +229 -0
- rustfava/core/documents.py +87 -0
- rustfava/core/extensions.py +132 -0
- rustfava/core/fava_options.py +255 -0
- rustfava/core/file.py +542 -0
- rustfava/core/filters.py +484 -0
- rustfava/core/group_entries.py +97 -0
- rustfava/core/ingest.py +509 -0
- rustfava/core/inventory.py +167 -0
- rustfava/core/misc.py +105 -0
- rustfava/core/module_base.py +18 -0
- rustfava/core/number.py +106 -0
- rustfava/core/query.py +180 -0
- rustfava/core/query_shell.py +301 -0
- rustfava/core/tree.py +265 -0
- rustfava/core/watcher.py +219 -0
- rustfava/ext/__init__.py +232 -0
- rustfava/ext/auto_commit.py +61 -0
- rustfava/ext/portfolio_list/PortfolioList.js +34 -0
- rustfava/ext/portfolio_list/__init__.py +29 -0
- rustfava/ext/portfolio_list/templates/PortfolioList.html +15 -0
- rustfava/ext/rustfava_ext_test/RustfavaExtTest.js +42 -0
- rustfava/ext/rustfava_ext_test/__init__.py +207 -0
- rustfava/ext/rustfava_ext_test/templates/RustfavaExtTest.html +45 -0
- rustfava/ext/rustfava_ext_test/templates/RustfavaExtTestInclude.html +1 -0
- rustfava/help/__init__.py +15 -0
- rustfava/help/_index.md +29 -0
- rustfava/help/beancount_syntax.md +156 -0
- rustfava/help/budgets.md +31 -0
- rustfava/help/conversion.md +29 -0
- rustfava/help/extensions.md +111 -0
- rustfava/help/features.md +179 -0
- rustfava/help/filters.md +103 -0
- rustfava/help/import.md +27 -0
- rustfava/help/options.md +289 -0
- rustfava/helpers.py +30 -0
- rustfava/internal_api.py +221 -0
- rustfava/json_api.py +952 -0
- rustfava/plugins/__init__.py +3 -0
- rustfava/plugins/link_documents.py +107 -0
- rustfava/plugins/tag_discovered_documents.py +44 -0
- rustfava/py.typed +0 -0
- rustfava/rustledger/__init__.py +31 -0
- rustfava/rustledger/constants.py +76 -0
- rustfava/rustledger/engine.py +485 -0
- rustfava/rustledger/loader.py +273 -0
- rustfava/rustledger/options.py +202 -0
- rustfava/rustledger/query.py +331 -0
- rustfava/rustledger/types.py +830 -0
- rustfava/serialisation.py +220 -0
- rustfava/static/app.css +2988 -0
- rustfava/static/app.css.map +7 -0
- rustfava/static/app.js +12854 -0
- rustfava/static/app.js.map +7 -0
- rustfava/static/beancount-JFV44ZVZ.css +5 -0
- rustfava/static/beancount-JFV44ZVZ.css.map +7 -0
- rustfava/static/beancount-VTTKRGSK.js +4642 -0
- rustfava/static/beancount-VTTKRGSK.js.map +7 -0
- rustfava/static/bql-MGFRUMBP.js +333 -0
- rustfava/static/bql-MGFRUMBP.js.map +7 -0
- rustfava/static/chunk-E7ZF4ASL.js +23061 -0
- rustfava/static/chunk-E7ZF4ASL.js.map +7 -0
- rustfava/static/chunk-V24TLQHT.js +12673 -0
- rustfava/static/chunk-V24TLQHT.js.map +7 -0
- rustfava/static/favicon.ico +0 -0
- rustfava/static/fira-mono-cyrillic-400-normal-BLAGXRCE.woff2 +0 -0
- rustfava/static/fira-mono-cyrillic-500-normal-EN7JUAAW.woff2 +0 -0
- rustfava/static/fira-mono-cyrillic-ext-400-normal-EX7VARTS.woff2 +0 -0
- rustfava/static/fira-mono-cyrillic-ext-500-normal-ZDPTUPRR.woff2 +0 -0
- rustfava/static/fira-mono-greek-400-normal-COGHKMOA.woff2 +0 -0
- rustfava/static/fira-mono-greek-500-normal-4EN2PKZT.woff2 +0 -0
- rustfava/static/fira-mono-greek-ext-400-normal-DYEQIJH7.woff2 +0 -0
- rustfava/static/fira-mono-greek-ext-500-normal-SG73CVKQ.woff2 +0 -0
- rustfava/static/fira-mono-latin-400-normal-NA3VLV7E.woff2 +0 -0
- rustfava/static/fira-mono-latin-500-normal-YC77GFWD.woff2 +0 -0
- rustfava/static/fira-mono-latin-ext-400-normal-DIKTZ5PW.woff2 +0 -0
- rustfava/static/fira-mono-latin-ext-500-normal-ZWY4UO4V.woff2 +0 -0
- rustfava/static/fira-mono-symbols2-400-normal-UITXT77Q.woff2 +0 -0
- rustfava/static/fira-mono-symbols2-500-normal-VWPC2EFN.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-400-normal-KLQMBCA6.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-500-normal-NFG7UD6J.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-ext-400-normal-GWO44OPC.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-ext-500-normal-SP47E5SC.woff2 +0 -0
- rustfava/static/fira-sans-greek-400-normal-UMQBTLC3.woff2 +0 -0
- rustfava/static/fira-sans-greek-500-normal-4ZKHN4FQ.woff2 +0 -0
- rustfava/static/fira-sans-greek-ext-400-normal-O2DVJAJZ.woff2 +0 -0
- rustfava/static/fira-sans-greek-ext-500-normal-SK6GNWGO.woff2 +0 -0
- rustfava/static/fira-sans-latin-400-normal-OYYTPMAV.woff2 +0 -0
- rustfava/static/fira-sans-latin-500-normal-SMQPZW5A.woff2 +0 -0
- rustfava/static/fira-sans-latin-ext-400-normal-OAUP3WK5.woff2 +0 -0
- rustfava/static/fira-sans-latin-ext-500-normal-LY3YDR5Y.woff2 +0 -0
- rustfava/static/fira-sans-vietnamese-400-normal-OBMQ72MR.woff2 +0 -0
- rustfava/static/fira-sans-vietnamese-500-normal-Y4NZR5EU.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-400-normal-TO22V6M3.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-500-normal-OGBWWWYW.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-ext-400-normal-XH44UCIA.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-ext-500-normal-3Z6MMVM6.woff2 +0 -0
- rustfava/static/source-code-pro-greek-400-normal-OUXXUQWK.woff2 +0 -0
- rustfava/static/source-code-pro-greek-500-normal-JA2Z5UXO.woff2 +0 -0
- rustfava/static/source-code-pro-greek-ext-400-normal-WCDKMX7U.woff2 +0 -0
- rustfava/static/source-code-pro-greek-ext-500-normal-ZHVI4VKW.woff2 +0 -0
- rustfava/static/source-code-pro-latin-400-normal-QOGTXED5.woff2 +0 -0
- rustfava/static/source-code-pro-latin-500-normal-X57QEOLQ.woff2 +0 -0
- rustfava/static/source-code-pro-latin-ext-400-normal-QXC74NBF.woff2 +0 -0
- rustfava/static/source-code-pro-latin-ext-500-normal-QGOY7MTT.woff2 +0 -0
- rustfava/static/source-code-pro-vietnamese-400-normal-NPDCDTBA.woff2 +0 -0
- rustfava/static/source-code-pro-vietnamese-500-normal-M6PJKTR5.woff2 +0 -0
- rustfava/static/tree-sitter-beancount-MLXFQBZ5.wasm +0 -0
- rustfava/static/web-tree-sitter-RNOQ6E74.wasm +0 -0
- rustfava/template_filters.py +64 -0
- rustfava/templates/_journal_table.html +156 -0
- rustfava/templates/_layout.html +26 -0
- rustfava/templates/_query_table.html +88 -0
- rustfava/templates/beancount_file +18 -0
- rustfava/templates/help.html +23 -0
- rustfava/templates/macros/_account_macros.html +5 -0
- rustfava/templates/macros/_commodity_macros.html +13 -0
- rustfava/translations/bg/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/bg/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/ca/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/ca/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/de/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/de/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/es/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/es/LC_MESSAGES/messages.po +619 -0
- rustfava/translations/fa/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/fa/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/fr/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/fr/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/ja/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/ja/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/nl/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/nl/LC_MESSAGES/messages.po +617 -0
- rustfava/translations/pt/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/pt/LC_MESSAGES/messages.po +617 -0
- rustfava/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/pt_BR/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/ru/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/ru/LC_MESSAGES/messages.po +617 -0
- rustfava/translations/sk/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/sk/LC_MESSAGES/messages.po +623 -0
- rustfava/translations/sv/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/sv/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/uk/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/uk/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/zh/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/zh/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.po +618 -0
- rustfava/util/__init__.py +157 -0
- rustfava/util/date.py +576 -0
- rustfava/util/excel.py +118 -0
- rustfava/util/ranking.py +79 -0
- rustfava/util/sets.py +18 -0
- rustfava/util/unreachable.py +20 -0
- rustfava-0.1.0.dist-info/METADATA +102 -0
- rustfava-0.1.0.dist-info/RECORD +187 -0
- rustfava-0.1.0.dist-info/WHEEL +5 -0
- rustfava-0.1.0.dist-info/entry_points.txt +2 -0
- rustfava-0.1.0.dist-info/licenses/AUTHORS +11 -0
- rustfava-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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)
|