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/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)
|