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/prices.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Price helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
from bisect import bisect
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from typing import TypeAlias
|
|
16
|
+
|
|
17
|
+
from rustfava.beans.abc import Price
|
|
18
|
+
|
|
19
|
+
BaseQuote: TypeAlias = tuple[str, str]
|
|
20
|
+
PricePoint: TypeAlias = tuple[datetime.date, Decimal]
|
|
21
|
+
|
|
22
|
+
ZERO = Decimal()
|
|
23
|
+
ONE = Decimal(1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DateKeyWrapper(list[datetime.date]):
|
|
27
|
+
"""A class wrapping a list of prices for bisect.
|
|
28
|
+
|
|
29
|
+
This is needed before Python 3.10, which adds the key argument.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
__slots__ = ("inner",)
|
|
33
|
+
|
|
34
|
+
def __init__(self, inner: list[PricePoint]) -> None:
|
|
35
|
+
self.inner = inner
|
|
36
|
+
|
|
37
|
+
def __len__(self) -> int:
|
|
38
|
+
return len(self.inner)
|
|
39
|
+
|
|
40
|
+
def __getitem__(self, k: int) -> datetime.date: # type: ignore[override]
|
|
41
|
+
return self.inner[k][0]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _keep_last_per_day(
|
|
45
|
+
prices: Sequence[PricePoint],
|
|
46
|
+
) -> Iterable[PricePoint]:
|
|
47
|
+
"""In a sorted non-empty list of prices, keep the last one for each day.
|
|
48
|
+
|
|
49
|
+
Yields:
|
|
50
|
+
The filtered prices.
|
|
51
|
+
"""
|
|
52
|
+
prices_iter = iter(prices)
|
|
53
|
+
last = next(prices_iter)
|
|
54
|
+
for price in prices_iter:
|
|
55
|
+
if price[0] > last[0]:
|
|
56
|
+
yield last
|
|
57
|
+
last = price
|
|
58
|
+
yield last
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RustfavaPriceMap:
|
|
62
|
+
"""A Fava alternative to Beancount's PriceMap.
|
|
63
|
+
|
|
64
|
+
By having some more methods on this class, fewer helper functions need to
|
|
65
|
+
be imported. Also, this is fully typed and allows to more easily reproduce
|
|
66
|
+
issues with the whole price logic.
|
|
67
|
+
|
|
68
|
+
This behaves slightly differently than Beancount. Beancount creates a list
|
|
69
|
+
for each currency pair and then merges the inverse rates. We just create
|
|
70
|
+
both the lists in tandem and count the directions that prices occur in.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
price_entries: A sorted list of price entries.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, price_entries: Iterable[Price]) -> None:
|
|
77
|
+
raw_map: dict[BaseQuote, list[PricePoint]] = defaultdict(list)
|
|
78
|
+
counts: Counter[BaseQuote] = Counter()
|
|
79
|
+
|
|
80
|
+
for price in price_entries:
|
|
81
|
+
rate = price.amount.number
|
|
82
|
+
base_quote = (price.currency, price.amount.currency)
|
|
83
|
+
raw_map[base_quote].append((price.date, rate))
|
|
84
|
+
counts[base_quote] += 1
|
|
85
|
+
if rate != ZERO:
|
|
86
|
+
raw_map[price.amount.currency, price.currency].append(
|
|
87
|
+
(price.date, ONE / rate),
|
|
88
|
+
)
|
|
89
|
+
self._forward_pairs = [
|
|
90
|
+
(base, quote)
|
|
91
|
+
for (base, quote), count in counts.items()
|
|
92
|
+
if counts.get((quote, base), 0) < count
|
|
93
|
+
]
|
|
94
|
+
self._map = {
|
|
95
|
+
k: list(_keep_last_per_day(rates)) for k, rates in raw_map.items()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def commodity_pairs(
|
|
99
|
+
self,
|
|
100
|
+
operating_currencies: Sequence[str],
|
|
101
|
+
) -> list[BaseQuote]:
|
|
102
|
+
"""List pairs of commodities.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
operating_currencies: A list of operating currencies.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A list of pairs of commodities. Pairs of operating currencies will
|
|
109
|
+
be given in both directions not just in the one most commonly found
|
|
110
|
+
in the file.
|
|
111
|
+
"""
|
|
112
|
+
forward_pairs = self._forward_pairs
|
|
113
|
+
extra_operating_pairs = []
|
|
114
|
+
for base, quote in forward_pairs:
|
|
115
|
+
if base in operating_currencies and quote in operating_currencies:
|
|
116
|
+
extra_operating_pairs.append((quote, base))
|
|
117
|
+
return sorted(forward_pairs + extra_operating_pairs)
|
|
118
|
+
|
|
119
|
+
def get_all_prices(self, base_quote: BaseQuote) -> list[PricePoint] | None:
|
|
120
|
+
"""Get all prices for the given currency pair."""
|
|
121
|
+
return self._map.get(base_quote)
|
|
122
|
+
|
|
123
|
+
def get_price(
|
|
124
|
+
self,
|
|
125
|
+
base_quote: BaseQuote,
|
|
126
|
+
date: datetime.date | None = None,
|
|
127
|
+
) -> Decimal | None:
|
|
128
|
+
"""Get the price for the given currency pair."""
|
|
129
|
+
return self.get_price_point(base_quote, date)[1]
|
|
130
|
+
|
|
131
|
+
def get_price_point(
|
|
132
|
+
self,
|
|
133
|
+
base_quote: BaseQuote,
|
|
134
|
+
date: datetime.date | None = None,
|
|
135
|
+
) -> PricePoint | tuple[None, Decimal] | tuple[None, None]:
|
|
136
|
+
"""Get the price point for the given currency pair."""
|
|
137
|
+
base, quote = base_quote
|
|
138
|
+
if base == quote:
|
|
139
|
+
return (None, ONE)
|
|
140
|
+
|
|
141
|
+
price_list = self._map.get(base_quote)
|
|
142
|
+
if price_list is None:
|
|
143
|
+
return (None, None)
|
|
144
|
+
|
|
145
|
+
if date is None:
|
|
146
|
+
return price_list[-1]
|
|
147
|
+
|
|
148
|
+
index = bisect(DateKeyWrapper(price_list), date)
|
|
149
|
+
if index == 0:
|
|
150
|
+
return (None, None)
|
|
151
|
+
return price_list[index - 1]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Abstract base classes for Beancount types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
9
|
+
import datetime
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Amount(Protocol):
|
|
14
|
+
"""An amount in some currency."""
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def number(self) -> Decimal:
|
|
18
|
+
"""Number of units in the amount."""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def currency(self) -> str:
|
|
22
|
+
"""Currency of the amount."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Cost(Protocol):
|
|
26
|
+
"""A cost (basically an amount with date and label)."""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def number(self) -> Decimal:
|
|
30
|
+
"""Number of units in the cost."""
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def currency(self) -> str:
|
|
34
|
+
"""Currency of the cost."""
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def date(self) -> datetime.date:
|
|
38
|
+
"""Date of the cost."""
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def label(self) -> str | None:
|
|
42
|
+
"""Label of the cost."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CostSpec(Protocol):
|
|
46
|
+
"""A cost specification (uses number_per/number_total instead of number)."""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def number_per(self) -> Decimal | None:
|
|
50
|
+
"""Per-unit cost."""
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def number_total(self) -> Decimal | None:
|
|
54
|
+
"""Total cost."""
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def currency(self) -> str | None:
|
|
58
|
+
"""Currency of the cost."""
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def date(self) -> datetime.date | None:
|
|
62
|
+
"""Date of the cost."""
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def label(self) -> str | None:
|
|
66
|
+
"""Label of the cost."""
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def merge(self) -> bool | None:
|
|
70
|
+
"""Whether to merge lots."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Position(Protocol):
|
|
74
|
+
"""A Beancount position - just cost and units."""
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def units(self) -> Amount:
|
|
78
|
+
"""Units of the posting."""
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def cost(self) -> Cost | None:
|
|
82
|
+
"""Units of the position."""
|
rustfava/beans/str.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Convert Beancount types to string."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from functools import singledispatch
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from rustfava.beans.abc import Balance
|
|
13
|
+
from rustfava.beans.abc import Close
|
|
14
|
+
from rustfava.beans.abc import Commodity
|
|
15
|
+
from rustfava.beans.abc import Custom
|
|
16
|
+
from rustfava.beans.abc import Directive
|
|
17
|
+
from rustfava.beans.abc import Document
|
|
18
|
+
from rustfava.beans.abc import Event
|
|
19
|
+
from rustfava.beans.abc import Note
|
|
20
|
+
from rustfava.beans.abc import Open
|
|
21
|
+
from rustfava.beans.abc import Pad
|
|
22
|
+
from rustfava.beans.abc import Position
|
|
23
|
+
from rustfava.beans.abc import Posting
|
|
24
|
+
from rustfava.beans.abc import Price
|
|
25
|
+
from rustfava.beans.abc import Query
|
|
26
|
+
from rustfava.beans.abc import Transaction
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Currency alignment regex (moved here from core.misc to avoid circular import)
|
|
30
|
+
CURRENCY_RE = r"[A-Z][A-Z0-9\'\.\_\-]{0,22}[A-Z0-9]"
|
|
31
|
+
ALIGN_RE = re.compile(
|
|
32
|
+
rf'([^";]*?)\s+([-+]?\s*[\d,]+(?:\.\d*)?)\s+({CURRENCY_RE}\b.*)',
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def align(string: str, currency_column: int) -> str:
|
|
37
|
+
"""Align currencies in one column."""
|
|
38
|
+
output = io.StringIO()
|
|
39
|
+
for line in string.splitlines():
|
|
40
|
+
match = ALIGN_RE.match(line)
|
|
41
|
+
if match:
|
|
42
|
+
prefix, number, rest = match.groups()
|
|
43
|
+
num_of_spaces = currency_column - len(prefix) - len(number) - 4
|
|
44
|
+
spaces = " " * num_of_spaces
|
|
45
|
+
output.write(prefix + spaces + " " + number + " " + rest)
|
|
46
|
+
else:
|
|
47
|
+
output.write(line)
|
|
48
|
+
output.write("\n")
|
|
49
|
+
|
|
50
|
+
return output.getvalue()
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
53
|
+
from rustfava.beans import protocols
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@singledispatch
|
|
57
|
+
def to_string(
|
|
58
|
+
obj: protocols.Amount
|
|
59
|
+
| protocols.Cost
|
|
60
|
+
| protocols.CostSpec
|
|
61
|
+
| Directive
|
|
62
|
+
| Position
|
|
63
|
+
| Posting,
|
|
64
|
+
_currency_column: int | None = None,
|
|
65
|
+
_indent: int | None = None,
|
|
66
|
+
) -> str:
|
|
67
|
+
"""Convert to a string."""
|
|
68
|
+
# Check if it's a CostSpec (has number_per attribute)
|
|
69
|
+
if hasattr(obj, "number_per"):
|
|
70
|
+
return costspec_to_string(obj)
|
|
71
|
+
|
|
72
|
+
number = getattr(obj, "number", None)
|
|
73
|
+
currency = getattr(obj, "currency", None)
|
|
74
|
+
if isinstance(number, Decimal) and isinstance(currency, str):
|
|
75
|
+
# The Amount and Cost protocols are ambiguous, so handle this here
|
|
76
|
+
# instead of having this be dispatched - relevant for older Pythons
|
|
77
|
+
if hasattr(obj, "date"):
|
|
78
|
+
return cost_to_string(obj) # type: ignore[arg-type]
|
|
79
|
+
return f"{number} {currency}"
|
|
80
|
+
msg = f"Unsupported object of type {type(obj)}"
|
|
81
|
+
raise TypeError(msg)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def amount_to_string(obj: protocols.Amount) -> str:
|
|
85
|
+
"""Convert an amount to a string."""
|
|
86
|
+
return f"{obj.number} {obj.currency}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cost_to_string(cost: protocols.Cost) -> str:
|
|
90
|
+
"""Convert a cost to a string."""
|
|
91
|
+
parts = [f"{cost.number} {cost.currency}"]
|
|
92
|
+
if cost.date is not None:
|
|
93
|
+
parts.append(cost.date.isoformat())
|
|
94
|
+
if cost.label:
|
|
95
|
+
parts.append(f'"{cost.label}"')
|
|
96
|
+
return ", ".join(parts)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@to_string.register(Position)
|
|
100
|
+
def _position_to_string(obj: Position) -> str:
|
|
101
|
+
units_str = amount_to_string(obj.units)
|
|
102
|
+
if obj.cost is None:
|
|
103
|
+
return units_str
|
|
104
|
+
cost_str = cost_to_string(obj.cost)
|
|
105
|
+
return f"{units_str} {{{cost_str}}}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def costspec_to_string(cost: object) -> str:
|
|
109
|
+
"""Convert a CostSpec to a string.
|
|
110
|
+
|
|
111
|
+
CostSpec has number_per/number_total instead of number, and may use MISSING.
|
|
112
|
+
"""
|
|
113
|
+
# Handle MISSING sentinel - it's a class used as a sentinel value
|
|
114
|
+
def is_missing(val: object) -> bool:
|
|
115
|
+
if val is None:
|
|
116
|
+
return False
|
|
117
|
+
# MISSING is a class used as a sentinel in beancount
|
|
118
|
+
# When used in CostSpec, the actual class is stored, not an instance
|
|
119
|
+
# So we check if val is a class AND its name is MISSING
|
|
120
|
+
if isinstance(val, type) and val.__name__ == "MISSING":
|
|
121
|
+
return True
|
|
122
|
+
# Also handle instances of MISSING-like classes
|
|
123
|
+
if type(val).__name__ == "MISSING":
|
|
124
|
+
return True
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
number_per = getattr(cost, "number_per", None)
|
|
128
|
+
number_total = getattr(cost, "number_total", None)
|
|
129
|
+
currency = getattr(cost, "currency", None)
|
|
130
|
+
date = getattr(cost, "date", None)
|
|
131
|
+
label = getattr(cost, "label", None)
|
|
132
|
+
merge = getattr(cost, "merge", None)
|
|
133
|
+
|
|
134
|
+
# If all values are MISSING, None, or False, return empty
|
|
135
|
+
all_none = (
|
|
136
|
+
(number_per is None or is_missing(number_per))
|
|
137
|
+
and (number_total is None or is_missing(number_total))
|
|
138
|
+
and (currency is None or is_missing(currency))
|
|
139
|
+
and date is None
|
|
140
|
+
and not label
|
|
141
|
+
and not merge
|
|
142
|
+
)
|
|
143
|
+
if all_none:
|
|
144
|
+
return ""
|
|
145
|
+
|
|
146
|
+
parts = []
|
|
147
|
+
|
|
148
|
+
# Build the amount part: "number_per # number_total currency"
|
|
149
|
+
amount_parts = []
|
|
150
|
+
if number_per is not None and not is_missing(number_per):
|
|
151
|
+
amount_parts.append(str(number_per))
|
|
152
|
+
if number_total is not None and not is_missing(number_total):
|
|
153
|
+
amount_parts.extend(["#", str(number_total)])
|
|
154
|
+
if currency is not None and not is_missing(currency) and isinstance(currency, str):
|
|
155
|
+
amount_parts.append(currency)
|
|
156
|
+
if amount_parts:
|
|
157
|
+
parts.append(" ".join(amount_parts))
|
|
158
|
+
|
|
159
|
+
if date is not None:
|
|
160
|
+
parts.append(date.isoformat())
|
|
161
|
+
if label:
|
|
162
|
+
parts.append(f'"{label}"')
|
|
163
|
+
if merge:
|
|
164
|
+
parts.append("*")
|
|
165
|
+
|
|
166
|
+
return ", ".join(parts)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@to_string.register(Posting)
|
|
170
|
+
def _posting_to_string(posting: Posting) -> str:
|
|
171
|
+
"""Convert a posting to a string (units + cost, not price).
|
|
172
|
+
|
|
173
|
+
Note: Price is NOT included here - it's added by serialise(Posting)
|
|
174
|
+
in serialisation.py when needed.
|
|
175
|
+
"""
|
|
176
|
+
if posting.units is None:
|
|
177
|
+
return ""
|
|
178
|
+
units_str = amount_to_string(posting.units)
|
|
179
|
+
if posting.cost is None:
|
|
180
|
+
return units_str
|
|
181
|
+
|
|
182
|
+
# Check if it's a CostSpec (has number_per) or Cost (has number)
|
|
183
|
+
if hasattr(posting.cost, "number_per"):
|
|
184
|
+
cost_str = costspec_to_string(posting.cost)
|
|
185
|
+
else:
|
|
186
|
+
cost_str = cost_to_string(posting.cost)
|
|
187
|
+
|
|
188
|
+
return f"{units_str} {{{cost_str}}}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _format_posting(posting: Posting, indent: int = 2) -> str:
|
|
192
|
+
"""Format a single posting line."""
|
|
193
|
+
prefix = " " * indent
|
|
194
|
+
parts = [prefix, posting.account]
|
|
195
|
+
amount_str = _posting_to_string(posting)
|
|
196
|
+
if amount_str:
|
|
197
|
+
parts.append(" ") # Two spaces before amount
|
|
198
|
+
parts.append(amount_str)
|
|
199
|
+
# Add price if present
|
|
200
|
+
if posting.price is not None:
|
|
201
|
+
parts.append(f" @ {amount_to_string(posting.price)}")
|
|
202
|
+
if posting.flag:
|
|
203
|
+
# Insert flag after indent, before account
|
|
204
|
+
parts[1] = f"{posting.flag} {posting.account}"
|
|
205
|
+
return "".join(parts)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _format_meta(meta: Mapping[str, object], indent: int = 2) -> list[str]:
|
|
209
|
+
"""Format metadata lines (excluding internal keys)."""
|
|
210
|
+
lines = []
|
|
211
|
+
prefix = " " * indent
|
|
212
|
+
for key, value in meta.items():
|
|
213
|
+
if key.startswith("_") or key in ("filename", "lineno", "hash"):
|
|
214
|
+
continue
|
|
215
|
+
if isinstance(value, str):
|
|
216
|
+
lines.append(f'{prefix}{key}: "{value}"')
|
|
217
|
+
else:
|
|
218
|
+
lines.append(f"{prefix}{key}: {value}")
|
|
219
|
+
return lines
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@to_string.register(Transaction)
|
|
223
|
+
def _format_transaction(
|
|
224
|
+
entry: Transaction,
|
|
225
|
+
currency_column: int = 61,
|
|
226
|
+
indent: int = 2,
|
|
227
|
+
) -> str:
|
|
228
|
+
"""Format a transaction entry."""
|
|
229
|
+
# Header line: date flag "payee" "narration" ^links #tags
|
|
230
|
+
parts = [entry.date.isoformat(), entry.flag or "*"]
|
|
231
|
+
if entry.payee:
|
|
232
|
+
parts.append(f'"{entry.payee}"')
|
|
233
|
+
parts.append(f'"{entry.narration}"')
|
|
234
|
+
for link in sorted(entry.links):
|
|
235
|
+
parts.append(f"^{link}")
|
|
236
|
+
for tag in sorted(entry.tags):
|
|
237
|
+
parts.append(f"#{tag}")
|
|
238
|
+
lines = [" ".join(parts)]
|
|
239
|
+
|
|
240
|
+
# Metadata
|
|
241
|
+
meta = dict(entry.meta) if entry.meta else {}
|
|
242
|
+
lines.extend(_format_meta(meta, indent))
|
|
243
|
+
|
|
244
|
+
# Postings
|
|
245
|
+
for posting in entry.postings:
|
|
246
|
+
lines.append(_format_posting(posting, indent))
|
|
247
|
+
# Posting metadata
|
|
248
|
+
if posting.meta:
|
|
249
|
+
posting_meta = dict(posting.meta)
|
|
250
|
+
lines.extend(_format_meta(posting_meta, indent + 2))
|
|
251
|
+
|
|
252
|
+
result = "\n".join(lines)
|
|
253
|
+
return align(result, currency_column)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@to_string.register(Balance)
|
|
257
|
+
def _format_balance(
|
|
258
|
+
entry: Balance,
|
|
259
|
+
currency_column: int = 61,
|
|
260
|
+
indent: int = 2,
|
|
261
|
+
) -> str:
|
|
262
|
+
"""Format a balance entry."""
|
|
263
|
+
amount_str = amount_to_string(entry.amount)
|
|
264
|
+
line = f"{entry.date.isoformat()} balance {entry.account} {amount_str}"
|
|
265
|
+
lines = [line]
|
|
266
|
+
meta = dict(entry.meta) if entry.meta else {}
|
|
267
|
+
lines.extend(_format_meta(meta, indent))
|
|
268
|
+
result = "\n".join(lines)
|
|
269
|
+
return align(result, currency_column)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@to_string.register(Open)
|
|
273
|
+
def _format_open(
|
|
274
|
+
entry: Open,
|
|
275
|
+
currency_column: int = 61,
|
|
276
|
+
indent: int = 2,
|
|
277
|
+
) -> str:
|
|
278
|
+
"""Format an open entry."""
|
|
279
|
+
parts = [entry.date.isoformat(), "open", entry.account]
|
|
280
|
+
if entry.currencies:
|
|
281
|
+
parts.append(", ".join(entry.currencies))
|
|
282
|
+
if entry.booking:
|
|
283
|
+
parts.append(f'"{entry.booking}"')
|
|
284
|
+
lines = [" ".join(parts)]
|
|
285
|
+
meta = dict(entry.meta) if entry.meta else {}
|
|
286
|
+
lines.extend(_format_meta(meta, indent))
|
|
287
|
+
return "\n".join(lines)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@to_string.register(Close)
|
|
291
|
+
def _format_close(
|
|
292
|
+
entry: Close,
|
|
293
|
+
currency_column: int = 61,
|
|
294
|
+
indent: int = 2,
|
|
295
|
+
) -> str:
|
|
296
|
+
"""Format a close entry."""
|
|
297
|
+
lines = [f"{entry.date.isoformat()} close {entry.account}"]
|
|
298
|
+
meta = dict(entry.meta) if entry.meta else {}
|
|
299
|
+
lines.extend(_format_meta(meta, indent))
|
|
300
|
+
return "\n".join(lines)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@to_string.register(Price)
|
|
304
|
+
def _format_price(
|
|
305
|
+
entry: Price,
|
|
306
|
+
currency_column: int = 61,
|
|
307
|
+
indent: int = 2,
|
|
308
|
+
) -> str:
|
|
309
|
+
"""Format a price entry."""
|
|
310
|
+
amount_str = amount_to_string(entry.amount)
|
|
311
|
+
lines = [f"{entry.date.isoformat()} price {entry.currency} {amount_str}"]
|
|
312
|
+
meta = dict(entry.meta) if entry.meta else {}
|
|
313
|
+
lines.extend(_format_meta(meta, indent))
|
|
314
|
+
result = "\n".join(lines)
|
|
315
|
+
return align(result, currency_column)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@to_string.register(Event)
|
|
319
|
+
def _format_event(
|
|
320
|
+
entry: Event,
|
|
321
|
+
currency_column: int = 61,
|
|
322
|
+
indent: int = 2,
|
|
323
|
+
) -> str:
|
|
324
|
+
"""Format an event entry."""
|
|
325
|
+
# Event has 'type' attribute for event type
|
|
326
|
+
event_type = getattr(entry, "type", "")
|
|
327
|
+
description = getattr(entry, "description", "")
|
|
328
|
+
lines = [f'{entry.date.isoformat()} event "{event_type}" "{description}"']
|
|
329
|
+
meta = dict(entry.meta) if entry.meta else {}
|
|
330
|
+
lines.extend(_format_meta(meta, indent))
|
|
331
|
+
return "\n".join(lines)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@to_string.register(Note)
|
|
335
|
+
def _format_note(
|
|
336
|
+
entry: Note,
|
|
337
|
+
currency_column: int = 61,
|
|
338
|
+
indent: int = 2,
|
|
339
|
+
) -> str:
|
|
340
|
+
"""Format a note entry."""
|
|
341
|
+
lines = [f'{entry.date.isoformat()} note {entry.account} "{entry.comment}"']
|
|
342
|
+
meta = dict(entry.meta) if entry.meta else {}
|
|
343
|
+
lines.extend(_format_meta(meta, indent))
|
|
344
|
+
return "\n".join(lines)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@to_string.register(Document)
|
|
348
|
+
def _format_document(
|
|
349
|
+
entry: Document,
|
|
350
|
+
currency_column: int = 61,
|
|
351
|
+
indent: int = 2,
|
|
352
|
+
) -> str:
|
|
353
|
+
"""Format a document entry."""
|
|
354
|
+
lines = [
|
|
355
|
+
f'{entry.date.isoformat()} document {entry.account} "{entry.filename}"'
|
|
356
|
+
]
|
|
357
|
+
meta = dict(entry.meta) if entry.meta else {}
|
|
358
|
+
lines.extend(_format_meta(meta, indent))
|
|
359
|
+
return "\n".join(lines)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@to_string.register(Pad)
|
|
363
|
+
def _format_pad(
|
|
364
|
+
entry: Pad,
|
|
365
|
+
currency_column: int = 61,
|
|
366
|
+
indent: int = 2,
|
|
367
|
+
) -> str:
|
|
368
|
+
"""Format a pad entry."""
|
|
369
|
+
lines = [
|
|
370
|
+
f"{entry.date.isoformat()} pad {entry.account} {entry.source_account}"
|
|
371
|
+
]
|
|
372
|
+
meta = dict(entry.meta) if entry.meta else {}
|
|
373
|
+
lines.extend(_format_meta(meta, indent))
|
|
374
|
+
return "\n".join(lines)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@to_string.register(Commodity)
|
|
378
|
+
def _format_commodity(
|
|
379
|
+
entry: Commodity,
|
|
380
|
+
currency_column: int = 61,
|
|
381
|
+
indent: int = 2,
|
|
382
|
+
) -> str:
|
|
383
|
+
"""Format a commodity entry."""
|
|
384
|
+
lines = [f"{entry.date.isoformat()} commodity {entry.currency}"]
|
|
385
|
+
meta = dict(entry.meta) if entry.meta else {}
|
|
386
|
+
lines.extend(_format_meta(meta, indent))
|
|
387
|
+
return "\n".join(lines)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@to_string.register(Query)
|
|
391
|
+
def _format_query(
|
|
392
|
+
entry: Query,
|
|
393
|
+
currency_column: int = 61,
|
|
394
|
+
indent: int = 2,
|
|
395
|
+
) -> str:
|
|
396
|
+
"""Format a query entry."""
|
|
397
|
+
lines = [
|
|
398
|
+
f'{entry.date.isoformat()} query "{entry.name}" "{entry.query_string}"'
|
|
399
|
+
]
|
|
400
|
+
meta = dict(entry.meta) if entry.meta else {}
|
|
401
|
+
lines.extend(_format_meta(meta, indent))
|
|
402
|
+
return "\n".join(lines)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@to_string.register(Custom)
|
|
406
|
+
def _format_custom(
|
|
407
|
+
entry: Custom,
|
|
408
|
+
currency_column: int = 61,
|
|
409
|
+
indent: int = 2,
|
|
410
|
+
) -> str:
|
|
411
|
+
"""Format a custom entry."""
|
|
412
|
+
parts = [entry.date.isoformat(), "custom", f'"{entry.type}"']
|
|
413
|
+
for val in entry.values:
|
|
414
|
+
# val is a CustomValue wrapper with .value attribute
|
|
415
|
+
v = val.value if hasattr(val, "value") else val
|
|
416
|
+
if isinstance(v, str):
|
|
417
|
+
parts.append(f'"{v}"')
|
|
418
|
+
else:
|
|
419
|
+
parts.append(str(v))
|
|
420
|
+
lines = [" ".join(parts)]
|
|
421
|
+
meta = dict(entry.meta) if entry.meta else {}
|
|
422
|
+
lines.extend(_format_meta(meta, indent))
|
|
423
|
+
return "\n".join(lines)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@to_string.register(Directive)
|
|
427
|
+
def _format_entry(
|
|
428
|
+
entry: Directive,
|
|
429
|
+
currency_column: int = 61,
|
|
430
|
+
indent: int = 2,
|
|
431
|
+
) -> str:
|
|
432
|
+
"""Format any directive entry (fallback)."""
|
|
433
|
+
# This is the fallback for directives not explicitly registered.
|
|
434
|
+
# Try to detect the type and format appropriately.
|
|
435
|
+
entry_type = type(entry).__name__
|
|
436
|
+
|
|
437
|
+
# Build a basic representation
|
|
438
|
+
date_str = entry.date.isoformat()
|
|
439
|
+
|
|
440
|
+
if hasattr(entry, "narration"):
|
|
441
|
+
# Transaction-like
|
|
442
|
+
return _format_transaction(entry, currency_column, indent) # type: ignore[arg-type]
|
|
443
|
+
if hasattr(entry, "amount") and hasattr(entry, "account"):
|
|
444
|
+
# Balance-like
|
|
445
|
+
return _format_balance(entry, currency_column, indent) # type: ignore[arg-type]
|
|
446
|
+
if hasattr(entry, "currencies"):
|
|
447
|
+
# Open-like
|
|
448
|
+
return _format_open(entry, currency_column, indent) # type: ignore[arg-type]
|
|
449
|
+
|
|
450
|
+
# Generic fallback
|
|
451
|
+
lines = [f"{date_str} {entry_type.lower()}"]
|
|
452
|
+
meta = dict(entry.meta) if entry.meta else {}
|
|
453
|
+
lines.extend(_format_meta(meta, indent))
|
|
454
|
+
return "\n".join(lines)
|