rustfava 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. rustfava/__init__.py +30 -0
  2. rustfava/_ctx_globals_class.py +55 -0
  3. rustfava/api_models.py +36 -0
  4. rustfava/application.py +534 -0
  5. rustfava/beans/__init__.py +6 -0
  6. rustfava/beans/abc.py +327 -0
  7. rustfava/beans/account.py +79 -0
  8. rustfava/beans/create.py +377 -0
  9. rustfava/beans/flags.py +20 -0
  10. rustfava/beans/funcs.py +38 -0
  11. rustfava/beans/helpers.py +52 -0
  12. rustfava/beans/ingest.py +75 -0
  13. rustfava/beans/load.py +31 -0
  14. rustfava/beans/prices.py +151 -0
  15. rustfava/beans/protocols.py +82 -0
  16. rustfava/beans/str.py +454 -0
  17. rustfava/beans/types.py +63 -0
  18. rustfava/cli.py +187 -0
  19. rustfava/context.py +13 -0
  20. rustfava/core/__init__.py +729 -0
  21. rustfava/core/accounts.py +161 -0
  22. rustfava/core/attributes.py +145 -0
  23. rustfava/core/budgets.py +207 -0
  24. rustfava/core/charts.py +301 -0
  25. rustfava/core/commodities.py +37 -0
  26. rustfava/core/conversion.py +229 -0
  27. rustfava/core/documents.py +87 -0
  28. rustfava/core/extensions.py +132 -0
  29. rustfava/core/fava_options.py +255 -0
  30. rustfava/core/file.py +542 -0
  31. rustfava/core/filters.py +484 -0
  32. rustfava/core/group_entries.py +97 -0
  33. rustfava/core/ingest.py +509 -0
  34. rustfava/core/inventory.py +167 -0
  35. rustfava/core/misc.py +105 -0
  36. rustfava/core/module_base.py +18 -0
  37. rustfava/core/number.py +106 -0
  38. rustfava/core/query.py +180 -0
  39. rustfava/core/query_shell.py +301 -0
  40. rustfava/core/tree.py +265 -0
  41. rustfava/core/watcher.py +219 -0
  42. rustfava/ext/__init__.py +232 -0
  43. rustfava/ext/auto_commit.py +61 -0
  44. rustfava/ext/portfolio_list/PortfolioList.js +34 -0
  45. rustfava/ext/portfolio_list/__init__.py +29 -0
  46. rustfava/ext/portfolio_list/templates/PortfolioList.html +15 -0
  47. rustfava/ext/rustfava_ext_test/RustfavaExtTest.js +42 -0
  48. rustfava/ext/rustfava_ext_test/__init__.py +207 -0
  49. rustfava/ext/rustfava_ext_test/templates/RustfavaExtTest.html +45 -0
  50. rustfava/ext/rustfava_ext_test/templates/RustfavaExtTestInclude.html +1 -0
  51. rustfava/help/__init__.py +15 -0
  52. rustfava/help/_index.md +29 -0
  53. rustfava/help/beancount_syntax.md +156 -0
  54. rustfava/help/budgets.md +31 -0
  55. rustfava/help/conversion.md +29 -0
  56. rustfava/help/extensions.md +111 -0
  57. rustfava/help/features.md +179 -0
  58. rustfava/help/filters.md +103 -0
  59. rustfava/help/import.md +27 -0
  60. rustfava/help/options.md +289 -0
  61. rustfava/helpers.py +30 -0
  62. rustfava/internal_api.py +221 -0
  63. rustfava/json_api.py +952 -0
  64. rustfava/plugins/__init__.py +3 -0
  65. rustfava/plugins/link_documents.py +107 -0
  66. rustfava/plugins/tag_discovered_documents.py +44 -0
  67. rustfava/py.typed +0 -0
  68. rustfava/rustledger/__init__.py +31 -0
  69. rustfava/rustledger/constants.py +76 -0
  70. rustfava/rustledger/engine.py +485 -0
  71. rustfava/rustledger/loader.py +273 -0
  72. rustfava/rustledger/options.py +202 -0
  73. rustfava/rustledger/query.py +331 -0
  74. rustfava/rustledger/types.py +830 -0
  75. rustfava/serialisation.py +220 -0
  76. rustfava/static/app.css +2988 -0
  77. rustfava/static/app.css.map +7 -0
  78. rustfava/static/app.js +12854 -0
  79. rustfava/static/app.js.map +7 -0
  80. rustfava/static/beancount-JFV44ZVZ.css +5 -0
  81. rustfava/static/beancount-JFV44ZVZ.css.map +7 -0
  82. rustfava/static/beancount-VTTKRGSK.js +4642 -0
  83. rustfava/static/beancount-VTTKRGSK.js.map +7 -0
  84. rustfava/static/bql-MGFRUMBP.js +333 -0
  85. rustfava/static/bql-MGFRUMBP.js.map +7 -0
  86. rustfava/static/chunk-E7ZF4ASL.js +23061 -0
  87. rustfava/static/chunk-E7ZF4ASL.js.map +7 -0
  88. rustfava/static/chunk-V24TLQHT.js +12673 -0
  89. rustfava/static/chunk-V24TLQHT.js.map +7 -0
  90. rustfava/static/favicon.ico +0 -0
  91. rustfava/static/fira-mono-cyrillic-400-normal-BLAGXRCE.woff2 +0 -0
  92. rustfava/static/fira-mono-cyrillic-500-normal-EN7JUAAW.woff2 +0 -0
  93. rustfava/static/fira-mono-cyrillic-ext-400-normal-EX7VARTS.woff2 +0 -0
  94. rustfava/static/fira-mono-cyrillic-ext-500-normal-ZDPTUPRR.woff2 +0 -0
  95. rustfava/static/fira-mono-greek-400-normal-COGHKMOA.woff2 +0 -0
  96. rustfava/static/fira-mono-greek-500-normal-4EN2PKZT.woff2 +0 -0
  97. rustfava/static/fira-mono-greek-ext-400-normal-DYEQIJH7.woff2 +0 -0
  98. rustfava/static/fira-mono-greek-ext-500-normal-SG73CVKQ.woff2 +0 -0
  99. rustfava/static/fira-mono-latin-400-normal-NA3VLV7E.woff2 +0 -0
  100. rustfava/static/fira-mono-latin-500-normal-YC77GFWD.woff2 +0 -0
  101. rustfava/static/fira-mono-latin-ext-400-normal-DIKTZ5PW.woff2 +0 -0
  102. rustfava/static/fira-mono-latin-ext-500-normal-ZWY4UO4V.woff2 +0 -0
  103. rustfava/static/fira-mono-symbols2-400-normal-UITXT77Q.woff2 +0 -0
  104. rustfava/static/fira-mono-symbols2-500-normal-VWPC2EFN.woff2 +0 -0
  105. rustfava/static/fira-sans-cyrillic-400-normal-KLQMBCA6.woff2 +0 -0
  106. rustfava/static/fira-sans-cyrillic-500-normal-NFG7UD6J.woff2 +0 -0
  107. rustfava/static/fira-sans-cyrillic-ext-400-normal-GWO44OPC.woff2 +0 -0
  108. rustfava/static/fira-sans-cyrillic-ext-500-normal-SP47E5SC.woff2 +0 -0
  109. rustfava/static/fira-sans-greek-400-normal-UMQBTLC3.woff2 +0 -0
  110. rustfava/static/fira-sans-greek-500-normal-4ZKHN4FQ.woff2 +0 -0
  111. rustfava/static/fira-sans-greek-ext-400-normal-O2DVJAJZ.woff2 +0 -0
  112. rustfava/static/fira-sans-greek-ext-500-normal-SK6GNWGO.woff2 +0 -0
  113. rustfava/static/fira-sans-latin-400-normal-OYYTPMAV.woff2 +0 -0
  114. rustfava/static/fira-sans-latin-500-normal-SMQPZW5A.woff2 +0 -0
  115. rustfava/static/fira-sans-latin-ext-400-normal-OAUP3WK5.woff2 +0 -0
  116. rustfava/static/fira-sans-latin-ext-500-normal-LY3YDR5Y.woff2 +0 -0
  117. rustfava/static/fira-sans-vietnamese-400-normal-OBMQ72MR.woff2 +0 -0
  118. rustfava/static/fira-sans-vietnamese-500-normal-Y4NZR5EU.woff2 +0 -0
  119. rustfava/static/source-code-pro-cyrillic-400-normal-TO22V6M3.woff2 +0 -0
  120. rustfava/static/source-code-pro-cyrillic-500-normal-OGBWWWYW.woff2 +0 -0
  121. rustfava/static/source-code-pro-cyrillic-ext-400-normal-XH44UCIA.woff2 +0 -0
  122. rustfava/static/source-code-pro-cyrillic-ext-500-normal-3Z6MMVM6.woff2 +0 -0
  123. rustfava/static/source-code-pro-greek-400-normal-OUXXUQWK.woff2 +0 -0
  124. rustfava/static/source-code-pro-greek-500-normal-JA2Z5UXO.woff2 +0 -0
  125. rustfava/static/source-code-pro-greek-ext-400-normal-WCDKMX7U.woff2 +0 -0
  126. rustfava/static/source-code-pro-greek-ext-500-normal-ZHVI4VKW.woff2 +0 -0
  127. rustfava/static/source-code-pro-latin-400-normal-QOGTXED5.woff2 +0 -0
  128. rustfava/static/source-code-pro-latin-500-normal-X57QEOLQ.woff2 +0 -0
  129. rustfava/static/source-code-pro-latin-ext-400-normal-QXC74NBF.woff2 +0 -0
  130. rustfava/static/source-code-pro-latin-ext-500-normal-QGOY7MTT.woff2 +0 -0
  131. rustfava/static/source-code-pro-vietnamese-400-normal-NPDCDTBA.woff2 +0 -0
  132. rustfava/static/source-code-pro-vietnamese-500-normal-M6PJKTR5.woff2 +0 -0
  133. rustfava/static/tree-sitter-beancount-MLXFQBZ5.wasm +0 -0
  134. rustfava/static/web-tree-sitter-RNOQ6E74.wasm +0 -0
  135. rustfava/template_filters.py +64 -0
  136. rustfava/templates/_journal_table.html +156 -0
  137. rustfava/templates/_layout.html +26 -0
  138. rustfava/templates/_query_table.html +88 -0
  139. rustfava/templates/beancount_file +18 -0
  140. rustfava/templates/help.html +23 -0
  141. rustfava/templates/macros/_account_macros.html +5 -0
  142. rustfava/templates/macros/_commodity_macros.html +13 -0
  143. rustfava/translations/bg/LC_MESSAGES/messages.mo +0 -0
  144. rustfava/translations/bg/LC_MESSAGES/messages.po +618 -0
  145. rustfava/translations/ca/LC_MESSAGES/messages.mo +0 -0
  146. rustfava/translations/ca/LC_MESSAGES/messages.po +618 -0
  147. rustfava/translations/de/LC_MESSAGES/messages.mo +0 -0
  148. rustfava/translations/de/LC_MESSAGES/messages.po +618 -0
  149. rustfava/translations/es/LC_MESSAGES/messages.mo +0 -0
  150. rustfava/translations/es/LC_MESSAGES/messages.po +619 -0
  151. rustfava/translations/fa/LC_MESSAGES/messages.mo +0 -0
  152. rustfava/translations/fa/LC_MESSAGES/messages.po +618 -0
  153. rustfava/translations/fr/LC_MESSAGES/messages.mo +0 -0
  154. rustfava/translations/fr/LC_MESSAGES/messages.po +618 -0
  155. rustfava/translations/ja/LC_MESSAGES/messages.mo +0 -0
  156. rustfava/translations/ja/LC_MESSAGES/messages.po +618 -0
  157. rustfava/translations/nl/LC_MESSAGES/messages.mo +0 -0
  158. rustfava/translations/nl/LC_MESSAGES/messages.po +617 -0
  159. rustfava/translations/pt/LC_MESSAGES/messages.mo +0 -0
  160. rustfava/translations/pt/LC_MESSAGES/messages.po +617 -0
  161. rustfava/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
  162. rustfava/translations/pt_BR/LC_MESSAGES/messages.po +618 -0
  163. rustfava/translations/ru/LC_MESSAGES/messages.mo +0 -0
  164. rustfava/translations/ru/LC_MESSAGES/messages.po +617 -0
  165. rustfava/translations/sk/LC_MESSAGES/messages.mo +0 -0
  166. rustfava/translations/sk/LC_MESSAGES/messages.po +623 -0
  167. rustfava/translations/sv/LC_MESSAGES/messages.mo +0 -0
  168. rustfava/translations/sv/LC_MESSAGES/messages.po +618 -0
  169. rustfava/translations/uk/LC_MESSAGES/messages.mo +0 -0
  170. rustfava/translations/uk/LC_MESSAGES/messages.po +618 -0
  171. rustfava/translations/zh/LC_MESSAGES/messages.mo +0 -0
  172. rustfava/translations/zh/LC_MESSAGES/messages.po +618 -0
  173. rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.mo +0 -0
  174. rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.po +618 -0
  175. rustfava/util/__init__.py +157 -0
  176. rustfava/util/date.py +576 -0
  177. rustfava/util/excel.py +118 -0
  178. rustfava/util/ranking.py +79 -0
  179. rustfava/util/sets.py +18 -0
  180. rustfava/util/unreachable.py +20 -0
  181. rustfava-0.1.0.dist-info/METADATA +102 -0
  182. rustfava-0.1.0.dist-info/RECORD +187 -0
  183. rustfava-0.1.0.dist-info/WHEEL +5 -0
  184. rustfava-0.1.0.dist-info/entry_points.txt +2 -0
  185. rustfava-0.1.0.dist-info/licenses/AUTHORS +11 -0
  186. rustfava-0.1.0.dist-info/licenses/LICENSE +21 -0
  187. rustfava-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)