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
rustfava/core/file.py ADDED
@@ -0,0 +1,542 @@
1
+ """Reading/writing Beancount files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import threading
8
+ from codecs import encode
9
+ from dataclasses import replace
10
+ from hashlib import sha256
11
+ from operator import attrgetter
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from markupsafe import Markup
16
+
17
+ from rustfava.beans.abc import Balance
18
+ from rustfava.beans.abc import Close
19
+ from rustfava.beans.abc import Document
20
+ from rustfava.beans.abc import Open
21
+ from rustfava.beans.abc import Transaction
22
+ from rustfava.beans.account import get_entry_accounts
23
+ from rustfava.beans.flags import FLAG_CONVERSIONS
24
+ from rustfava.beans.flags import FLAG_MERGING
25
+ from rustfava.beans.flags import FLAG_PADDING
26
+ from rustfava.beans.flags import FLAG_RETURNS
27
+ from rustfava.beans.flags import FLAG_SUMMARIZE
28
+ from rustfava.beans.flags import FLAG_TRANSFER
29
+ from rustfava.beans.flags import FLAG_UNREALIZED
30
+ from rustfava.beans.funcs import get_position
31
+ from rustfava.beans.str import to_string
32
+ from rustfava.core.module_base import FavaModule
33
+ from rustfava.helpers import RustfavaAPIError
34
+ from rustfava.util import next_key
35
+
36
+ if TYPE_CHECKING: # pragma: no cover
37
+ import datetime
38
+ from collections.abc import Iterable
39
+ from collections.abc import Sequence
40
+
41
+ from rustfava.beans.abc import Directive
42
+ from rustfava.core import RustfavaLedger
43
+ from rustfava.core.fava_options import InsertEntryOption
44
+
45
+ #: The flags to exclude when rendering entries.
46
+ _EXCL_FLAGS = {
47
+ FLAG_PADDING, # P
48
+ FLAG_SUMMARIZE, # S
49
+ FLAG_TRANSFER, # T
50
+ FLAG_CONVERSIONS, # C
51
+ FLAG_UNREALIZED, # U
52
+ FLAG_RETURNS, # R
53
+ FLAG_MERGING, # M
54
+ }
55
+
56
+
57
+ def _sha256_str(val: str) -> str:
58
+ """Hash a string."""
59
+ return sha256(encode(val, encoding="utf-8")).hexdigest()
60
+
61
+
62
+ class NonSourceFileError(RustfavaAPIError):
63
+ """Trying to read a non-source file."""
64
+
65
+ def __init__(self, path: Path) -> None:
66
+ super().__init__(f"Trying to read a non-source file at '{path}'")
67
+
68
+
69
+ class ExternallyChangedError(RustfavaAPIError):
70
+ """The file changed externally."""
71
+
72
+ def __init__(self, path: Path) -> None:
73
+ super().__init__(f"The file at '{path}' changed externally.")
74
+
75
+
76
+ class GeneratedEntryError(RustfavaAPIError):
77
+ """The entry is generated and cannot be edited."""
78
+
79
+ def __init__(self) -> None:
80
+ super().__init__("The entry is generated and cannot be edited.")
81
+
82
+
83
+ class InvalidUnicodeError(RustfavaAPIError):
84
+ """The source file contains invalid unicode."""
85
+
86
+ def __init__(self, reason: str) -> None:
87
+ super().__init__(
88
+ f"The source file contains invalid unicode: {reason}.",
89
+ )
90
+
91
+
92
+ def _get_position(entry: Directive) -> tuple[Path, int]:
93
+ """Get the entry position, checking for generated entries."""
94
+ filename, lineno = get_position(entry)
95
+ if filename.startswith("<") or not lineno:
96
+ raise GeneratedEntryError
97
+ return Path(filename), lineno
98
+
99
+
100
+ def _file_newline_character(path: Path) -> str:
101
+ """Get the newline character of the file by looking at the first line."""
102
+ with path.open("rb") as file:
103
+ firstline = file.readline()
104
+ if firstline.endswith(b"\r\n"):
105
+ return "\r\n"
106
+ if firstline.endswith(b"\n"):
107
+ return "\n"
108
+ return os.linesep
109
+
110
+
111
+ class FileModule(FavaModule):
112
+ """Functions related to reading/writing to Beancount files."""
113
+
114
+ def __init__(self, ledger: RustfavaLedger) -> None:
115
+ super().__init__(ledger)
116
+ self._lock = threading.Lock()
117
+
118
+ def get_source(self, path: Path) -> tuple[str, str]:
119
+ """Get source files.
120
+
121
+ Args:
122
+ path: The path of the file.
123
+
124
+ Returns:
125
+ A string with the file contents and the `sha256sum` of the file.
126
+
127
+ Raises:
128
+ NonSourceFileError: If the file is not one of the source files.
129
+ InvalidUnicodeError: If the file contains invalid unicode.
130
+ """
131
+ if str(path) not in self.ledger.options["include"]:
132
+ raise NonSourceFileError(path)
133
+
134
+ try:
135
+ source = path.read_text("utf-8")
136
+ except UnicodeDecodeError as exc:
137
+ raise InvalidUnicodeError(str(exc)) from exc
138
+
139
+ return source, _sha256_str(source)
140
+
141
+ def set_source(self, path: Path, source: str, sha256sum: str) -> str:
142
+ """Write to source file.
143
+
144
+ Args:
145
+ path: The path of the file.
146
+ source: A string with the file contents.
147
+ sha256sum: Hash of the file.
148
+
149
+ Returns:
150
+ The `sha256sum` of the updated file.
151
+
152
+ Raises:
153
+ NonSourceFileError: If the file is not one of the source files.
154
+ InvalidUnicodeError: If the file contains invalid unicode.
155
+ ExternallyChangedError: If the file was changed externally.
156
+ """
157
+ with self._lock:
158
+ _, original_sha256sum = self.get_source(path)
159
+ if original_sha256sum != sha256sum:
160
+ raise ExternallyChangedError(path)
161
+
162
+ newline = _file_newline_character(path)
163
+ with path.open("w", encoding="utf-8", newline=newline) as file:
164
+ file.write(source)
165
+ self.ledger.watcher.notify(path)
166
+
167
+ self.ledger.extensions.after_write_source(str(path), source)
168
+ self.ledger.load_file()
169
+
170
+ return _sha256_str(source)
171
+
172
+ def insert_metadata(
173
+ self,
174
+ entry_hash: str,
175
+ basekey: str,
176
+ value: str,
177
+ ) -> None:
178
+ """Insert metadata into a file at lineno.
179
+
180
+ Also, prevent duplicate keys.
181
+
182
+ Args:
183
+ entry_hash: Hash of an entry.
184
+ basekey: Key to insert metadata for.
185
+ value: Metadate value to insert.
186
+ """
187
+ with self._lock:
188
+ self.ledger.changed()
189
+ entry = self.ledger.get_entry(entry_hash)
190
+ key = next_key(basekey, entry.meta)
191
+ indent = self.ledger.fava_options.indent
192
+ path, lineno = _get_position(entry)
193
+ insert_metadata_in_file(path, lineno, indent, key, value)
194
+ self.ledger.watcher.notify(path)
195
+ self.ledger.extensions.after_insert_metadata(entry, key, value)
196
+
197
+ def save_entry_slice(
198
+ self,
199
+ entry_hash: str,
200
+ source_slice: str,
201
+ sha256sum: str,
202
+ ) -> str:
203
+ """Save slice of the source file for an entry.
204
+
205
+ Args:
206
+ entry_hash: Hash of an entry.
207
+ source_slice: The lines that the entry should be replaced with.
208
+ sha256sum: The sha256sum of the current lines of the entry.
209
+
210
+ Returns:
211
+ The `sha256sum` of the new lines of the entry.
212
+
213
+ Raises:
214
+ RustfavaAPIError: If the entry is not found or the file changed.
215
+ """
216
+ with self._lock:
217
+ entry = self.ledger.get_entry(entry_hash)
218
+ new_sha256sum = save_entry_slice(entry, source_slice, sha256sum)
219
+ self.ledger.watcher.notify(Path(get_position(entry)[0]))
220
+ self.ledger.extensions.after_entry_modified(entry, source_slice)
221
+ return new_sha256sum
222
+
223
+ def delete_entry_slice(self, entry_hash: str, sha256sum: str) -> None:
224
+ """Delete slice of the source file for an entry.
225
+
226
+ Args:
227
+ entry_hash: Hash of an entry.
228
+ sha256sum: The sha256sum of the current lines of the entry.
229
+
230
+ Raises:
231
+ RustfavaAPIError: If the entry is not found or the file changed.
232
+ """
233
+ with self._lock:
234
+ entry = self.ledger.get_entry(entry_hash)
235
+ delete_entry_slice(entry, sha256sum)
236
+ self.ledger.watcher.notify(Path(get_position(entry)[0]))
237
+ self.ledger.extensions.after_delete_entry(entry)
238
+
239
+ def insert_entries(self, entries: Sequence[Directive]) -> None:
240
+ """Insert entries.
241
+
242
+ Args:
243
+ entries: A list of entries.
244
+ """
245
+ with self._lock:
246
+ self.ledger.changed()
247
+ fava_options = self.ledger.fava_options
248
+ for entry in sorted(entries, key=_incomplete_sortkey):
249
+ path, updated_insert_options = insert_entry(
250
+ entry,
251
+ (
252
+ self.ledger.fava_options.default_file
253
+ or self.ledger.beancount_file_path
254
+ ),
255
+ insert_options=fava_options.insert_entry,
256
+ currency_column=fava_options.currency_column,
257
+ indent=fava_options.indent,
258
+ )
259
+ self.ledger.watcher.notify(path)
260
+ self.ledger.fava_options.insert_entry = updated_insert_options
261
+ self.ledger.extensions.after_insert_entry(entry)
262
+
263
+ def render_entries(self, entries: Sequence[Directive]) -> Iterable[Markup]:
264
+ """Return entries in Beancount format.
265
+
266
+ Only renders :class:`.Balance` and :class:`.Transaction`.
267
+
268
+ Args:
269
+ entries: A list of entries.
270
+
271
+ Yields:
272
+ The entries rendered in Beancount format.
273
+ """
274
+ indent = self.ledger.fava_options.indent
275
+ for entry in entries:
276
+ if isinstance(entry, (Balance, Transaction)):
277
+ if (
278
+ isinstance(entry, Transaction)
279
+ and entry.flag in _EXCL_FLAGS
280
+ ):
281
+ continue
282
+ try:
283
+ yield Markup(get_entry_slice(entry)[0] + "\n") # noqa: S704
284
+ except (KeyError, FileNotFoundError):
285
+ yield Markup( # noqa: S704
286
+ to_string(
287
+ entry,
288
+ self.ledger.fava_options.currency_column,
289
+ indent,
290
+ ),
291
+ )
292
+
293
+
294
+ def _incomplete_sortkey(entry: Directive) -> tuple[datetime.date, int]:
295
+ """Sortkey for entries that might have incomplete metadata."""
296
+ if isinstance(entry, Open):
297
+ return (entry.date, -2)
298
+ if isinstance(entry, Balance):
299
+ return (entry.date, -1)
300
+ if isinstance(entry, Document):
301
+ return (entry.date, 1)
302
+ if isinstance(entry, Close):
303
+ return (entry.date, 2)
304
+ return (entry.date, 0)
305
+
306
+
307
+ def insert_metadata_in_file(
308
+ path: Path,
309
+ lineno: int,
310
+ indent: int,
311
+ key: str,
312
+ value: str,
313
+ ) -> None:
314
+ """Insert the specified metadata in the file below lineno.
315
+
316
+ Takes the whitespace in front of the line that lineno into account.
317
+ """
318
+ with path.open(encoding="utf-8") as file:
319
+ contents = file.readlines()
320
+
321
+ contents.insert(lineno, f'{" " * indent}{key}: "{value}"\n')
322
+ newline = _file_newline_character(path)
323
+ with path.open("w", encoding="utf-8", newline=newline) as file:
324
+ file.write("".join(contents))
325
+
326
+
327
+ def find_entry_lines(lines: Sequence[str], lineno: int) -> Sequence[str]:
328
+ """Lines of entry starting at lineno.
329
+
330
+ Args:
331
+ lines: A list of lines.
332
+ lineno: The 0-based line-index to start at.
333
+ """
334
+ entry_lines = [lines[lineno]]
335
+ while True:
336
+ lineno += 1
337
+ try:
338
+ line = lines[lineno]
339
+ except IndexError:
340
+ return entry_lines
341
+ if not line.strip() or re.match(r"\S", line[0]):
342
+ return entry_lines
343
+ entry_lines.append(line)
344
+
345
+
346
+ def get_entry_slice(entry: Directive) -> tuple[str, str]:
347
+ """Get slice of the source file for an entry.
348
+
349
+ Args:
350
+ entry: An entry.
351
+
352
+ Returns:
353
+ A string containing the lines of the entry and the `sha256sum` of
354
+ these lines.
355
+
356
+ Raises:
357
+ GeneratedEntryError: If the entry is generated and cannot be edited.
358
+ """
359
+ path, lineno = _get_position(entry)
360
+ with path.open(encoding="utf-8") as file:
361
+ lines = file.readlines()
362
+
363
+ entry_lines = find_entry_lines(lines, lineno - 1)
364
+ entry_source = "".join(entry_lines).rstrip("\n")
365
+
366
+ return entry_source, _sha256_str(entry_source)
367
+
368
+
369
+ def save_entry_slice(
370
+ entry: Directive,
371
+ source_slice: str,
372
+ sha256sum: str,
373
+ ) -> str:
374
+ """Save slice of the source file for an entry.
375
+
376
+ Args:
377
+ entry: An entry.
378
+ source_slice: The lines that the entry should be replaced with.
379
+ sha256sum: The sha256sum of the current lines of the entry.
380
+
381
+ Returns:
382
+ The `sha256sum` of the new lines of the entry.
383
+
384
+ Raises:
385
+ ExternallyChangedError: If the file was changed externally.
386
+ GeneratedEntryError: If the entry is generated and cannot be edited.
387
+ """
388
+ path, lineno = _get_position(entry)
389
+ with path.open(encoding="utf-8") as file:
390
+ lines = file.readlines()
391
+
392
+ first_entry_line = lineno - 1
393
+ entry_lines = find_entry_lines(lines, first_entry_line)
394
+ entry_source = "".join(entry_lines).rstrip("\n")
395
+ if _sha256_str(entry_source) != sha256sum:
396
+ raise ExternallyChangedError(path)
397
+
398
+ lines = [
399
+ *lines[:first_entry_line],
400
+ source_slice + "\n",
401
+ *lines[first_entry_line + len(entry_lines) :],
402
+ ]
403
+ newline = _file_newline_character(path)
404
+ with path.open("w", encoding="utf-8", newline=newline) as file:
405
+ file.writelines(lines)
406
+
407
+ return _sha256_str(source_slice)
408
+
409
+
410
+ def delete_entry_slice(
411
+ entry: Directive,
412
+ sha256sum: str,
413
+ ) -> None:
414
+ """Delete slice of the source file for an entry.
415
+
416
+ Args:
417
+ entry: An entry.
418
+ sha256sum: The sha256sum of the current lines of the entry.
419
+
420
+ Raises:
421
+ ExternallyChangedError: If the file was changed externally.
422
+ GeneratedEntryError: If the entry is generated and cannot be edited.
423
+ """
424
+ path, lineno = _get_position(entry)
425
+ with path.open(encoding="utf-8") as file:
426
+ lines = file.readlines()
427
+
428
+ first_entry_line = lineno - 1
429
+ entry_lines = find_entry_lines(lines, first_entry_line)
430
+ entry_source = "".join(entry_lines).rstrip("\n")
431
+ if _sha256_str(entry_source) != sha256sum:
432
+ raise ExternallyChangedError(path)
433
+
434
+ # Also delete the whitespace following this entry
435
+ last_entry_line = first_entry_line + len(entry_lines)
436
+ while True:
437
+ try:
438
+ line = lines[last_entry_line]
439
+ except IndexError:
440
+ break
441
+ if line.strip(): # pragma: no cover
442
+ break
443
+ last_entry_line += 1 # pragma: no cover
444
+ lines = lines[:first_entry_line] + lines[last_entry_line:]
445
+ newline = _file_newline_character(path)
446
+ with path.open("w", encoding="utf-8", newline=newline) as file:
447
+ file.writelines(lines)
448
+
449
+
450
+ def insert_entry(
451
+ entry: Directive,
452
+ default_filename: str,
453
+ insert_options: Sequence[InsertEntryOption],
454
+ currency_column: int,
455
+ indent: int,
456
+ ) -> tuple[Path, Sequence[InsertEntryOption]]:
457
+ """Insert an entry.
458
+
459
+ Args:
460
+ entry: An entry.
461
+ default_filename: The default file to insert into if no option matches.
462
+ insert_options: Insert options.
463
+ currency_column: The column to align currencies at.
464
+ indent: Number of indent spaces.
465
+
466
+ Returns:
467
+ A changed path and list of updated insert options.
468
+ """
469
+ filename, lineno = find_insert_position(
470
+ entry,
471
+ insert_options,
472
+ default_filename,
473
+ )
474
+ content = to_string(entry, currency_column, indent)
475
+
476
+ path = Path(filename)
477
+ with path.open(encoding="utf-8") as file:
478
+ contents = file.readlines()
479
+
480
+ if lineno is None:
481
+ # Appending
482
+ contents += "\n" + content
483
+ else:
484
+ contents.insert(lineno, content + "\n")
485
+
486
+ newline = _file_newline_character(path)
487
+ with path.open("w", encoding="utf-8", newline=newline) as file:
488
+ file.writelines(contents)
489
+
490
+ if lineno is None:
491
+ return (path, insert_options)
492
+
493
+ added_lines = content.count("\n") + 1
494
+ return (
495
+ path,
496
+ [
497
+ (
498
+ replace(option, lineno=option.lineno + added_lines)
499
+ if option.filename == filename and option.lineno > lineno
500
+ else option
501
+ )
502
+ for option in insert_options
503
+ ],
504
+ )
505
+
506
+
507
+ def find_insert_position(
508
+ entry: Directive,
509
+ insert_options: Sequence[InsertEntryOption],
510
+ default_filename: str,
511
+ ) -> tuple[str, int | None]:
512
+ """Find insert position for an entry.
513
+
514
+ Args:
515
+ entry: An entry.
516
+ insert_options: A list of InsertOption.
517
+ default_filename: The default file to insert into if no option matches.
518
+
519
+ Returns:
520
+ A tuple of the filename and the line number.
521
+ """
522
+ # Get the list of accounts that should be considered for the entry.
523
+ # For transactions, we want the reversed list of posting accounts.
524
+ accounts = get_entry_accounts(entry)
525
+
526
+ # Make no assumptions about the order of insert_options entries and instead
527
+ # sort them ourselves (by descending dates)
528
+ insert_options = sorted(
529
+ insert_options,
530
+ key=attrgetter("date"),
531
+ reverse=True,
532
+ )
533
+
534
+ for account in accounts:
535
+ for insert_option in insert_options:
536
+ # Only consider InsertOptions before the entry date.
537
+ if insert_option.date >= entry.date:
538
+ continue
539
+ if insert_option.re.match(account):
540
+ return (insert_option.filename, insert_option.lineno - 1)
541
+
542
+ return (default_filename, None)