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,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]
|