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