rustfava 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rustfava/__init__.py +30 -0
- rustfava/_ctx_globals_class.py +55 -0
- rustfava/api_models.py +36 -0
- rustfava/application.py +534 -0
- rustfava/beans/__init__.py +6 -0
- rustfava/beans/abc.py +327 -0
- rustfava/beans/account.py +79 -0
- rustfava/beans/create.py +377 -0
- rustfava/beans/flags.py +20 -0
- rustfava/beans/funcs.py +38 -0
- rustfava/beans/helpers.py +52 -0
- rustfava/beans/ingest.py +75 -0
- rustfava/beans/load.py +31 -0
- rustfava/beans/prices.py +151 -0
- rustfava/beans/protocols.py +82 -0
- rustfava/beans/str.py +454 -0
- rustfava/beans/types.py +63 -0
- rustfava/cli.py +187 -0
- rustfava/context.py +13 -0
- rustfava/core/__init__.py +729 -0
- rustfava/core/accounts.py +161 -0
- rustfava/core/attributes.py +145 -0
- rustfava/core/budgets.py +207 -0
- rustfava/core/charts.py +301 -0
- rustfava/core/commodities.py +37 -0
- rustfava/core/conversion.py +229 -0
- rustfava/core/documents.py +87 -0
- rustfava/core/extensions.py +132 -0
- rustfava/core/fava_options.py +255 -0
- rustfava/core/file.py +542 -0
- rustfava/core/filters.py +484 -0
- rustfava/core/group_entries.py +97 -0
- rustfava/core/ingest.py +509 -0
- rustfava/core/inventory.py +167 -0
- rustfava/core/misc.py +105 -0
- rustfava/core/module_base.py +18 -0
- rustfava/core/number.py +106 -0
- rustfava/core/query.py +180 -0
- rustfava/core/query_shell.py +301 -0
- rustfava/core/tree.py +265 -0
- rustfava/core/watcher.py +219 -0
- rustfava/ext/__init__.py +232 -0
- rustfava/ext/auto_commit.py +61 -0
- rustfava/ext/portfolio_list/PortfolioList.js +34 -0
- rustfava/ext/portfolio_list/__init__.py +29 -0
- rustfava/ext/portfolio_list/templates/PortfolioList.html +15 -0
- rustfava/ext/rustfava_ext_test/RustfavaExtTest.js +42 -0
- rustfava/ext/rustfava_ext_test/__init__.py +207 -0
- rustfava/ext/rustfava_ext_test/templates/RustfavaExtTest.html +45 -0
- rustfava/ext/rustfava_ext_test/templates/RustfavaExtTestInclude.html +1 -0
- rustfava/help/__init__.py +15 -0
- rustfava/help/_index.md +29 -0
- rustfava/help/beancount_syntax.md +156 -0
- rustfava/help/budgets.md +31 -0
- rustfava/help/conversion.md +29 -0
- rustfava/help/extensions.md +111 -0
- rustfava/help/features.md +179 -0
- rustfava/help/filters.md +103 -0
- rustfava/help/import.md +27 -0
- rustfava/help/options.md +289 -0
- rustfava/helpers.py +30 -0
- rustfava/internal_api.py +221 -0
- rustfava/json_api.py +952 -0
- rustfava/plugins/__init__.py +3 -0
- rustfava/plugins/link_documents.py +107 -0
- rustfava/plugins/tag_discovered_documents.py +44 -0
- rustfava/py.typed +0 -0
- rustfava/rustledger/__init__.py +31 -0
- rustfava/rustledger/constants.py +76 -0
- rustfava/rustledger/engine.py +485 -0
- rustfava/rustledger/loader.py +273 -0
- rustfava/rustledger/options.py +202 -0
- rustfava/rustledger/query.py +331 -0
- rustfava/rustledger/types.py +830 -0
- rustfava/serialisation.py +220 -0
- rustfava/static/app.css +2988 -0
- rustfava/static/app.css.map +7 -0
- rustfava/static/app.js +12854 -0
- rustfava/static/app.js.map +7 -0
- rustfava/static/beancount-JFV44ZVZ.css +5 -0
- rustfava/static/beancount-JFV44ZVZ.css.map +7 -0
- rustfava/static/beancount-VTTKRGSK.js +4642 -0
- rustfava/static/beancount-VTTKRGSK.js.map +7 -0
- rustfava/static/bql-MGFRUMBP.js +333 -0
- rustfava/static/bql-MGFRUMBP.js.map +7 -0
- rustfava/static/chunk-E7ZF4ASL.js +23061 -0
- rustfava/static/chunk-E7ZF4ASL.js.map +7 -0
- rustfava/static/chunk-V24TLQHT.js +12673 -0
- rustfava/static/chunk-V24TLQHT.js.map +7 -0
- rustfava/static/favicon.ico +0 -0
- rustfava/static/fira-mono-cyrillic-400-normal-BLAGXRCE.woff2 +0 -0
- rustfava/static/fira-mono-cyrillic-500-normal-EN7JUAAW.woff2 +0 -0
- rustfava/static/fira-mono-cyrillic-ext-400-normal-EX7VARTS.woff2 +0 -0
- rustfava/static/fira-mono-cyrillic-ext-500-normal-ZDPTUPRR.woff2 +0 -0
- rustfava/static/fira-mono-greek-400-normal-COGHKMOA.woff2 +0 -0
- rustfava/static/fira-mono-greek-500-normal-4EN2PKZT.woff2 +0 -0
- rustfava/static/fira-mono-greek-ext-400-normal-DYEQIJH7.woff2 +0 -0
- rustfava/static/fira-mono-greek-ext-500-normal-SG73CVKQ.woff2 +0 -0
- rustfava/static/fira-mono-latin-400-normal-NA3VLV7E.woff2 +0 -0
- rustfava/static/fira-mono-latin-500-normal-YC77GFWD.woff2 +0 -0
- rustfava/static/fira-mono-latin-ext-400-normal-DIKTZ5PW.woff2 +0 -0
- rustfava/static/fira-mono-latin-ext-500-normal-ZWY4UO4V.woff2 +0 -0
- rustfava/static/fira-mono-symbols2-400-normal-UITXT77Q.woff2 +0 -0
- rustfava/static/fira-mono-symbols2-500-normal-VWPC2EFN.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-400-normal-KLQMBCA6.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-500-normal-NFG7UD6J.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-ext-400-normal-GWO44OPC.woff2 +0 -0
- rustfava/static/fira-sans-cyrillic-ext-500-normal-SP47E5SC.woff2 +0 -0
- rustfava/static/fira-sans-greek-400-normal-UMQBTLC3.woff2 +0 -0
- rustfava/static/fira-sans-greek-500-normal-4ZKHN4FQ.woff2 +0 -0
- rustfava/static/fira-sans-greek-ext-400-normal-O2DVJAJZ.woff2 +0 -0
- rustfava/static/fira-sans-greek-ext-500-normal-SK6GNWGO.woff2 +0 -0
- rustfava/static/fira-sans-latin-400-normal-OYYTPMAV.woff2 +0 -0
- rustfava/static/fira-sans-latin-500-normal-SMQPZW5A.woff2 +0 -0
- rustfava/static/fira-sans-latin-ext-400-normal-OAUP3WK5.woff2 +0 -0
- rustfava/static/fira-sans-latin-ext-500-normal-LY3YDR5Y.woff2 +0 -0
- rustfava/static/fira-sans-vietnamese-400-normal-OBMQ72MR.woff2 +0 -0
- rustfava/static/fira-sans-vietnamese-500-normal-Y4NZR5EU.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-400-normal-TO22V6M3.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-500-normal-OGBWWWYW.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-ext-400-normal-XH44UCIA.woff2 +0 -0
- rustfava/static/source-code-pro-cyrillic-ext-500-normal-3Z6MMVM6.woff2 +0 -0
- rustfava/static/source-code-pro-greek-400-normal-OUXXUQWK.woff2 +0 -0
- rustfava/static/source-code-pro-greek-500-normal-JA2Z5UXO.woff2 +0 -0
- rustfava/static/source-code-pro-greek-ext-400-normal-WCDKMX7U.woff2 +0 -0
- rustfava/static/source-code-pro-greek-ext-500-normal-ZHVI4VKW.woff2 +0 -0
- rustfava/static/source-code-pro-latin-400-normal-QOGTXED5.woff2 +0 -0
- rustfava/static/source-code-pro-latin-500-normal-X57QEOLQ.woff2 +0 -0
- rustfava/static/source-code-pro-latin-ext-400-normal-QXC74NBF.woff2 +0 -0
- rustfava/static/source-code-pro-latin-ext-500-normal-QGOY7MTT.woff2 +0 -0
- rustfava/static/source-code-pro-vietnamese-400-normal-NPDCDTBA.woff2 +0 -0
- rustfava/static/source-code-pro-vietnamese-500-normal-M6PJKTR5.woff2 +0 -0
- rustfava/static/tree-sitter-beancount-MLXFQBZ5.wasm +0 -0
- rustfava/static/web-tree-sitter-RNOQ6E74.wasm +0 -0
- rustfava/template_filters.py +64 -0
- rustfava/templates/_journal_table.html +156 -0
- rustfava/templates/_layout.html +26 -0
- rustfava/templates/_query_table.html +88 -0
- rustfava/templates/beancount_file +18 -0
- rustfava/templates/help.html +23 -0
- rustfava/templates/macros/_account_macros.html +5 -0
- rustfava/templates/macros/_commodity_macros.html +13 -0
- rustfava/translations/bg/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/bg/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/ca/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/ca/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/de/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/de/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/es/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/es/LC_MESSAGES/messages.po +619 -0
- rustfava/translations/fa/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/fa/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/fr/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/fr/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/ja/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/ja/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/nl/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/nl/LC_MESSAGES/messages.po +617 -0
- rustfava/translations/pt/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/pt/LC_MESSAGES/messages.po +617 -0
- rustfava/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/pt_BR/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/ru/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/ru/LC_MESSAGES/messages.po +617 -0
- rustfava/translations/sk/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/sk/LC_MESSAGES/messages.po +623 -0
- rustfava/translations/sv/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/sv/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/uk/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/uk/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/zh/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/zh/LC_MESSAGES/messages.po +618 -0
- rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.mo +0 -0
- rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.po +618 -0
- rustfava/util/__init__.py +157 -0
- rustfava/util/date.py +576 -0
- rustfava/util/excel.py +118 -0
- rustfava/util/ranking.py +79 -0
- rustfava/util/sets.py +18 -0
- rustfava/util/unreachable.py +20 -0
- rustfava-0.1.0.dist-info/METADATA +102 -0
- rustfava-0.1.0.dist-info/RECORD +187 -0
- rustfava-0.1.0.dist-info/WHEEL +5 -0
- rustfava-0.1.0.dist-info/entry_points.txt +2 -0
- rustfava-0.1.0.dist-info/licenses/AUTHORS +11 -0
- rustfava-0.1.0.dist-info/licenses/LICENSE +21 -0
- rustfava-0.1.0.dist-info/top_level.txt +1 -0
rustfava/core/charts.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Provide data suitable for rustfava's charts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from dataclasses import fields
|
|
8
|
+
from dataclasses import is_dataclass
|
|
9
|
+
from datetime import date
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from re import Pattern
|
|
12
|
+
from typing import Any
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
|
|
17
|
+
from flask.json.provider import JSONProvider
|
|
18
|
+
|
|
19
|
+
from rustfava.beans.abc import Position
|
|
20
|
+
from rustfava.rustledger.constants import Booking
|
|
21
|
+
from rustfava.rustledger.constants import MISSING
|
|
22
|
+
from rustfava.beans.abc import Transaction
|
|
23
|
+
from rustfava.beans.account import account_tester
|
|
24
|
+
from rustfava.beans.flags import FLAG_UNREALIZED
|
|
25
|
+
from rustfava.beans.helpers import slice_entry_dates
|
|
26
|
+
from rustfava.core.conversion import conversion_from_str
|
|
27
|
+
from rustfava.core.inventory import CounterInventory
|
|
28
|
+
from rustfava.core.module_base import FavaModule
|
|
29
|
+
from rustfava.util import listify
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
32
|
+
from collections.abc import Iterable
|
|
33
|
+
from collections.abc import Mapping
|
|
34
|
+
|
|
35
|
+
from rustfava.core import FilteredLedger
|
|
36
|
+
from rustfava.core.conversion import Conversion
|
|
37
|
+
from rustfava.core.inventory import SimpleCounterInventory
|
|
38
|
+
from rustfava.core.tree import SerialisedTreeNode
|
|
39
|
+
from rustfava.util.date import Interval
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
ZERO = Decimal()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _json_default(o: Any) -> Any:
|
|
46
|
+
"""Specific serialisation for some data types."""
|
|
47
|
+
if isinstance(o, Decimal):
|
|
48
|
+
return float(o)
|
|
49
|
+
if isinstance(o, (date, Booking, Position)):
|
|
50
|
+
return str(o)
|
|
51
|
+
if isinstance(o, (set, frozenset)):
|
|
52
|
+
return list(o)
|
|
53
|
+
if isinstance(o, Pattern):
|
|
54
|
+
return o.pattern
|
|
55
|
+
if is_dataclass(o):
|
|
56
|
+
return {field.name: getattr(o, field.name) for field in fields(o)}
|
|
57
|
+
if o is MISSING: # pragma: no cover
|
|
58
|
+
return None
|
|
59
|
+
raise TypeError # pragma: no cover
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def dumps(obj: Any, **_kwargs: Any) -> str:
|
|
63
|
+
"""Dump as a JSON string."""
|
|
64
|
+
return json.dumps(
|
|
65
|
+
obj, sort_keys=True, separators=(",", ":"), default=_json_default
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def loads(s: str | bytes) -> Any:
|
|
70
|
+
"""Load a JSON string."""
|
|
71
|
+
return json.loads(s)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RustfavaJSONProvider(JSONProvider):
|
|
75
|
+
"""Use custom JSON encoder and decoder."""
|
|
76
|
+
|
|
77
|
+
def dumps(self, obj: Any, **_kwargs: Any) -> str: # noqa: D102
|
|
78
|
+
return json.dumps(
|
|
79
|
+
obj, sort_keys=True, separators=(",", ":"), default=_json_default
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def loads(self, s: str | bytes, **_kwargs: Any) -> Any: # noqa: D102
|
|
83
|
+
return json.loads(s)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class DateAndBalance:
|
|
88
|
+
"""Balance at a date."""
|
|
89
|
+
|
|
90
|
+
date: date
|
|
91
|
+
balance: SimpleCounterInventory
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class DateAndBalanceWithBudget:
|
|
96
|
+
"""Balance at a date with a budget."""
|
|
97
|
+
|
|
98
|
+
date: date
|
|
99
|
+
balance: SimpleCounterInventory
|
|
100
|
+
account_balances: Mapping[str, SimpleCounterInventory]
|
|
101
|
+
budgets: Mapping[str, Decimal]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ChartModule(FavaModule):
|
|
105
|
+
"""Return data for the various charts in rustfava."""
|
|
106
|
+
|
|
107
|
+
def hierarchy(
|
|
108
|
+
self,
|
|
109
|
+
filtered: FilteredLedger,
|
|
110
|
+
account_name: str,
|
|
111
|
+
conversion: Conversion,
|
|
112
|
+
) -> SerialisedTreeNode:
|
|
113
|
+
"""Render an account tree."""
|
|
114
|
+
tree = filtered.root_tree
|
|
115
|
+
return tree.get(account_name).serialise(
|
|
116
|
+
conversion, self.ledger.prices, filtered.end_date
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@listify
|
|
120
|
+
def interval_totals(
|
|
121
|
+
self,
|
|
122
|
+
filtered: FilteredLedger,
|
|
123
|
+
interval: Interval,
|
|
124
|
+
accounts: str | tuple[str, ...],
|
|
125
|
+
conversion: str | Conversion,
|
|
126
|
+
*,
|
|
127
|
+
invert: bool = False,
|
|
128
|
+
) -> Iterable[DateAndBalanceWithBudget]:
|
|
129
|
+
"""Render totals for account (or accounts) in the intervals.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
filtered: The filtered ledger.
|
|
133
|
+
interval: An interval.
|
|
134
|
+
accounts: A single account (str) or a tuple of accounts.
|
|
135
|
+
conversion: The conversion to use.
|
|
136
|
+
invert: invert all numbers.
|
|
137
|
+
|
|
138
|
+
Yields:
|
|
139
|
+
The balances and budgets for the intervals.
|
|
140
|
+
"""
|
|
141
|
+
conv = conversion_from_str(conversion)
|
|
142
|
+
prices = self.ledger.prices
|
|
143
|
+
|
|
144
|
+
# limit the bar charts to 100 intervals
|
|
145
|
+
intervals = filtered.interval_ranges(interval)[-100:]
|
|
146
|
+
|
|
147
|
+
for date_range in intervals:
|
|
148
|
+
inventory = CounterInventory()
|
|
149
|
+
entries = slice_entry_dates(
|
|
150
|
+
filtered.entries, date_range.begin, date_range.end
|
|
151
|
+
)
|
|
152
|
+
account_inventories: dict[str, CounterInventory] = defaultdict(
|
|
153
|
+
CounterInventory,
|
|
154
|
+
)
|
|
155
|
+
for entry in entries:
|
|
156
|
+
for posting in getattr(entry, "postings", []):
|
|
157
|
+
if posting.account.startswith(accounts):
|
|
158
|
+
account_inventories[posting.account].add_position(
|
|
159
|
+
posting,
|
|
160
|
+
)
|
|
161
|
+
inventory.add_position(posting)
|
|
162
|
+
balance = conv.apply(
|
|
163
|
+
inventory,
|
|
164
|
+
prices,
|
|
165
|
+
date_range.end_inclusive,
|
|
166
|
+
)
|
|
167
|
+
account_balances = {
|
|
168
|
+
account: conv.apply(
|
|
169
|
+
acct_value,
|
|
170
|
+
prices,
|
|
171
|
+
date_range.end_inclusive,
|
|
172
|
+
)
|
|
173
|
+
for account, acct_value in account_inventories.items()
|
|
174
|
+
}
|
|
175
|
+
budgets = (
|
|
176
|
+
self.ledger.budgets.calculate_children(
|
|
177
|
+
accounts,
|
|
178
|
+
date_range.begin,
|
|
179
|
+
date_range.end,
|
|
180
|
+
)
|
|
181
|
+
if isinstance(accounts, str)
|
|
182
|
+
else {}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if invert:
|
|
186
|
+
balance = -balance
|
|
187
|
+
budgets = {k: -v for k, v in budgets.items()}
|
|
188
|
+
account_balances = {k: -v for k, v in account_balances.items()}
|
|
189
|
+
|
|
190
|
+
yield DateAndBalanceWithBudget(
|
|
191
|
+
date_range.end_inclusive,
|
|
192
|
+
balance,
|
|
193
|
+
account_balances,
|
|
194
|
+
budgets,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
@listify
|
|
198
|
+
def linechart(
|
|
199
|
+
self,
|
|
200
|
+
filtered: FilteredLedger,
|
|
201
|
+
account_name: str,
|
|
202
|
+
conversion: str | Conversion,
|
|
203
|
+
) -> Iterable[DateAndBalance]:
|
|
204
|
+
"""Get the balance of an account as a line chart.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
filtered: The filtered ledger.
|
|
208
|
+
account_name: A string.
|
|
209
|
+
conversion: The conversion to use.
|
|
210
|
+
|
|
211
|
+
Yields:
|
|
212
|
+
Dicts for all dates on which the balance of the given
|
|
213
|
+
account has changed containing the balance (in units) of the
|
|
214
|
+
account at that date.
|
|
215
|
+
"""
|
|
216
|
+
conv = conversion_from_str(conversion)
|
|
217
|
+
|
|
218
|
+
def _balances() -> Iterable[tuple[date, CounterInventory]]:
|
|
219
|
+
last_date = None
|
|
220
|
+
running_balance = CounterInventory()
|
|
221
|
+
is_child_account = account_tester(account_name, with_children=True)
|
|
222
|
+
|
|
223
|
+
for entry in filtered.entries:
|
|
224
|
+
for posting in getattr(entry, "postings", []):
|
|
225
|
+
if is_child_account(posting.account):
|
|
226
|
+
new_date = entry.date
|
|
227
|
+
if last_date is not None and new_date > last_date:
|
|
228
|
+
yield (last_date, running_balance)
|
|
229
|
+
running_balance.add_position(posting)
|
|
230
|
+
last_date = new_date
|
|
231
|
+
|
|
232
|
+
if last_date is not None:
|
|
233
|
+
yield (last_date, running_balance)
|
|
234
|
+
|
|
235
|
+
# When the balance for a commodity just went to zero, it will be
|
|
236
|
+
# missing from the 'balance' so keep track of currencies that last had
|
|
237
|
+
# a balance.
|
|
238
|
+
last_currencies = None
|
|
239
|
+
prices = self.ledger.prices
|
|
240
|
+
|
|
241
|
+
for d, running_bal in _balances():
|
|
242
|
+
balance = conv.apply(running_bal, prices, d)
|
|
243
|
+
currencies = set(balance.keys())
|
|
244
|
+
if last_currencies:
|
|
245
|
+
for currency in last_currencies - currencies:
|
|
246
|
+
balance[currency] = ZERO
|
|
247
|
+
last_currencies = currencies
|
|
248
|
+
yield DateAndBalance(d, balance)
|
|
249
|
+
|
|
250
|
+
@listify
|
|
251
|
+
def net_worth(
|
|
252
|
+
self,
|
|
253
|
+
filtered: FilteredLedger,
|
|
254
|
+
interval: Interval,
|
|
255
|
+
conversion: str | Conversion,
|
|
256
|
+
) -> Iterable[DateAndBalance]:
|
|
257
|
+
"""Compute net worth.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
filtered: The filtered ledger.
|
|
261
|
+
interval: A string for the interval.
|
|
262
|
+
conversion: The conversion to use.
|
|
263
|
+
|
|
264
|
+
Yields:
|
|
265
|
+
Dicts for all ends of the given interval containing the
|
|
266
|
+
net worth (Assets + Liabilities) separately converted to all
|
|
267
|
+
operating currencies.
|
|
268
|
+
"""
|
|
269
|
+
conv = conversion_from_str(conversion)
|
|
270
|
+
transactions = (
|
|
271
|
+
entry
|
|
272
|
+
for entry in filtered.entries
|
|
273
|
+
if (
|
|
274
|
+
isinstance(entry, Transaction)
|
|
275
|
+
and entry.flag != FLAG_UNREALIZED
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
types = (
|
|
280
|
+
self.ledger.options["name_assets"],
|
|
281
|
+
self.ledger.options["name_liabilities"],
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
txn = next(transactions, None)
|
|
285
|
+
inventory = CounterInventory()
|
|
286
|
+
|
|
287
|
+
prices = self.ledger.prices
|
|
288
|
+
for date_range in filtered.interval_ranges(interval):
|
|
289
|
+
while txn and txn.date < date_range.end:
|
|
290
|
+
for posting in txn.postings:
|
|
291
|
+
if posting.account.startswith(types):
|
|
292
|
+
inventory.add_position(posting)
|
|
293
|
+
txn = next(transactions, None)
|
|
294
|
+
yield DateAndBalance(
|
|
295
|
+
date_range.end_inclusive,
|
|
296
|
+
conv.apply(
|
|
297
|
+
inventory,
|
|
298
|
+
prices,
|
|
299
|
+
date_range.end_inclusive,
|
|
300
|
+
),
|
|
301
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Attributes for auto-completion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from rustfava.core.module_base import FavaModule
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
12
|
+
from rustfava.core import RustfavaLedger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CommoditiesModule(FavaModule):
|
|
16
|
+
"""Details about the currencies and commodities."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, ledger: RustfavaLedger) -> None:
|
|
19
|
+
super().__init__(ledger)
|
|
20
|
+
self.names: dict[str, str] = {}
|
|
21
|
+
self.precisions: dict[str, int] = {}
|
|
22
|
+
|
|
23
|
+
def load_file(self) -> None: # noqa: D102
|
|
24
|
+
self.names = {}
|
|
25
|
+
self.precisions = {}
|
|
26
|
+
for commodity in self.ledger.all_entries_by_type.Commodity:
|
|
27
|
+
name = commodity.meta.get("name")
|
|
28
|
+
if name:
|
|
29
|
+
self.names[commodity.currency] = str(name)
|
|
30
|
+
precision = commodity.meta.get("precision")
|
|
31
|
+
if isinstance(precision, (str, int, Decimal)):
|
|
32
|
+
with suppress(ValueError):
|
|
33
|
+
self.precisions[commodity.currency] = int(precision)
|
|
34
|
+
|
|
35
|
+
def name(self, commodity: str) -> str:
|
|
36
|
+
"""Get the name of a commodity (or the commodity itself if not set)."""
|
|
37
|
+
return self.names.get(commodity, commodity)
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Commodity conversion helpers for Fava.
|
|
2
|
+
|
|
3
|
+
All functions in this module will be automatically added as template filters.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from abc import ABC
|
|
9
|
+
from abc import abstractmethod
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from rustfava.core.inventory import _Amount
|
|
13
|
+
from rustfava.core.inventory import SimpleCounterInventory
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from typing import override
|
|
17
|
+
except ImportError: # pragma: no cover
|
|
18
|
+
from typing import override
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
21
|
+
import datetime
|
|
22
|
+
|
|
23
|
+
from beancount.core.inventory import Inventory
|
|
24
|
+
|
|
25
|
+
from rustfava.beans.prices import RustfavaPriceMap
|
|
26
|
+
from rustfava.beans.protocols import Amount
|
|
27
|
+
from rustfava.beans.protocols import Position
|
|
28
|
+
from rustfava.core.inventory import CounterInventory
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_cost(pos: Position) -> Amount:
|
|
32
|
+
"""Return the total cost of a Position."""
|
|
33
|
+
cost_ = pos.cost
|
|
34
|
+
return (
|
|
35
|
+
_Amount(cost_.number * pos.units.number, cost_.currency)
|
|
36
|
+
if cost_ is not None
|
|
37
|
+
else pos.units
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_market_value(
|
|
42
|
+
pos: Position,
|
|
43
|
+
prices: RustfavaPriceMap,
|
|
44
|
+
date: datetime.date | None = None,
|
|
45
|
+
) -> Amount:
|
|
46
|
+
"""Get the market value of a Position.
|
|
47
|
+
|
|
48
|
+
This differs from the convert.get_value function in Beancount by returning
|
|
49
|
+
the cost value if no price can be found.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
pos: A Position.
|
|
53
|
+
prices: A rustfavaPriceMap
|
|
54
|
+
date: A datetime.date instance to evaluate the value at, or None.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
An Amount, with value converted or if the conversion failed just the
|
|
58
|
+
cost value (or the units if the position has no cost).
|
|
59
|
+
"""
|
|
60
|
+
units_ = pos.units
|
|
61
|
+
cost_ = pos.cost
|
|
62
|
+
|
|
63
|
+
if cost_ is not None:
|
|
64
|
+
value_currency = cost_.currency
|
|
65
|
+
base_quote = (units_.currency, value_currency)
|
|
66
|
+
price_number = prices.get_price(base_quote, date)
|
|
67
|
+
if price_number is not None:
|
|
68
|
+
return _Amount(
|
|
69
|
+
units_.number * price_number,
|
|
70
|
+
value_currency,
|
|
71
|
+
)
|
|
72
|
+
return _Amount(units_.number * cost_.number, value_currency)
|
|
73
|
+
return units_
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def convert_position(
|
|
77
|
+
pos: Position,
|
|
78
|
+
target_currency: str,
|
|
79
|
+
prices: RustfavaPriceMap,
|
|
80
|
+
date: datetime.date | None = None,
|
|
81
|
+
) -> Amount:
|
|
82
|
+
"""Get the value of a Position in a particular currency.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
pos: A Position.
|
|
86
|
+
target_currency: The target currency to convert to.
|
|
87
|
+
prices: A rustfavaPriceMap
|
|
88
|
+
date: A datetime.date instance to evaluate the value at, or None.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
An Amount, with value converted or if the conversion failed just the
|
|
92
|
+
cost value (or the units if the position has no cost).
|
|
93
|
+
"""
|
|
94
|
+
units_ = pos.units
|
|
95
|
+
|
|
96
|
+
# try the direct conversion
|
|
97
|
+
base_quote = (units_.currency, target_currency)
|
|
98
|
+
price_number = prices.get_price(base_quote, date)
|
|
99
|
+
if price_number is not None:
|
|
100
|
+
return _Amount(units_.number * price_number, target_currency)
|
|
101
|
+
|
|
102
|
+
cost_ = pos.cost
|
|
103
|
+
if cost_ is not None:
|
|
104
|
+
cost_currency = cost_.currency
|
|
105
|
+
if cost_currency != target_currency:
|
|
106
|
+
base_quote1 = (units_.currency, cost_currency)
|
|
107
|
+
rate1 = prices.get_price(base_quote1, date)
|
|
108
|
+
if rate1 is not None:
|
|
109
|
+
base_quote2 = (cost_currency, target_currency)
|
|
110
|
+
rate2 = prices.get_price(base_quote2, date)
|
|
111
|
+
if rate2 is not None:
|
|
112
|
+
return _Amount(
|
|
113
|
+
units_.number * rate1 * rate2,
|
|
114
|
+
target_currency,
|
|
115
|
+
)
|
|
116
|
+
return units_
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Conversion(ABC):
|
|
120
|
+
"""A conversion."""
|
|
121
|
+
|
|
122
|
+
@abstractmethod
|
|
123
|
+
def apply(
|
|
124
|
+
self,
|
|
125
|
+
inventory: CounterInventory,
|
|
126
|
+
prices: RustfavaPriceMap,
|
|
127
|
+
date: datetime.date | None = None,
|
|
128
|
+
) -> SimpleCounterInventory:
|
|
129
|
+
"""Apply the conversion to an inventory (CounterInventory)."""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _AtCostConversion(Conversion):
|
|
133
|
+
@override
|
|
134
|
+
def apply(
|
|
135
|
+
self,
|
|
136
|
+
inventory: CounterInventory,
|
|
137
|
+
prices: RustfavaPriceMap | None = None,
|
|
138
|
+
date: datetime.date | None = None,
|
|
139
|
+
) -> SimpleCounterInventory:
|
|
140
|
+
return inventory.reduce(get_cost)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _AtValueConversion(Conversion):
|
|
144
|
+
@override
|
|
145
|
+
def apply(
|
|
146
|
+
self,
|
|
147
|
+
inventory: CounterInventory,
|
|
148
|
+
prices: RustfavaPriceMap,
|
|
149
|
+
date: datetime.date | None = None,
|
|
150
|
+
) -> SimpleCounterInventory:
|
|
151
|
+
return inventory.reduce(get_market_value, prices, date)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class _UnitsConversion(Conversion):
|
|
155
|
+
@override
|
|
156
|
+
def apply(
|
|
157
|
+
self,
|
|
158
|
+
inventory: CounterInventory,
|
|
159
|
+
prices: RustfavaPriceMap | None = None,
|
|
160
|
+
date: datetime.date | None = None,
|
|
161
|
+
) -> SimpleCounterInventory:
|
|
162
|
+
counter = SimpleCounterInventory()
|
|
163
|
+
for (currency, _cost), number in inventory.items():
|
|
164
|
+
counter.add(currency, number)
|
|
165
|
+
return counter
|
|
166
|
+
|
|
167
|
+
def apply_inventory(
|
|
168
|
+
self,
|
|
169
|
+
inventory: Inventory,
|
|
170
|
+
) -> SimpleCounterInventory:
|
|
171
|
+
"""Apply the conversion to an Beancount Inventory."""
|
|
172
|
+
counter = SimpleCounterInventory()
|
|
173
|
+
for pos in inventory:
|
|
174
|
+
counter.add(pos.units.currency, pos.units.number)
|
|
175
|
+
return counter
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class _CurrencyConversion(Conversion):
|
|
179
|
+
"""Conversion to a list of currencies."""
|
|
180
|
+
|
|
181
|
+
def __init__(self, value: str) -> None:
|
|
182
|
+
self._currencies = tuple(value.split(","))
|
|
183
|
+
|
|
184
|
+
@override
|
|
185
|
+
def apply(
|
|
186
|
+
self,
|
|
187
|
+
inventory: CounterInventory,
|
|
188
|
+
prices: RustfavaPriceMap,
|
|
189
|
+
date: datetime.date | None = None,
|
|
190
|
+
) -> SimpleCounterInventory:
|
|
191
|
+
currencies = iter(self._currencies)
|
|
192
|
+
currency = next(currencies)
|
|
193
|
+
res = inventory.reduce(convert_position, currency, prices, date)
|
|
194
|
+
for currency in currencies:
|
|
195
|
+
res = res.reduce(convert_position, currency, prices, date)
|
|
196
|
+
return res
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
#: Convert position to its total cost.
|
|
200
|
+
AT_COST = _AtCostConversion()
|
|
201
|
+
#: Convert position to its market value.
|
|
202
|
+
AT_VALUE = _AtValueConversion()
|
|
203
|
+
#: Convert position to its units.
|
|
204
|
+
UNITS = _UnitsConversion()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def conversion_from_str(value: str | Conversion) -> Conversion:
|
|
208
|
+
"""Parse a conversion string."""
|
|
209
|
+
if not isinstance(value, str):
|
|
210
|
+
return value
|
|
211
|
+
if value == "at_cost":
|
|
212
|
+
return AT_COST
|
|
213
|
+
if value == "at_value":
|
|
214
|
+
return AT_VALUE
|
|
215
|
+
if value == "units":
|
|
216
|
+
return UNITS
|
|
217
|
+
|
|
218
|
+
return _CurrencyConversion(value)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cost_or_value(
|
|
222
|
+
inventory: CounterInventory,
|
|
223
|
+
conversion: str | Conversion,
|
|
224
|
+
prices: RustfavaPriceMap,
|
|
225
|
+
date: datetime.date | None = None,
|
|
226
|
+
) -> SimpleCounterInventory:
|
|
227
|
+
"""Get the cost or value of an inventory."""
|
|
228
|
+
conversion = conversion_from_str(conversion)
|
|
229
|
+
return conversion.apply(inventory, prices, date)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Document path related helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from os import altsep
|
|
6
|
+
from os import sep
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from rustfava.helpers import RustfavaAPIError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
13
|
+
from rustfava.core import RustfavaLedger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotADocumentsFolderError(RustfavaAPIError):
|
|
17
|
+
"""Not a documents folder."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, folder: str) -> None:
|
|
20
|
+
super().__init__(f"Not a documents folder: {folder}.")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NotAValidAccountError(RustfavaAPIError):
|
|
24
|
+
"""Not a valid account."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, account: str) -> None:
|
|
27
|
+
super().__init__(f"Not a valid account: '{account}'")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_document_or_import_file(filename: str, ledger: RustfavaLedger) -> bool:
|
|
31
|
+
"""Check whether the filename is a document or in an import directory.
|
|
32
|
+
|
|
33
|
+
This is a security validation function that prevents path traversal.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
filename: The filename to check.
|
|
37
|
+
ledger: The RustfavaLedger.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Whether this is one of the documents or a path in an import dir.
|
|
41
|
+
"""
|
|
42
|
+
# Check if it's an exact match for a known document
|
|
43
|
+
if any(
|
|
44
|
+
filename == d.filename for d in ledger.all_entries_by_type.Document
|
|
45
|
+
):
|
|
46
|
+
return True
|
|
47
|
+
# Check if resolved path is within an import directory (prevents path traversal)
|
|
48
|
+
file_path = Path(filename).resolve()
|
|
49
|
+
for import_dir in ledger.fava_options.import_dirs:
|
|
50
|
+
resolved_dir = ledger.join_path(import_dir).resolve()
|
|
51
|
+
if file_path.is_relative_to(resolved_dir):
|
|
52
|
+
return True
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def filepath_in_document_folder(
|
|
57
|
+
documents_folder: str,
|
|
58
|
+
account: str,
|
|
59
|
+
filename: str,
|
|
60
|
+
ledger: RustfavaLedger,
|
|
61
|
+
) -> Path:
|
|
62
|
+
"""File path for a document in the folder for an account.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
documents_folder: The documents folder.
|
|
66
|
+
account: The account to choose the subfolder for.
|
|
67
|
+
filename: The filename of the document.
|
|
68
|
+
ledger: The RustfavaLedger.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The path that the document should be saved at.
|
|
72
|
+
"""
|
|
73
|
+
if documents_folder not in ledger.options["documents"]:
|
|
74
|
+
raise NotADocumentsFolderError(documents_folder)
|
|
75
|
+
|
|
76
|
+
if account not in ledger.attributes.accounts:
|
|
77
|
+
raise NotAValidAccountError(account)
|
|
78
|
+
|
|
79
|
+
filename = filename.replace(sep, " ")
|
|
80
|
+
if altsep: # pragma: no cover
|
|
81
|
+
filename = filename.replace(altsep, " ")
|
|
82
|
+
|
|
83
|
+
return ledger.join_path(
|
|
84
|
+
documents_folder,
|
|
85
|
+
*account.split(":"),
|
|
86
|
+
filename,
|
|
87
|
+
)
|