rustfava 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rustfava/__init__.py +30 -0
- rustfava/_ctx_globals_class.py +55 -0
- rustfava/api_models.py +36 -0
- rustfava/application.py +534 -0
- rustfava/beans/__init__.py +6 -0
- rustfava/beans/abc.py +327 -0
- rustfava/beans/account.py +79 -0
- rustfava/beans/create.py +377 -0
- rustfava/beans/flags.py +20 -0
- rustfava/beans/funcs.py +38 -0
- rustfava/beans/helpers.py +52 -0
- rustfava/beans/ingest.py +75 -0
- rustfava/beans/load.py +31 -0
- rustfava/beans/prices.py +151 -0
- rustfava/beans/protocols.py +82 -0
- rustfava/beans/str.py +454 -0
- rustfava/beans/types.py +63 -0
- rustfava/cli.py +187 -0
- rustfava/context.py +13 -0
- rustfava/core/__init__.py +729 -0
- rustfava/core/accounts.py +161 -0
- rustfava/core/attributes.py +145 -0
- rustfava/core/budgets.py +207 -0
- rustfava/core/charts.py +301 -0
- rustfava/core/commodities.py +37 -0
- rustfava/core/conversion.py +229 -0
- rustfava/core/documents.py +87 -0
- rustfava/core/extensions.py +132 -0
- rustfava/core/fava_options.py +255 -0
- rustfava/core/file.py +542 -0
- rustfava/core/filters.py +484 -0
- rustfava/core/group_entries.py +97 -0
- rustfava/core/ingest.py +509 -0
- rustfava/core/inventory.py +167 -0
- rustfava/core/misc.py +105 -0
- rustfava/core/module_base.py +18 -0
- rustfava/core/number.py +106 -0
- rustfava/core/query.py +180 -0
- rustfava/core/query_shell.py +301 -0
- rustfava/core/tree.py +265 -0
- rustfava/core/watcher.py +219 -0
- rustfava/ext/__init__.py +232 -0
- rustfava/ext/auto_commit.py +61 -0
- rustfava/ext/portfolio_list/PortfolioList.js +34 -0
- rustfava/ext/portfolio_list/__init__.py +29 -0
- rustfava/ext/portfolio_list/templates/PortfolioList.html +15 -0
- rustfava/ext/rustfava_ext_test/RustfavaExtTest.js +42 -0
- rustfava/ext/rustfava_ext_test/__init__.py +207 -0
- rustfava/ext/rustfava_ext_test/templates/RustfavaExtTest.html +45 -0
- rustfava/ext/rustfava_ext_test/templates/RustfavaExtTestInclude.html +1 -0
- rustfava/help/__init__.py +15 -0
- rustfava/help/_index.md +29 -0
- rustfava/help/beancount_syntax.md +156 -0
- rustfava/help/budgets.md +31 -0
- rustfava/help/conversion.md +29 -0
- rustfava/help/extensions.md +111 -0
- rustfava/help/features.md +179 -0
- rustfava/help/filters.md +103 -0
- rustfava/help/import.md +27 -0
- rustfava/help/options.md +289 -0
- rustfava/helpers.py +30 -0
- rustfava/internal_api.py +221 -0
- rustfava/json_api.py +952 -0
- rustfava/plugins/__init__.py +3 -0
- rustfava/plugins/link_documents.py +107 -0
- rustfava/plugins/tag_discovered_documents.py +44 -0
- rustfava/py.typed +0 -0
- rustfava/rustledger/__init__.py +31 -0
- rustfava/rustledger/constants.py +76 -0
- rustfava/rustledger/engine.py +485 -0
- rustfava/rustledger/loader.py +273 -0
- rustfava/rustledger/options.py +202 -0
- rustfava/rustledger/query.py +331 -0
- rustfava/rustledger/types.py +830 -0
- rustfava/serialisation.py +220 -0
- rustfava/static/app.css +2988 -0
- rustfava/static/app.css.map +7 -0
- rustfava/static/app.js +12854 -0
- rustfava/static/app.js.map +7 -0
- rustfava/static/beancount-JFV44ZVZ.css +5 -0
- rustfava/static/beancount-JFV44ZVZ.css.map +7 -0
- rustfava/static/beancount-VTTKRGSK.js +4642 -0
- rustfava/static/beancount-VTTKRGSK.js.map +7 -0
- rustfava/static/bql-MGFRUMBP.js +333 -0
- rustfava/static/bql-MGFRUMBP.js.map +7 -0
- rustfava/static/chunk-E7ZF4ASL.js +23061 -0
- rustfava/static/chunk-E7ZF4ASL.js.map +7 -0
- rustfava/static/chunk-V24TLQHT.js +12673 -0
- rustfava/static/chunk-V24TLQHT.js.map +7 -0
- rustfava/static/favicon.ico +0 -0
- rustfava/static/fira-mono-cyrillic-400-normal-BLAGXRCE.woff2 +0 -0
- rustfava/static/fira-mono-cyrillic-500-normal-EN7JUAAW.woff2 +0 -0
- rustfava/static/fira-mono-cyrillic-ext-400-normal-EX7VARTS.woff2 +0 -0
- rustfava/static/fira-mono-cyrillic-ext-500-normal-ZDPTUPRR.woff2 +0 -0
- rustfava/static/fira-mono-greek-400-normal-COGHKMOA.woff2 +0 -0
- rustfava/static/fira-mono-greek-500-normal-4EN2PKZT.woff2 +0 -0
- rustfava/static/fira-mono-greek-ext-400-normal-DYEQIJH7.woff2 +0 -0
- rustfava/static/fira-mono-greek-ext-500-normal-SG73CVKQ.woff2 +0 -0
- rustfava/static/fira-mono-latin-400-normal-NA3VLV7E.woff2 +0 -0
- rustfava/static/fira-mono-latin-500-normal-YC77GFWD.woff2 +0 -0
- rustfava/static/fira-mono-latin-ext-400-normal-DIKTZ5PW.woff2 +0 -0
- rustfava/static/fira-mono-latin-ext-500-normal-ZWY4UO4V.woff2 +0 -0
- rustfava/static/fira-mono-symbols2-400-normal-UITXT77Q.woff2 +0 -0
- rustfava/static/fira-mono-symbols2-500-normal-VWPC2EFN.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-400-normal-KLQMBCA6.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-500-normal-NFG7UD6J.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-ext-400-normal-GWO44OPC.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-ext-500-normal-SP47E5SC.woff2 +0 -0
- rustfava/static/fira-sans-greek-400-normal-UMQBTLC3.woff2 +0 -0
- rustfava/static/fira-sans-greek-500-normal-4ZKHN4FQ.woff2 +0 -0
- rustfava/static/fira-sans-greek-ext-400-normal-O2DVJAJZ.woff2 +0 -0
- rustfava/static/fira-sans-greek-ext-500-normal-SK6GNWGO.woff2 +0 -0
- rustfava/static/fira-sans-latin-400-normal-OYYTPMAV.woff2 +0 -0
- rustfava/static/fira-sans-latin-500-normal-SMQPZW5A.woff2 +0 -0
- rustfava/static/fira-sans-latin-ext-400-normal-OAUP3WK5.woff2 +0 -0
- rustfava/static/fira-sans-latin-ext-500-normal-LY3YDR5Y.woff2 +0 -0
- rustfava/static/fira-sans-vietnamese-400-normal-OBMQ72MR.woff2 +0 -0
- rustfava/static/fira-sans-vietnamese-500-normal-Y4NZR5EU.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-400-normal-TO22V6M3.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-500-normal-OGBWWWYW.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-ext-400-normal-XH44UCIA.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-ext-500-normal-3Z6MMVM6.woff2 +0 -0
- rustfava/static/source-code-pro-greek-400-normal-OUXXUQWK.woff2 +0 -0
- rustfava/static/source-code-pro-greek-500-normal-JA2Z5UXO.woff2 +0 -0
- rustfava/static/source-code-pro-greek-ext-400-normal-WCDKMX7U.woff2 +0 -0
- rustfava/static/source-code-pro-greek-ext-500-normal-ZHVI4VKW.woff2 +0 -0
- rustfava/static/source-code-pro-latin-400-normal-QOGTXED5.woff2 +0 -0
- rustfava/static/source-code-pro-latin-500-normal-X57QEOLQ.woff2 +0 -0
- rustfava/static/source-code-pro-latin-ext-400-normal-QXC74NBF.woff2 +0 -0
- rustfava/static/source-code-pro-latin-ext-500-normal-QGOY7MTT.woff2 +0 -0
- rustfava/static/source-code-pro-vietnamese-400-normal-NPDCDTBA.woff2 +0 -0
- rustfava/static/source-code-pro-vietnamese-500-normal-M6PJKTR5.woff2 +0 -0
- rustfava/static/tree-sitter-beancount-MLXFQBZ5.wasm +0 -0
- rustfava/static/web-tree-sitter-RNOQ6E74.wasm +0 -0
- rustfava/template_filters.py +64 -0
- rustfava/templates/_journal_table.html +156 -0
- rustfava/templates/_layout.html +26 -0
- rustfava/templates/_query_table.html +88 -0
- rustfava/templates/beancount_file +18 -0
- rustfava/templates/help.html +23 -0
- rustfava/templates/macros/_account_macros.html +5 -0
- rustfava/templates/macros/_commodity_macros.html +13 -0
- rustfava/translations/bg/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/bg/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/ca/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/ca/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/de/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/de/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/es/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/es/LC_MESSAGES/messages.po +619 -0
- rustfava/translations/fa/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/fa/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/fr/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/fr/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/ja/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/ja/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/nl/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/nl/LC_MESSAGES/messages.po +617 -0
- rustfava/translations/pt/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/pt/LC_MESSAGES/messages.po +617 -0
- rustfava/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/pt_BR/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/ru/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/ru/LC_MESSAGES/messages.po +617 -0
- rustfava/translations/sk/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/sk/LC_MESSAGES/messages.po +623 -0
- rustfava/translations/sv/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/sv/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/uk/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/uk/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/zh/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/zh/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.po +618 -0
- rustfava/util/__init__.py +157 -0
- rustfava/util/date.py +576 -0
- rustfava/util/excel.py +118 -0
- rustfava/util/ranking.py +79 -0
- rustfava/util/sets.py +18 -0
- rustfava/util/unreachable.py +20 -0
- rustfava-0.1.0.dist-info/METADATA +102 -0
- rustfava-0.1.0.dist-info/RECORD +187 -0
- rustfava-0.1.0.dist-info/WHEEL +5 -0
- rustfava-0.1.0.dist-info/entry_points.txt +2 -0
- rustfava-0.1.0.dist-info/licenses/AUTHORS +11 -0
- rustfava-0.1.0.dist-info/licenses/LICENSE +21 -0
- rustfava-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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)
|