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,161 @@
|
|
|
1
|
+
"""Account close date and metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from dataclasses import field
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from rustfava.beans.abc import Balance
|
|
10
|
+
from rustfava.beans.abc import Close
|
|
11
|
+
from rustfava.beans.flags import FLAG_UNREALIZED
|
|
12
|
+
from rustfava.beans.funcs import hash_entry
|
|
13
|
+
from rustfava.core.conversion import UNITS
|
|
14
|
+
from rustfava.core.group_entries import group_entries_by_account
|
|
15
|
+
from rustfava.core.group_entries import TransactionPosting
|
|
16
|
+
from rustfava.core.module_base import FavaModule
|
|
17
|
+
from rustfava.core.tree import Tree
|
|
18
|
+
from rustfava.util.date import local_today
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
21
|
+
import datetime
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from typing import Literal
|
|
24
|
+
|
|
25
|
+
from rustfava.beans.abc import Directive
|
|
26
|
+
from rustfava.beans.abc import Meta
|
|
27
|
+
from rustfava.core.tree import TreeNode
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_last_entry(
|
|
31
|
+
txn_postings: Sequence[Directive | TransactionPosting],
|
|
32
|
+
) -> Directive | None:
|
|
33
|
+
"""Last entry."""
|
|
34
|
+
for txn_posting in reversed(txn_postings):
|
|
35
|
+
if isinstance(txn_posting, TransactionPosting):
|
|
36
|
+
transaction = txn_posting.transaction
|
|
37
|
+
if transaction.flag != FLAG_UNREALIZED:
|
|
38
|
+
return transaction
|
|
39
|
+
else:
|
|
40
|
+
return txn_posting
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def uptodate_status(
|
|
45
|
+
txn_postings: Sequence[Directive | TransactionPosting],
|
|
46
|
+
) -> Literal["green", "yellow", "red"] | None:
|
|
47
|
+
"""Status of the last balance or transaction.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
txn_postings: The TransactionPosting for the account.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A status string for the last balance or transaction of the account.
|
|
54
|
+
|
|
55
|
+
- 'green': A balance check that passed.
|
|
56
|
+
- 'red': A balance check that failed.
|
|
57
|
+
- 'yellow': Not a balance check.
|
|
58
|
+
"""
|
|
59
|
+
for txn_posting in reversed(txn_postings):
|
|
60
|
+
if isinstance(txn_posting, Balance):
|
|
61
|
+
return "red" if txn_posting.diff_amount else "green"
|
|
62
|
+
if (
|
|
63
|
+
isinstance(txn_posting, TransactionPosting)
|
|
64
|
+
and txn_posting.transaction.flag != FLAG_UNREALIZED
|
|
65
|
+
):
|
|
66
|
+
return "yellow"
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def balance_string(tree_node: TreeNode) -> str:
|
|
71
|
+
"""Balance directive for the given account for today."""
|
|
72
|
+
account = tree_node.name
|
|
73
|
+
today = str(local_today())
|
|
74
|
+
res = ""
|
|
75
|
+
for currency, number in UNITS.apply(tree_node.balance).items():
|
|
76
|
+
res += f"{today} balance {account:<28} {number:>15} {currency}\n"
|
|
77
|
+
return res
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class LastEntry:
|
|
82
|
+
"""Date and hash of the last entry for an account."""
|
|
83
|
+
|
|
84
|
+
#: The entry date.
|
|
85
|
+
date: datetime.date
|
|
86
|
+
|
|
87
|
+
#: The entry hash.
|
|
88
|
+
entry_hash: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class AccountData:
|
|
93
|
+
"""Holds information about an account."""
|
|
94
|
+
|
|
95
|
+
#: The date on which this account is closed (or datetime.date.max).
|
|
96
|
+
close_date: datetime.date | None = None
|
|
97
|
+
|
|
98
|
+
#: The metadata of the Open entry of this account.
|
|
99
|
+
meta: Meta = field(default_factory=dict)
|
|
100
|
+
|
|
101
|
+
#: Uptodate status. Is only computed if the account has a
|
|
102
|
+
#: "fava-uptodate-indication" meta attribute.
|
|
103
|
+
uptodate_status: Literal["green", "yellow", "red"] | None = None
|
|
104
|
+
|
|
105
|
+
#: Balance directive if this account has an uptodate status.
|
|
106
|
+
balance_string: str | None = None
|
|
107
|
+
|
|
108
|
+
#: The last entry of the account (unless it is a close Entry)
|
|
109
|
+
last_entry: LastEntry | None = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AccountDict(FavaModule, dict[str, AccountData]):
|
|
113
|
+
"""Account info dictionary."""
|
|
114
|
+
|
|
115
|
+
EMPTY = AccountData()
|
|
116
|
+
|
|
117
|
+
def __missing__(self, key: str) -> AccountData:
|
|
118
|
+
return self.EMPTY
|
|
119
|
+
|
|
120
|
+
def setdefault(
|
|
121
|
+
self,
|
|
122
|
+
key: str,
|
|
123
|
+
_: AccountData | None = None,
|
|
124
|
+
) -> AccountData:
|
|
125
|
+
"""Get the account of the given name, insert one if it is missing."""
|
|
126
|
+
if key not in self:
|
|
127
|
+
self[key] = AccountData()
|
|
128
|
+
return self[key]
|
|
129
|
+
|
|
130
|
+
def load_file(self) -> None: # noqa: D102
|
|
131
|
+
self.clear()
|
|
132
|
+
entries_by_account = group_entries_by_account(self.ledger.all_entries)
|
|
133
|
+
tree = Tree(self.ledger.all_entries)
|
|
134
|
+
for open_entry in self.ledger.all_entries_by_type.Open:
|
|
135
|
+
meta = open_entry.meta
|
|
136
|
+
account_data = self.setdefault(open_entry.account)
|
|
137
|
+
account_data.meta = meta
|
|
138
|
+
|
|
139
|
+
txn_postings = entries_by_account[open_entry.account]
|
|
140
|
+
last = get_last_entry(txn_postings)
|
|
141
|
+
if last is not None and not isinstance(last, Close):
|
|
142
|
+
account_data.last_entry = LastEntry(
|
|
143
|
+
date=last.date,
|
|
144
|
+
entry_hash=hash_entry(last),
|
|
145
|
+
)
|
|
146
|
+
if meta.get("fava-uptodate-indication"):
|
|
147
|
+
account_data.uptodate_status = uptodate_status(txn_postings)
|
|
148
|
+
if account_data.uptodate_status != "green":
|
|
149
|
+
account_data.balance_string = balance_string(
|
|
150
|
+
tree.get(open_entry.account),
|
|
151
|
+
)
|
|
152
|
+
for close in self.ledger.all_entries_by_type.Close:
|
|
153
|
+
self.setdefault(close.account).close_date = close.date
|
|
154
|
+
|
|
155
|
+
def all_balance_directives(self) -> str:
|
|
156
|
+
"""Balance directives for all accounts."""
|
|
157
|
+
return "".join(
|
|
158
|
+
account_details.balance_string
|
|
159
|
+
for account_details in self.values()
|
|
160
|
+
if account_details.balance_string
|
|
161
|
+
)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Attributes for auto-completion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from rustfava.core.module_base import FavaModule
|
|
8
|
+
from rustfava.util.date import END_OF_YEAR
|
|
9
|
+
from rustfava.util.ranking import ExponentialDecayRanker
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
from rustfava.beans.abc import Directive
|
|
15
|
+
from rustfava.beans.abc import Transaction
|
|
16
|
+
from rustfava.core import RustfavaLedger
|
|
17
|
+
from rustfava.util.date import FiscalYearEnd
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_active_years(
|
|
21
|
+
entries: Sequence[Directive],
|
|
22
|
+
fye: FiscalYearEnd,
|
|
23
|
+
) -> list[str]:
|
|
24
|
+
"""Return active years, with support for fiscal years.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
entries: Beancount entries
|
|
28
|
+
fye: fiscal year end
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
A reverse sorted list of years or fiscal years that occur in the
|
|
32
|
+
entries.
|
|
33
|
+
"""
|
|
34
|
+
years = []
|
|
35
|
+
if fye == END_OF_YEAR:
|
|
36
|
+
prev_year = None
|
|
37
|
+
for entry in entries:
|
|
38
|
+
year = entry.date.year
|
|
39
|
+
if year != prev_year:
|
|
40
|
+
prev_year = year
|
|
41
|
+
years.append(year)
|
|
42
|
+
return [f"{year}" for year in reversed(years)]
|
|
43
|
+
month = fye.month
|
|
44
|
+
day = fye.day
|
|
45
|
+
prev_year = None
|
|
46
|
+
for entry in entries:
|
|
47
|
+
date = entry.date
|
|
48
|
+
year = (
|
|
49
|
+
entry.date.year + 1
|
|
50
|
+
if date.month > month or (date.month == month and date.day > day)
|
|
51
|
+
else entry.date.year
|
|
52
|
+
)
|
|
53
|
+
if year != prev_year:
|
|
54
|
+
prev_year = year
|
|
55
|
+
years.append(year)
|
|
56
|
+
return [f"FY{year}" for year in reversed(years)]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AttributesModule(FavaModule):
|
|
60
|
+
"""Some attributes of the ledger (mostly for auto-completion)."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, ledger: RustfavaLedger) -> None:
|
|
63
|
+
super().__init__(ledger)
|
|
64
|
+
self.accounts: Sequence[str] = []
|
|
65
|
+
self.currencies: Sequence[str] = []
|
|
66
|
+
self.payees: Sequence[str] = []
|
|
67
|
+
self.links: Sequence[str] = []
|
|
68
|
+
self.tags: Sequence[str] = []
|
|
69
|
+
self.years: Sequence[str] = []
|
|
70
|
+
|
|
71
|
+
def load_file(self) -> None: # noqa: D102
|
|
72
|
+
all_entries = self.ledger.all_entries
|
|
73
|
+
|
|
74
|
+
all_links = set()
|
|
75
|
+
all_tags = set()
|
|
76
|
+
for entry in all_entries:
|
|
77
|
+
links = getattr(entry, "links", None)
|
|
78
|
+
if links is not None:
|
|
79
|
+
all_links.update(links)
|
|
80
|
+
tags = getattr(entry, "tags", None)
|
|
81
|
+
if tags is not None:
|
|
82
|
+
all_tags.update(tags)
|
|
83
|
+
self.links = sorted(all_links)
|
|
84
|
+
self.tags = sorted(all_tags)
|
|
85
|
+
|
|
86
|
+
self.years = get_active_years(
|
|
87
|
+
all_entries,
|
|
88
|
+
self.ledger.fava_options.fiscal_year_end,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
account_ranker = ExponentialDecayRanker(
|
|
92
|
+
sorted(self.ledger.accounts.keys()),
|
|
93
|
+
)
|
|
94
|
+
currency_ranker = ExponentialDecayRanker()
|
|
95
|
+
payee_ranker = ExponentialDecayRanker()
|
|
96
|
+
|
|
97
|
+
for txn in self.ledger.all_entries_by_type.Transaction:
|
|
98
|
+
if txn.payee:
|
|
99
|
+
payee_ranker.update(txn.payee, txn.date)
|
|
100
|
+
for posting in txn.postings:
|
|
101
|
+
account_ranker.update(posting.account, txn.date)
|
|
102
|
+
# Skip postings with missing units (can happen with parse errors)
|
|
103
|
+
if posting.units is not None:
|
|
104
|
+
currency_ranker.update(posting.units.currency, txn.date)
|
|
105
|
+
if posting.cost and posting.cost.currency is not None:
|
|
106
|
+
currency_ranker.update(posting.cost.currency, txn.date)
|
|
107
|
+
|
|
108
|
+
self.accounts = account_ranker.sort()
|
|
109
|
+
self.currencies = currency_ranker.sort()
|
|
110
|
+
self.payees = payee_ranker.sort()
|
|
111
|
+
|
|
112
|
+
def payee_accounts(self, payee: str) -> Sequence[str]:
|
|
113
|
+
"""Rank accounts for the given payee."""
|
|
114
|
+
account_ranker = ExponentialDecayRanker(self.accounts)
|
|
115
|
+
transactions = self.ledger.all_entries_by_type.Transaction
|
|
116
|
+
for txn in transactions:
|
|
117
|
+
if txn.payee == payee:
|
|
118
|
+
for posting in txn.postings:
|
|
119
|
+
account_ranker.update(posting.account, txn.date)
|
|
120
|
+
return account_ranker.sort()
|
|
121
|
+
|
|
122
|
+
def payee_transaction(self, payee: str) -> Transaction | None:
|
|
123
|
+
"""Get the last transaction for a payee."""
|
|
124
|
+
transactions = self.ledger.all_entries_by_type.Transaction
|
|
125
|
+
for txn in reversed(transactions):
|
|
126
|
+
if txn.payee == payee:
|
|
127
|
+
return txn
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
def narration_transaction(self, narration: str) -> Transaction | None:
|
|
131
|
+
"""Get the last transaction for a narration."""
|
|
132
|
+
transactions = self.ledger.all_entries_by_type.Transaction
|
|
133
|
+
for txn in reversed(transactions):
|
|
134
|
+
if txn.narration == narration:
|
|
135
|
+
return txn
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def narrations(self) -> Sequence[str]:
|
|
140
|
+
"""Get the narrations of all transactions."""
|
|
141
|
+
narration_ranker = ExponentialDecayRanker()
|
|
142
|
+
for txn in self.ledger.all_entries_by_type.Transaction:
|
|
143
|
+
if txn.narration:
|
|
144
|
+
narration_ranker.update(txn.narration, txn.date)
|
|
145
|
+
return narration_ranker.sort()
|
rustfava/core/budgets.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Parsing and computing budgets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import NamedTuple
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from rustfava.core.module_base import FavaModule
|
|
12
|
+
from rustfava.helpers import BeancountError
|
|
13
|
+
from rustfava.util.date import days_in_daterange
|
|
14
|
+
from rustfava.util.date import INTERVALS
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
17
|
+
import datetime
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
|
|
21
|
+
from rustfava.beans.abc import Custom
|
|
22
|
+
from rustfava.core import RustfavaLedger
|
|
23
|
+
from rustfava.util.date import Interval
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Budget(NamedTuple):
|
|
27
|
+
"""A budget entry."""
|
|
28
|
+
|
|
29
|
+
account: str
|
|
30
|
+
date_start: datetime.date
|
|
31
|
+
period: Interval
|
|
32
|
+
number: Decimal
|
|
33
|
+
currency: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
BudgetDict = dict[str, list[Budget]]
|
|
37
|
+
"""A map of account names to lists of budget entries."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BudgetError(BeancountError):
|
|
41
|
+
"""Error with a budget."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BudgetModule(FavaModule):
|
|
45
|
+
"""Parses budget entries."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, ledger: RustfavaLedger) -> None:
|
|
48
|
+
super().__init__(ledger)
|
|
49
|
+
self._budget_entries: BudgetDict = {}
|
|
50
|
+
self.errors: Sequence[BudgetError] = []
|
|
51
|
+
|
|
52
|
+
def load_file(self) -> None: # noqa: D102
|
|
53
|
+
self._budget_entries, self.errors = parse_budgets(
|
|
54
|
+
self.ledger.all_entries_by_type.Custom,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def calculate(
|
|
58
|
+
self,
|
|
59
|
+
account: str,
|
|
60
|
+
begin_date: datetime.date,
|
|
61
|
+
end_date: datetime.date,
|
|
62
|
+
) -> Mapping[str, Decimal]:
|
|
63
|
+
"""Calculate the budget for an account in an interval."""
|
|
64
|
+
return calculate_budget(
|
|
65
|
+
self._budget_entries,
|
|
66
|
+
account,
|
|
67
|
+
begin_date,
|
|
68
|
+
end_date,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def calculate_children(
|
|
72
|
+
self,
|
|
73
|
+
account: str,
|
|
74
|
+
begin_date: datetime.date,
|
|
75
|
+
end_date: datetime.date,
|
|
76
|
+
) -> Mapping[str, Decimal]:
|
|
77
|
+
"""Calculate the budget for an account including its children."""
|
|
78
|
+
return calculate_budget_children(
|
|
79
|
+
self._budget_entries,
|
|
80
|
+
account,
|
|
81
|
+
begin_date,
|
|
82
|
+
end_date,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_budgets(
|
|
87
|
+
custom_entries: Sequence[Custom],
|
|
88
|
+
) -> tuple[BudgetDict, Sequence[BudgetError]]:
|
|
89
|
+
"""Parse budget directives from custom entries.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
custom_entries: the Custom entries to parse budgets from.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
A dict of accounts to lists of budgets.
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
2015-04-09 custom "budget" Expenses:Books "monthly" 20.00 EUR
|
|
99
|
+
"""
|
|
100
|
+
budgets: BudgetDict = defaultdict(list)
|
|
101
|
+
errors = []
|
|
102
|
+
|
|
103
|
+
for entry in (entry for entry in custom_entries if entry.type == "budget"):
|
|
104
|
+
try:
|
|
105
|
+
interval = INTERVALS.get(str(entry.values[1].value).lower())
|
|
106
|
+
if not interval:
|
|
107
|
+
errors.append(
|
|
108
|
+
BudgetError(
|
|
109
|
+
entry.meta,
|
|
110
|
+
"Invalid interval for budget entry",
|
|
111
|
+
entry,
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
continue
|
|
115
|
+
budget = Budget(
|
|
116
|
+
entry.values[0].value,
|
|
117
|
+
entry.date,
|
|
118
|
+
interval,
|
|
119
|
+
entry.values[2].value.number,
|
|
120
|
+
entry.values[2].value.currency,
|
|
121
|
+
)
|
|
122
|
+
budgets[budget.account].append(budget)
|
|
123
|
+
except (IndexError, TypeError):
|
|
124
|
+
errors.append(
|
|
125
|
+
BudgetError(entry.meta, "Failed to parse budget entry", entry),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return budgets, errors
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _matching_budgets(
|
|
132
|
+
budgets: Sequence[Budget],
|
|
133
|
+
date_active: datetime.date,
|
|
134
|
+
) -> Mapping[str, Budget]:
|
|
135
|
+
"""Find matching budgets.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The budget that is active on the specified date for the
|
|
139
|
+
specified account.
|
|
140
|
+
"""
|
|
141
|
+
last_seen_budgets = {}
|
|
142
|
+
for budget in budgets:
|
|
143
|
+
if budget.date_start <= date_active:
|
|
144
|
+
last_seen_budgets[budget.currency] = budget
|
|
145
|
+
else:
|
|
146
|
+
break
|
|
147
|
+
return last_seen_budgets
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def calculate_budget(
|
|
151
|
+
budgets: BudgetDict,
|
|
152
|
+
account: str,
|
|
153
|
+
date_from: datetime.date,
|
|
154
|
+
date_to: datetime.date,
|
|
155
|
+
) -> Mapping[str, Decimal]:
|
|
156
|
+
"""Calculate budget for an account.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
budgets: A list of :class:`Budget` entries.
|
|
160
|
+
account: An account name.
|
|
161
|
+
date_from: Starting date.
|
|
162
|
+
date_to: End date (exclusive).
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
A dictionary of currency to Decimal with the budget for the
|
|
166
|
+
specified account and period.
|
|
167
|
+
"""
|
|
168
|
+
budget_list = budgets.get(account, None)
|
|
169
|
+
if budget_list is None:
|
|
170
|
+
return {}
|
|
171
|
+
|
|
172
|
+
currency_dict: dict[str, Decimal] = defaultdict(Decimal)
|
|
173
|
+
|
|
174
|
+
for day in days_in_daterange(date_from, date_to):
|
|
175
|
+
matches = _matching_budgets(budget_list, day)
|
|
176
|
+
for budget in matches.values():
|
|
177
|
+
days_in_period = budget.period.number_of_days(day)
|
|
178
|
+
currency_dict[budget.currency] += budget.number / days_in_period
|
|
179
|
+
return dict(currency_dict)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def calculate_budget_children(
|
|
183
|
+
budgets: BudgetDict,
|
|
184
|
+
account: str,
|
|
185
|
+
date_from: datetime.date,
|
|
186
|
+
date_to: datetime.date,
|
|
187
|
+
) -> Mapping[str, Decimal]:
|
|
188
|
+
"""Calculate budget for an account including budgets of its children.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
budgets: A list of :class:`Budget` entries.
|
|
192
|
+
account: An account name.
|
|
193
|
+
date_from: Starting date.
|
|
194
|
+
date_to: End date (exclusive).
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
A dictionary of currency to Decimal with the budget for the
|
|
198
|
+
specified account and period.
|
|
199
|
+
"""
|
|
200
|
+
currency_dict: dict[str, Decimal] = Counter() # type: ignore[assignment]
|
|
201
|
+
|
|
202
|
+
for child in budgets:
|
|
203
|
+
if child.startswith(account):
|
|
204
|
+
currency_dict.update(
|
|
205
|
+
calculate_budget(budgets, child, date_from, date_to),
|
|
206
|
+
)
|
|
207
|
+
return dict(currency_dict)
|