rustfava 0.1.0__py3-none-any.whl

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