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,729 @@
1
+ """This module provides the data required by Fava's reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date
7
+ from datetime import timedelta
8
+ from functools import cached_property
9
+ from functools import lru_cache
10
+ from itertools import islice
11
+ from itertools import takewhile
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from rustfava.beans.abc import Balance
16
+ from rustfava.beans.abc import Price
17
+ from rustfava.beans.abc import Transaction
18
+ from rustfava.beans.account import account_tester
19
+ from rustfava.beans.account import get_entry_accounts
20
+ from rustfava.beans.funcs import get_position
21
+ from rustfava.beans.funcs import hash_entry
22
+ from rustfava.beans.helpers import slice_entry_dates
23
+ from rustfava.beans.load import load_uncached
24
+ from rustfava.beans.prices import RustfavaPriceMap
25
+ from rustfava.beans.str import to_string
26
+ from rustfava.core.accounts import AccountDict
27
+ from rustfava.rustledger import is_encrypted_file
28
+ from rustfava.core.attributes import AttributesModule
29
+ from rustfava.core.budgets import BudgetModule
30
+ from rustfava.core.charts import ChartModule
31
+ from rustfava.core.commodities import CommoditiesModule
32
+ from rustfava.core.conversion import conversion_from_str
33
+ from rustfava.core.extensions import ExtensionModule
34
+ from rustfava.core.fava_options import parse_options
35
+ from rustfava.core.file import _incomplete_sortkey
36
+ from rustfava.core.file import FileModule
37
+ from rustfava.core.filters import AccountFilter
38
+ from rustfava.core.filters import AdvancedFilter
39
+ from rustfava.core.filters import TimeFilter
40
+ from rustfava.core.group_entries import group_entries_by_type
41
+ from rustfava.core.ingest import IngestModule
42
+ from rustfava.core.inventory import CounterInventory
43
+ from rustfava.core.misc import FavaMisc
44
+ from rustfava.core.number import DecimalFormatModule
45
+ from rustfava.core.query_shell import QueryShell
46
+ from rustfava.core.tree import Tree
47
+ from rustfava.core.watcher import Watcher
48
+ from rustfava.core.watcher import WatchfilesWatcher
49
+ from rustfava.helpers import RustfavaAPIError
50
+ from rustfava.util import listify
51
+ from rustfava.util.date import dateranges
52
+
53
+ if TYPE_CHECKING: # pragma: no cover
54
+ from collections.abc import Iterable
55
+ from collections.abc import Mapping
56
+ from collections.abc import Sequence
57
+ from decimal import Decimal
58
+ from typing import Literal
59
+
60
+ from rustfava.beans.abc import Directive
61
+ from rustfava.beans.types import BeancountOptions
62
+ from rustfava.core.conversion import Conversion
63
+ from rustfava.core.fava_options import RustfavaOptions
64
+ from rustfava.core.group_entries import EntriesByType
65
+ from rustfava.core.inventory import SimpleCounterInventory
66
+ from rustfava.helpers import BeancountError
67
+ from rustfava.util.date import DateRange
68
+ from rustfava.util.date import Interval
69
+
70
+
71
+ class EntryNotFoundForHashError(RustfavaAPIError):
72
+ """Entry not found for hash."""
73
+
74
+ def __init__(self, entry_hash: str) -> None:
75
+ super().__init__(f'No entry found for hash "{entry_hash}"')
76
+
77
+
78
+ class StatementNotFoundError(RustfavaAPIError):
79
+ """Statement not found."""
80
+
81
+ def __init__(self) -> None:
82
+ super().__init__("Statement not found.")
83
+
84
+
85
+ class StatementMetadataInvalidError(RustfavaAPIError):
86
+ """Statement metadata not found or invalid."""
87
+
88
+ def __init__(self, key: str) -> None:
89
+ super().__init__(
90
+ f"Statement path at key '{key}' missing or not a string."
91
+ )
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class JournalPage:
96
+ """A page of journal entries."""
97
+
98
+ entries: Sequence[tuple[int, Directive]]
99
+ total_pages: int
100
+
101
+
102
+ class FilteredLedger:
103
+ """Filtered Beancount ledger."""
104
+
105
+ __slots__ = (
106
+ "__dict__", # for the cached_property decorator
107
+ "_date_first",
108
+ "_date_last",
109
+ "_pages",
110
+ "date_range",
111
+ "entries",
112
+ "ledger",
113
+ )
114
+ _date_first: date | None
115
+ _date_last: date | None
116
+
117
+ def __init__(
118
+ self,
119
+ ledger: RustfavaLedger,
120
+ *,
121
+ account: str | None = None,
122
+ filter: str | None = None, # noqa: A002
123
+ time: str | None = None,
124
+ ) -> None:
125
+ """Create a filtered view of a ledger.
126
+
127
+ Args:
128
+ ledger: The ledger to filter.
129
+ account: The account filter.
130
+ filter: The advanced filter.
131
+ time: The time filter.
132
+ """
133
+ self.ledger = ledger
134
+ self.date_range: DateRange | None = None
135
+ self._pages: (
136
+ tuple[
137
+ int,
138
+ Literal["asc", "desc"],
139
+ list[Sequence[tuple[int, Directive]]],
140
+ ]
141
+ | None
142
+ ) = None
143
+
144
+ entries = ledger.all_entries
145
+ if account:
146
+ entries = AccountFilter(account).apply(entries)
147
+ if filter and filter.strip():
148
+ entries = AdvancedFilter(filter.strip()).apply(entries)
149
+ if time:
150
+ time_filter = TimeFilter(ledger.options, ledger.fava_options, time)
151
+ entries = time_filter.apply(entries)
152
+ self.date_range = time_filter.date_range
153
+ self.entries = entries
154
+
155
+ if self.date_range:
156
+ self._date_first = self.date_range.begin
157
+ self._date_last = self.date_range.end
158
+ return
159
+
160
+ self._date_first = None
161
+ self._date_last = None
162
+ for entry in self.entries:
163
+ if isinstance(entry, Transaction):
164
+ self._date_first = entry.date
165
+ break
166
+ for entry in reversed(self.entries):
167
+ if isinstance(entry, (Transaction, Price)):
168
+ self._date_last = entry.date + timedelta(1)
169
+ break
170
+
171
+ @property
172
+ def end_date(self) -> date | None:
173
+ """The date to use for prices."""
174
+ date_range = self.date_range
175
+ if date_range:
176
+ return date_range.end_inclusive
177
+ return None
178
+
179
+ @cached_property
180
+ def entries_with_all_prices(self) -> Sequence[Directive]:
181
+ """The filtered entries, with all prices added back in for queries."""
182
+ entries = [*self.entries, *self.ledger.all_entries_by_type.Price]
183
+ entries.sort(key=_incomplete_sortkey)
184
+ return entries
185
+
186
+ @cached_property
187
+ def entries_without_prices(self) -> Sequence[Directive]:
188
+ """The filtered entries, without prices for journals."""
189
+ return [e for e in self.entries if not isinstance(e, Price)]
190
+
191
+ @cached_property
192
+ def root_tree(self) -> Tree:
193
+ """A root tree."""
194
+ return Tree(self.entries)
195
+
196
+ @cached_property
197
+ def root_tree_closed(self) -> Tree:
198
+ """A root tree for the balance sheet."""
199
+ tree = Tree(self.entries)
200
+ tree.cap(self.ledger.options, self.ledger.fava_options.unrealized)
201
+ return tree
202
+
203
+ def interval_ranges(self, interval: Interval) -> Sequence[DateRange]:
204
+ """Yield date ranges corresponding to interval boundaries.
205
+
206
+ Args:
207
+ interval: The interval to yield ranges for.
208
+ """
209
+ if not self._date_first or not self._date_last:
210
+ return []
211
+ complete = not self.date_range
212
+ return dateranges(
213
+ self._date_first, self._date_last, interval, complete=complete
214
+ )
215
+
216
+ def prices(self, base: str, quote: str) -> Sequence[tuple[date, Decimal]]:
217
+ """List all prices for a pair of commodities.
218
+
219
+ Args:
220
+ base: The price base.
221
+ quote: The price quote.
222
+ """
223
+ all_prices = self.ledger.prices.get_all_prices((base, quote))
224
+ if all_prices is None:
225
+ return []
226
+
227
+ date_range = self.date_range
228
+ if date_range:
229
+ return [
230
+ price_point
231
+ for price_point in all_prices
232
+ if date_range.begin <= price_point[0] < date_range.end
233
+ ]
234
+ return all_prices
235
+
236
+ def account_is_closed(self, account_name: str) -> bool:
237
+ """Check if the account is closed.
238
+
239
+ Args:
240
+ account_name: An account name.
241
+
242
+ Returns:
243
+ True if the account is closed before the end date of the current
244
+ time filter.
245
+ """
246
+ date_range = self.date_range
247
+ close_date = self.ledger.accounts[account_name].close_date
248
+ if close_date is None:
249
+ return False
250
+ return close_date < date_range.end if date_range else True
251
+
252
+ def paginate_journal(
253
+ self,
254
+ page: int,
255
+ per_page: int = 1000,
256
+ order: Literal["asc", "desc"] = "desc",
257
+ ) -> JournalPage | None:
258
+ """Get entries for a journal page with pagination info.
259
+
260
+ Args:
261
+ page: Page number (1-indexed).
262
+ order: Datewise order to sort in
263
+ per_page: Number of entries per page.
264
+
265
+ Returns:
266
+ A JournalPage, containing a list of entries as (global_index,
267
+ directive) tuples in reverse chronological order and the total
268
+ number of pages.
269
+ """
270
+ if (
271
+ self._pages is None
272
+ or self._pages[0] != per_page
273
+ or self._pages[1] != order
274
+ ):
275
+ pages: list[Sequence[tuple[int, Directive]]] = []
276
+ enumerated = list(enumerate(self.entries_without_prices))
277
+ entries = (
278
+ iter(enumerated) if order == "asc" else reversed(enumerated)
279
+ )
280
+ while batch := tuple(islice(entries, per_page)):
281
+ pages.append(batch)
282
+ if not pages:
283
+ pages.append([])
284
+ self._pages = (per_page, order, pages)
285
+ _per_pages, _order, pages = self._pages
286
+ total = len(pages)
287
+ if page > total:
288
+ return None
289
+ return JournalPage(pages[page - 1], total)
290
+
291
+
292
+ class RustfavaLedger:
293
+ """Interface for a Beancount ledger."""
294
+
295
+ __slots__ = (
296
+ "_is_encrypted",
297
+ "accounts",
298
+ "accounts",
299
+ "all_entries",
300
+ "all_entries_by_type",
301
+ "attributes",
302
+ "beancount_file_path",
303
+ "budgets",
304
+ "charts",
305
+ "commodities",
306
+ "extensions",
307
+ "fava_options",
308
+ "fava_options_errors",
309
+ "file",
310
+ "format_decimal",
311
+ "get_entry",
312
+ "get_filtered",
313
+ "ingest",
314
+ "load_errors",
315
+ "misc",
316
+ "options",
317
+ "prices",
318
+ "query_shell",
319
+ "watcher",
320
+ )
321
+
322
+ #: List of all (unfiltered) entries.
323
+ all_entries: Sequence[Directive]
324
+
325
+ #: A list of all errors reported by Beancount.
326
+ load_errors: Sequence[BeancountError]
327
+
328
+ #: The Beancount options map.
329
+ options: BeancountOptions
330
+
331
+ #: A dict with all of Fava's option values.
332
+ fava_options: RustfavaOptions
333
+
334
+ #: A list of all errors from parsing the custom options.
335
+ fava_options_errors: Sequence[BeancountError]
336
+
337
+ #: The price map.
338
+ prices: RustfavaPriceMap
339
+
340
+ #: Dict of list of all (unfiltered) entries by type.
341
+ all_entries_by_type: EntriesByType
342
+
343
+ #: A :class:`.AccountDict` module - details about the accounts.
344
+ accounts: AccountDict
345
+
346
+ #: An :class:`AttributesModule` instance.
347
+ attributes: AttributesModule
348
+
349
+ #: A :class:`.BudgetModule` instance.
350
+ budgets: BudgetModule
351
+
352
+ #: A :class:`.ChartModule` instance.
353
+ charts: ChartModule
354
+
355
+ #: A :class:`.CommoditiesModule` instance.
356
+ commodities: CommoditiesModule
357
+
358
+ #: A :class:`.ExtensionModule` instance.
359
+ extensions: ExtensionModule
360
+
361
+ #: A :class:`.FileModule` instance.
362
+ file: FileModule
363
+
364
+ #: A :class:`.DecimalFormatModule` instance.
365
+ format_decimal: DecimalFormatModule
366
+
367
+ #: A :class:`.IngestModule` instance.
368
+ ingest: IngestModule
369
+
370
+ #: A :class:`.FavaMisc` instance.
371
+ misc: FavaMisc
372
+
373
+ #: A :class:`.QueryShell` instance.
374
+ query_shell: QueryShell
375
+
376
+ def __init__(self, path: str, *, poll_watcher: bool = False) -> None:
377
+ """Create an interface for a Beancount ledger.
378
+
379
+ Arguments:
380
+ path: Path to the main Beancount file.
381
+ poll_watcher: Whether to use the polling file watcher.
382
+ """
383
+ #: The path to the main Beancount file.
384
+ self.beancount_file_path = path
385
+ self._is_encrypted = is_encrypted_file(path)
386
+ self.get_filtered = lru_cache(maxsize=16)(self._get_filtered)
387
+ self.get_entry = lru_cache(maxsize=16)(self._get_entry)
388
+
389
+ self.accounts = AccountDict(self)
390
+ self.attributes = AttributesModule(self)
391
+ self.budgets = BudgetModule(self)
392
+ self.charts = ChartModule(self)
393
+ self.commodities = CommoditiesModule(self)
394
+ self.extensions = ExtensionModule(self)
395
+ self.file = FileModule(self)
396
+ self.format_decimal = DecimalFormatModule(self)
397
+ self.ingest = IngestModule(self)
398
+ self.misc = FavaMisc(self)
399
+ self.query_shell = QueryShell(self)
400
+
401
+ self.watcher = WatchfilesWatcher() if not poll_watcher else Watcher()
402
+
403
+ self.load_file()
404
+
405
+ def load_file(self) -> None:
406
+ """Load the main file and all included files and set attributes."""
407
+ self.all_entries, self.load_errors, self.options = load_uncached(
408
+ self.beancount_file_path,
409
+ is_encrypted=self._is_encrypted,
410
+ )
411
+ self.get_filtered.cache_clear()
412
+ self.get_entry.cache_clear()
413
+
414
+ self.all_entries_by_type = group_entries_by_type(self.all_entries)
415
+ self.prices = RustfavaPriceMap(self.all_entries_by_type.Price)
416
+
417
+ self.fava_options, self.fava_options_errors = parse_options(
418
+ self.all_entries_by_type.Custom,
419
+ )
420
+
421
+ if self._is_encrypted: # pragma: no cover
422
+ pass
423
+ else:
424
+ self.watcher.update(*self.paths_to_watch())
425
+
426
+ # Call load_file of all modules.
427
+ self.accounts.load_file()
428
+ self.attributes.load_file()
429
+ self.budgets.load_file()
430
+ self.charts.load_file()
431
+ self.commodities.load_file()
432
+ self.extensions.load_file()
433
+ self.file.load_file()
434
+ self.format_decimal.load_file()
435
+ self.misc.load_file()
436
+ self.query_shell.load_file()
437
+ self.ingest.load_file()
438
+
439
+ self.extensions.after_load_file()
440
+
441
+ def _get_filtered(
442
+ self,
443
+ account: str | None = None,
444
+ filter: str | None = None, # noqa: A002
445
+ time: str | None = None,
446
+ ) -> FilteredLedger:
447
+ """Filter the ledger.
448
+
449
+ Args:
450
+ account: The account filter.
451
+ filter: The advanced filter.
452
+ time: The time filter.
453
+ """
454
+ return FilteredLedger(
455
+ ledger=self, account=account, filter=filter, time=time
456
+ )
457
+
458
+ @property
459
+ def mtime(self) -> int:
460
+ """The timestamp to the latest change of the underlying files."""
461
+ return self.watcher.last_checked
462
+
463
+ @property
464
+ def errors(self) -> Sequence[BeancountError]:
465
+ """The errors that the Beancount loading plus Fava module errors."""
466
+ return [
467
+ *self.load_errors,
468
+ *self.fava_options_errors,
469
+ *self.budgets.errors,
470
+ *self.extensions.errors,
471
+ *self.misc.errors,
472
+ *self.ingest.errors,
473
+ ]
474
+
475
+ @property
476
+ def root_accounts(self) -> tuple[str, str, str, str, str]:
477
+ """The five root accounts."""
478
+ options = self.options
479
+ return (
480
+ options["name_assets"],
481
+ options["name_liabilities"],
482
+ options["name_equity"],
483
+ options["name_income"],
484
+ options["name_expenses"],
485
+ )
486
+
487
+ def join_path(self, *args: str) -> Path:
488
+ """Path relative to the directory of the ledger."""
489
+ return Path(self.beancount_file_path).parent.joinpath(*args).resolve()
490
+
491
+ def paths_to_watch(self) -> tuple[Sequence[Path], Sequence[Path]]:
492
+ """Get paths to included files and document directories.
493
+
494
+ Returns:
495
+ A tuple (files, directories).
496
+ """
497
+ files = [Path(i) for i in self.options["include"]]
498
+ if self.ingest.module_path:
499
+ files.append(self.ingest.module_path)
500
+ return (
501
+ files,
502
+ [
503
+ self.join_path(path, account)
504
+ for account in self.root_accounts
505
+ for path in self.options["documents"]
506
+ ],
507
+ )
508
+
509
+ def changed(self) -> bool:
510
+ """Check if the file needs to be reloaded.
511
+
512
+ Returns:
513
+ True if a change in one of the included files or a change in a
514
+ document folder was detected and the file has been reloaded.
515
+ """
516
+ # We can't reload an encrypted file, so act like it never changes.
517
+ if self._is_encrypted: # pragma: no cover
518
+ return False
519
+ changed = self.watcher.check()
520
+ if changed:
521
+ self.load_file()
522
+ return changed
523
+
524
+ def interval_balances(
525
+ self,
526
+ filtered: FilteredLedger,
527
+ interval: Interval,
528
+ account_name: str,
529
+ *,
530
+ accumulate: bool = False,
531
+ ) -> tuple[Sequence[Tree], Sequence[DateRange]]:
532
+ """Balances by interval.
533
+
534
+ Arguments:
535
+ filtered: The currently filtered ledger.
536
+ interval: An interval.
537
+ account_name: An account name.
538
+ accumulate: A boolean, ``True`` if the balances for an interval
539
+ should include all entries up to the end of the interval.
540
+
541
+ Returns:
542
+ A pair of a list of Tree instances and the intervals.
543
+ """
544
+ min_accounts = [
545
+ account
546
+ for account in self.accounts
547
+ if account.startswith(account_name)
548
+ ]
549
+
550
+ interval_ranges = list(reversed(filtered.interval_ranges(interval)))
551
+ interval_balances = [
552
+ Tree(
553
+ slice_entry_dates(
554
+ filtered.entries,
555
+ date.min if accumulate else date_range.begin,
556
+ date_range.end,
557
+ ),
558
+ min_accounts,
559
+ )
560
+ for date_range in interval_ranges
561
+ ]
562
+
563
+ return interval_balances, interval_ranges
564
+
565
+ @listify
566
+ def account_journal(
567
+ self,
568
+ filtered: FilteredLedger,
569
+ account_name: str,
570
+ conversion: str | Conversion,
571
+ *,
572
+ with_children: bool,
573
+ ) -> Iterable[
574
+ tuple[int, Directive, SimpleCounterInventory, SimpleCounterInventory]
575
+ ]:
576
+ """Journal for an account.
577
+
578
+ Args:
579
+ filtered: The currently filtered ledger.
580
+ account_name: An account name.
581
+ conversion: The conversion to use.
582
+ with_children: Whether to include postings of subaccounts of
583
+ the account.
584
+
585
+ Yields:
586
+ Tuples of ``(index, entry, change, balance)``.
587
+ """
588
+ conv = conversion_from_str(conversion)
589
+ relevant_account = account_tester(
590
+ account_name, with_children=with_children
591
+ )
592
+
593
+ prices = self.prices
594
+ balance = CounterInventory()
595
+ for index, entry in enumerate(filtered.entries_without_prices):
596
+ change = CounterInventory()
597
+ entry_is_relevant = False
598
+ postings = getattr(entry, "postings", None)
599
+ if postings is not None:
600
+ for posting in postings:
601
+ if relevant_account(posting.account):
602
+ entry_is_relevant = True
603
+ balance.add_position(posting)
604
+ change.add_position(posting)
605
+ elif any(relevant_account(a) for a in get_entry_accounts(entry)):
606
+ entry_is_relevant = True
607
+
608
+ if entry_is_relevant:
609
+ yield (
610
+ index,
611
+ entry,
612
+ conv.apply(change, prices, entry.date),
613
+ conv.apply(balance, prices, entry.date),
614
+ )
615
+
616
+ def _get_entry(self, entry_hash: str) -> Directive:
617
+ """Find an entry.
618
+
619
+ Arguments:
620
+ entry_hash: Hash of the entry.
621
+
622
+ Returns:
623
+ The entry with the given hash.
624
+
625
+ Raises:
626
+ EntryNotFoundForHashError: If there is no entry for the given hash.
627
+ """
628
+ try:
629
+ return next(
630
+ entry
631
+ for entry in self.all_entries
632
+ if entry_hash == hash_entry(entry)
633
+ )
634
+ except StopIteration as exc:
635
+ raise EntryNotFoundForHashError(entry_hash) from exc
636
+
637
+ def context(
638
+ self,
639
+ entry_hash: str,
640
+ ) -> tuple[
641
+ Directive,
642
+ Mapping[str, Sequence[str]] | None,
643
+ Mapping[str, Sequence[str]] | None,
644
+ ]:
645
+ """Context for an entry.
646
+
647
+ Arguments:
648
+ entry_hash: Hash of entry.
649
+
650
+ Returns:
651
+ A tuple ``(entry, before, after, source_slice, sha256sum)`` of the
652
+ (unique) entry with the given ``entry_hash``. If the entry is a
653
+ Balance or Transaction then ``before`` and ``after`` contain
654
+ the balances before and after the entry of the affected accounts.
655
+ """
656
+ entry = self.get_entry(entry_hash)
657
+
658
+ if not isinstance(entry, (Balance, Transaction)):
659
+ return entry, None, None
660
+
661
+ entry_accounts = get_entry_accounts(entry)
662
+ balances = {account: CounterInventory() for account in entry_accounts}
663
+ for entry_ in takewhile(lambda e: e is not entry, self.all_entries):
664
+ if isinstance(entry_, Transaction):
665
+ for posting in entry_.postings:
666
+ balance = balances.get(posting.account, None)
667
+ if balance is not None:
668
+ balance.add_position(posting)
669
+
670
+ def visualise(inv: CounterInventory) -> Sequence[str]:
671
+ return inv.to_strings()
672
+
673
+ before = {acc: visualise(inv) for acc, inv in balances.items()}
674
+
675
+ if isinstance(entry, Balance):
676
+ return entry, before, None
677
+
678
+ for posting in entry.postings:
679
+ balances[posting.account].add_position(posting)
680
+ after = {acc: visualise(inv) for acc, inv in balances.items()}
681
+ return entry, before, after
682
+
683
+ def commodity_pairs(self) -> Sequence[tuple[str, str]]:
684
+ """List pairs of commodities.
685
+
686
+ Returns:
687
+ A list of pairs of commodities. Pairs of operating currencies will
688
+ be given in both directions not just in the one found in file.
689
+ """
690
+ return self.prices.commodity_pairs(self.options["operating_currency"])
691
+
692
+ def statement_path(self, entry_hash: str, metadata_key: str) -> str:
693
+ """Get the path for a statement found in the specified entry.
694
+
695
+ The entry that we look up should contain a path to a document (absolute
696
+ or relative to the filename of the entry) or just its basename. We go
697
+ through all documents and match on the full path or if one of the
698
+ documents with a matching account has a matching file basename.
699
+
700
+ Arguments:
701
+ entry_hash: Hash of the entry containing the path in its metadata.
702
+ metadata_key: The key that the path should be in.
703
+
704
+ Returns:
705
+ The filename of the matching document entry.
706
+
707
+ Raises:
708
+ StatementMetadataInvalidError: If the metadata at the given key is
709
+ invalid.
710
+ StatementNotFoundError: If no matching document is found.
711
+ """
712
+ entry = self.get_entry(entry_hash)
713
+ value = entry.meta.get(metadata_key, None)
714
+ if not isinstance(value, str):
715
+ raise StatementMetadataInvalidError(metadata_key)
716
+
717
+ accounts = set(get_entry_accounts(entry))
718
+ filename, _ = get_position(entry)
719
+ full_path = (Path(filename).parent / value).resolve()
720
+ for document in self.all_entries_by_type.Document:
721
+ document_path = Path(document.filename)
722
+ if document_path == full_path:
723
+ return document.filename
724
+ if document.account in accounts and document_path.name == value:
725
+ return document.filename
726
+
727
+ raise StatementNotFoundError
728
+
729
+ group_entries_by_type = staticmethod(group_entries_by_type)