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,509 @@
1
+ """Ingest helper functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import os
7
+ import traceback
8
+ from dataclasses import dataclass
9
+ from functools import wraps
10
+ from inspect import get_annotations
11
+ from inspect import signature
12
+ from os import altsep
13
+ from os import sep
14
+ from pathlib import Path
15
+ from runpy import run_path
16
+ from typing import TYPE_CHECKING
17
+
18
+ from beangulp import Importer
19
+
20
+ try: # pragma: no cover
21
+ from beancount.ingest import cache # type: ignore[import-not-found]
22
+ from beancount.ingest import extract
23
+
24
+ DEFAULT_HOOKS = [extract.find_duplicate_entries]
25
+ except ImportError:
26
+ from beangulp import cache
27
+
28
+ DEFAULT_HOOKS = []
29
+
30
+ from rustfava.beans.ingest import BeanImporterProtocol
31
+ from rustfava.core.file import _incomplete_sortkey
32
+ from rustfava.core.module_base import FavaModule
33
+ from rustfava.helpers import BeancountError
34
+ from rustfava.helpers import RustfavaAPIError
35
+ from rustfava.util.date import local_today
36
+
37
+ if TYPE_CHECKING: # pragma: no cover
38
+ from collections.abc import Callable
39
+ from collections.abc import Iterable
40
+ from collections.abc import Mapping
41
+ from collections.abc import Sequence
42
+ from typing import Any
43
+ from typing import ParamSpec
44
+ from typing import TypeVar
45
+
46
+ from rustfava.beans.abc import Directive
47
+ from rustfava.beans.ingest import FileMemo
48
+ from rustfava.core import RustfavaLedger
49
+
50
+ HookOutput = (
51
+ list[tuple[str, list[Directive], str, BeanImporterProtocol | Importer]]
52
+ | list[tuple[str, list[Directive]]]
53
+ )
54
+ Hooks = Sequence[Callable[[HookOutput, Sequence[Directive]], HookOutput]]
55
+
56
+ P = ParamSpec("P")
57
+ T = TypeVar("T")
58
+
59
+
60
+ class IngestError(BeancountError):
61
+ """An error with one of the importers."""
62
+
63
+
64
+ class ImporterMethodCallError(RustfavaAPIError):
65
+ """Error calling one of the importer methods."""
66
+
67
+ def __init__(self) -> None:
68
+ super().__init__(
69
+ f"Error calling method on importer:\n\n{traceback.format_exc()}"
70
+ )
71
+
72
+
73
+ class ImporterInvalidTypeError(RustfavaAPIError):
74
+ """One of the importer methods returned an unexpected type."""
75
+
76
+ def __init__(self, attr: str, expected: type[Any], actual: Any) -> None:
77
+ super().__init__(
78
+ f"Got unexpected type from importer as {attr}:"
79
+ f" expected {expected!s}, got {type(actual)!s}:"
80
+ )
81
+
82
+
83
+ class ImporterExtractError(ImporterMethodCallError):
84
+ """Error calling extract for importer."""
85
+
86
+
87
+ class MissingImporterConfigError(RustfavaAPIError):
88
+ """Missing import-config option."""
89
+
90
+ def __init__(self) -> None:
91
+ super().__init__("Missing import-config option")
92
+
93
+
94
+ class MissingImporterDirsError(RustfavaAPIError):
95
+ """You need to set at least one imports-dir."""
96
+
97
+ def __init__(self) -> None:
98
+ super().__init__("You need to set at least one imports-dir.")
99
+
100
+
101
+ class ImportConfigLoadError(RustfavaAPIError):
102
+ """Error on loading the import config."""
103
+
104
+
105
+ IGNORE_DIRS = {
106
+ ".cache",
107
+ ".git",
108
+ ".hg",
109
+ ".idea",
110
+ ".svn",
111
+ ".tox",
112
+ ".venv",
113
+ "__pycache__",
114
+ "node_modules",
115
+ }
116
+
117
+
118
+ def walk_dir(directory: Path) -> Iterable[Path]:
119
+ """Walk through all files in dir.
120
+
121
+ Ignores common dot-directories like .git, .cache. .venv, see IGNORE_DIRS.
122
+
123
+ Args:
124
+ directory: The directory to start in.
125
+
126
+ Yields:
127
+ All full paths under directory, ignoring some directories.
128
+ """
129
+ for root, dirs, filenames in os.walk(directory):
130
+ dirs[:] = sorted(d for d in dirs if d not in IGNORE_DIRS)
131
+ root_path = Path(root)
132
+ for filename in sorted(filenames):
133
+ yield root_path / filename
134
+
135
+
136
+ # Keep our own cache to also keep track of file mtimes
137
+ _CACHE: dict[Path, tuple[int, FileMemo]] = {}
138
+
139
+
140
+ def get_cached_file(path: Path) -> FileMemo:
141
+ """Get a cached FileMemo.
142
+
143
+ This checks the file's mtime before getting it from the Cache.
144
+ In addition to using the beangulp cache.
145
+ """
146
+ mtime = path.stat().st_mtime_ns
147
+ filename = str(path)
148
+ cached = _CACHE.get(path)
149
+ if cached:
150
+ mtime_cached, memo_cached = cached
151
+ if mtime <= mtime_cached: # pragma: no cover
152
+ return memo_cached
153
+ memo: FileMemo = cache._FileMemo(filename) # noqa: SLF001
154
+ cache._CACHE[filename] = memo # noqa: SLF001
155
+ _CACHE[path] = (mtime, memo)
156
+ return memo
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class FileImportInfo:
161
+ """Info about one file/importer combination."""
162
+
163
+ importer_name: str
164
+ account: str
165
+ date: datetime.date
166
+ name: str
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class FileImporters:
171
+ """Importers for a file."""
172
+
173
+ name: str
174
+ basename: str
175
+ importers: list[FileImportInfo]
176
+
177
+
178
+ def _catch_any(func: Callable[P, T]) -> Callable[P, T]:
179
+ """Helper to catch any exception that might be raised by the importer."""
180
+
181
+ @wraps(func)
182
+ def wrapper(*args: P.args, **kwds: P.kwargs) -> T:
183
+ try:
184
+ return func(*args, **kwds)
185
+ except Exception as err:
186
+ if isinstance(err, ImporterInvalidTypeError):
187
+ raise
188
+ raise ImporterMethodCallError from err
189
+
190
+ return wrapper
191
+
192
+
193
+ def _assert_type(attr: str, value: T, type_: type[T]) -> T:
194
+ """Helper to validate types return by importer methods."""
195
+ if not isinstance(value, type_):
196
+ raise ImporterInvalidTypeError(attr, type_, value)
197
+ return value
198
+
199
+
200
+ class WrappedImporter:
201
+ """A wrapper to safely call importer methods."""
202
+
203
+ importer: BeanImporterProtocol | Importer
204
+
205
+ def __init__(self, importer: BeanImporterProtocol | Importer) -> None:
206
+ self.importer = importer
207
+
208
+ @property
209
+ @_catch_any
210
+ def name(self) -> str:
211
+ """Get the name of the importer."""
212
+ importer = self.importer
213
+ name = (
214
+ importer.name
215
+ if isinstance(importer, Importer)
216
+ else importer.name()
217
+ )
218
+ return _assert_type("name", name, str)
219
+
220
+ @_catch_any
221
+ def identify(self: WrappedImporter, path: Path) -> bool:
222
+ """Whether the importer is matching the file."""
223
+ importer = self.importer
224
+ matches = (
225
+ importer.identify(str(path))
226
+ if isinstance(importer, Importer)
227
+ else importer.identify(get_cached_file(path))
228
+ )
229
+ return _assert_type("identify", matches, bool)
230
+
231
+ @_catch_any
232
+ def file_import_info(self, path: Path) -> FileImportInfo:
233
+ """Generate info about a file with an importer."""
234
+ importer = self.importer
235
+ if isinstance(importer, Importer):
236
+ str_path = str(path)
237
+ account = importer.account(str_path)
238
+ date = importer.date(str_path)
239
+ filename = importer.filename(str_path)
240
+ else:
241
+ file = get_cached_file(path)
242
+ account = importer.file_account(file)
243
+ date = importer.file_date(file)
244
+ filename = importer.file_name(file)
245
+
246
+ return FileImportInfo(
247
+ self.name,
248
+ _assert_type("account", account or "", str),
249
+ _assert_type("date", date or local_today(), datetime.date),
250
+ _assert_type("filename", filename or path.name, str),
251
+ )
252
+
253
+
254
+ # Copied here from beangulp to minimise the imports.
255
+ _FILE_TOO_LARGE_THRESHOLD = 8 * 1024 * 1024
256
+
257
+
258
+ def find_imports(
259
+ config: Sequence[WrappedImporter], directory: Path
260
+ ) -> Iterable[FileImporters]:
261
+ """Pair files and matching importers.
262
+
263
+ Yields:
264
+ For each file in directory, a pair of its filename and the matching
265
+ importers.
266
+ """
267
+ for path in walk_dir(directory):
268
+ stat = path.stat()
269
+ if stat.st_size > _FILE_TOO_LARGE_THRESHOLD: # pragma: no cover
270
+ continue
271
+
272
+ importers = [
273
+ importer.file_import_info(path)
274
+ for importer in config
275
+ if importer.identify(path)
276
+ ]
277
+ yield FileImporters(
278
+ name=str(path), basename=path.name, importers=importers
279
+ )
280
+
281
+
282
+ def extract_from_file(
283
+ wrapped_importer: WrappedImporter,
284
+ path: Path,
285
+ existing_entries: Sequence[Directive],
286
+ ) -> list[Directive]:
287
+ """Import entries from a document.
288
+
289
+ Args:
290
+ wrapped_importer: The importer instance to handle the document.
291
+ path: Filesystem path to the document.
292
+ existing_entries: Existing entries.
293
+
294
+ Returns:
295
+ The list of imported entries.
296
+ """
297
+ filename = str(path)
298
+ importer = wrapped_importer.importer
299
+ if isinstance(importer, Importer):
300
+ entries = importer.extract(filename, existing=existing_entries)
301
+ else:
302
+ file = get_cached_file(path)
303
+ entries = (
304
+ importer.extract(file, existing_entries=existing_entries)
305
+ if "existing_entries" in signature(importer.extract).parameters
306
+ else importer.extract(file)
307
+ ) or []
308
+
309
+ if hasattr(importer, "sort"):
310
+ importer.sort(entries)
311
+ else:
312
+ entries.sort(key=_incomplete_sortkey)
313
+ if isinstance(importer, Importer):
314
+ importer.deduplicate(entries, existing=existing_entries)
315
+ return entries
316
+
317
+
318
+ def load_import_config(
319
+ module_path: Path,
320
+ ) -> tuple[Mapping[str, WrappedImporter], Hooks]:
321
+ """Load the given import config and extract importers and hooks.
322
+
323
+ Args:
324
+ module_path: Path to the import config.
325
+
326
+ Returns:
327
+ A pair of the importers (by name) and the list of hooks.
328
+ """
329
+ try:
330
+ mod = run_path(str(module_path))
331
+ except Exception as error: # pragma: no cover
332
+ message = traceback.format_exc()
333
+ raise ImportConfigLoadError(message) from error
334
+
335
+ if "CONFIG" not in mod:
336
+ msg = "CONFIG is missing"
337
+ raise ImportConfigLoadError(msg)
338
+ if not isinstance(mod["CONFIG"], list): # pragma: no cover
339
+ msg = "CONFIG is not a list"
340
+ raise ImportConfigLoadError(msg)
341
+
342
+ config = mod["CONFIG"]
343
+ hooks = DEFAULT_HOOKS
344
+ if "HOOKS" in mod: # pragma: no cover
345
+ hooks = mod["HOOKS"]
346
+ if not isinstance(hooks, list) or not all(
347
+ callable(fn) for fn in hooks
348
+ ):
349
+ msg = "HOOKS is not a list of callables"
350
+ raise ImportConfigLoadError(msg)
351
+ importers = {}
352
+ for importer in config:
353
+ if not isinstance(
354
+ importer, (BeanImporterProtocol, Importer)
355
+ ): # pragma: no cover
356
+ name = importer.__class__.__name__
357
+ msg = (
358
+ f"Importer class '{name}' in '{module_path}' does "
359
+ "not satisfy importer protocol"
360
+ )
361
+ raise ImportConfigLoadError(msg)
362
+ wrapped_importer = WrappedImporter(importer)
363
+ if wrapped_importer.name in importers:
364
+ msg = f"Duplicate importer name found: {wrapped_importer.name}"
365
+ raise ImportConfigLoadError(msg)
366
+ importers[wrapped_importer.name] = wrapped_importer
367
+ return importers, hooks
368
+
369
+
370
+ class IngestModule(FavaModule):
371
+ """Exposes ingest functionality."""
372
+
373
+ def __init__(self, ledger: RustfavaLedger) -> None:
374
+ super().__init__(ledger)
375
+ self.importers: Mapping[str, WrappedImporter] = {}
376
+ self.hooks: Hooks = []
377
+ self.mtime: int | None = None
378
+ self.errors: list[IngestError] = []
379
+
380
+ @property
381
+ def module_path(self) -> Path | None:
382
+ """The path to the importer configuration."""
383
+ config_path = self.ledger.fava_options.import_config
384
+ if not config_path:
385
+ return None
386
+ return self.ledger.join_path(config_path)
387
+
388
+ def _error(self, msg: str) -> None:
389
+ self.errors.append(
390
+ IngestError(
391
+ {"filename": str(self.module_path), "lineno": 0},
392
+ msg,
393
+ None,
394
+ ),
395
+ )
396
+
397
+ def load_file(self) -> None: # noqa: D102
398
+ self.errors = []
399
+ module_path = self.module_path
400
+ if module_path is None:
401
+ return
402
+
403
+ if not module_path.exists():
404
+ self._error("Import config does not exist")
405
+ return
406
+
407
+ new_mtime = module_path.stat().st_mtime_ns
408
+ if new_mtime == self.mtime:
409
+ return
410
+
411
+ try:
412
+ self.importers, self.hooks = load_import_config(module_path)
413
+ self.mtime = new_mtime
414
+ except RustfavaAPIError as error: # pragma: no cover
415
+ msg = f"Error in import config '{module_path}': {error!s}"
416
+ self._error(msg)
417
+
418
+ def import_data(self) -> list[FileImporters]:
419
+ """Identify files and importers that can be imported.
420
+
421
+ Returns:
422
+ A list of :class:`.FileImportInfo`.
423
+ """
424
+ if not self.importers:
425
+ return []
426
+
427
+ importers = list(self.importers.values())
428
+
429
+ ret: list[FileImporters] = []
430
+ for directory in self.ledger.fava_options.import_dirs:
431
+ full_path = self.ledger.join_path(directory)
432
+ ret.extend(find_imports(importers, full_path))
433
+
434
+ return ret
435
+
436
+ def extract(self, filename: str, importer_name: str) -> list[Directive]:
437
+ """Extract entries from filename with the specified importer.
438
+
439
+ Args:
440
+ filename: The full path to a file.
441
+ importer_name: The name of an importer that matched the file.
442
+
443
+ Returns:
444
+ A list of new imported entries.
445
+ """
446
+ if not self.module_path:
447
+ raise MissingImporterConfigError
448
+
449
+ # reload (if changed)
450
+ self.load_file()
451
+
452
+ try:
453
+ path = Path(filename)
454
+ importer = self.importers[importer_name]
455
+ new_entries = extract_from_file(
456
+ importer,
457
+ path,
458
+ existing_entries=self.ledger.all_entries,
459
+ )
460
+ except Exception as exc:
461
+ raise ImporterExtractError from exc
462
+
463
+ for hook_fn in self.hooks:
464
+ annotations = get_annotations(hook_fn)
465
+ if any("Importer" in a for a in annotations.values()):
466
+ importer_info = importer.file_import_info(path)
467
+ new_entries_list: HookOutput = [
468
+ (
469
+ filename,
470
+ new_entries,
471
+ importer_info.account,
472
+ importer.importer,
473
+ )
474
+ ]
475
+ else:
476
+ new_entries_list = [(filename, new_entries)]
477
+
478
+ new_entries_list = hook_fn(
479
+ new_entries_list,
480
+ self.ledger.all_entries,
481
+ )
482
+
483
+ new_entries = new_entries_list[0][1]
484
+
485
+ return new_entries
486
+
487
+
488
+ def filepath_in_primary_imports_folder(
489
+ filename: str,
490
+ ledger: RustfavaLedger,
491
+ ) -> Path:
492
+ """File path for a document to upload to the primary import folder.
493
+
494
+ Args:
495
+ filename: The filename of the document.
496
+ ledger: The RustfavaLedger.
497
+
498
+ Returns:
499
+ The path that the document should be saved at.
500
+ """
501
+ primary_imports_folder = next(iter(ledger.fava_options.import_dirs), None)
502
+ if primary_imports_folder is None:
503
+ raise MissingImporterDirsError
504
+
505
+ filename = filename.replace(sep, " ")
506
+ if altsep: # pragma: no cover
507
+ filename = filename.replace(altsep, " ")
508
+
509
+ return ledger.join_path(primary_imports_folder, filename)
@@ -0,0 +1,167 @@
1
+ """Alternative implementation of Beancount's Inventory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+ from typing import NamedTuple
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rustfava.beans.protocols import Cost
10
+ from rustfava.beans.str import cost_to_string
11
+
12
+ if TYPE_CHECKING: # pragma: no cover
13
+ import datetime
14
+ from collections.abc import Callable
15
+ from collections.abc import Iterator
16
+ from typing import Concatenate
17
+ from typing import ParamSpec
18
+
19
+ from rustfava.beans.protocols import Amount
20
+ from rustfava.beans.protocols import Position
21
+
22
+ P = ParamSpec("P")
23
+
24
+
25
+ ZERO = Decimal()
26
+ InventoryKey = tuple[str, Cost | None]
27
+
28
+
29
+ class _Amount(NamedTuple):
30
+ number: Decimal
31
+ currency: str
32
+
33
+
34
+ class _Cost(NamedTuple):
35
+ number: Decimal
36
+ currency: str
37
+ date: datetime.date
38
+ label: str | None
39
+
40
+
41
+ class _Position(NamedTuple):
42
+ units: Amount
43
+ cost: Cost | None
44
+
45
+
46
+ class SimpleCounterInventory(dict[str, Decimal]):
47
+ """A simple inventory mapping just strings to numbers."""
48
+
49
+ def is_empty(self) -> bool:
50
+ """Check if the inventory is empty."""
51
+ return not bool(self)
52
+
53
+ def add(self, key: str, number: Decimal) -> None:
54
+ """Add a number to key."""
55
+ new_num = number + self.get(key, ZERO)
56
+ if new_num == ZERO:
57
+ self.pop(key, None)
58
+ else:
59
+ self[key] = new_num
60
+
61
+ def __iter__(self) -> Iterator[str]:
62
+ raise NotImplementedError
63
+
64
+ def __neg__(self) -> SimpleCounterInventory:
65
+ return SimpleCounterInventory({key: -num for key, num in self.items()})
66
+
67
+ def reduce(
68
+ self,
69
+ reducer: Callable[Concatenate[Position, P], Amount],
70
+ *args: P.args,
71
+ **_kwargs: P.kwargs,
72
+ ) -> SimpleCounterInventory:
73
+ """Reduce inventory."""
74
+ counter = SimpleCounterInventory()
75
+ for currency, number in self.items():
76
+ pos = _Position(_Amount(number, currency), None)
77
+ amount = reducer(pos, *args) # type: ignore[call-arg]
78
+ counter.add(amount.currency, amount.number)
79
+ return counter
80
+
81
+
82
+ class CounterInventory(dict[InventoryKey, Decimal]):
83
+ """A lightweight inventory.
84
+
85
+ This is intended as a faster alternative to Beancount's Inventory class.
86
+ Due to not using a list, for inventories with a lot of different positions,
87
+ inserting is much faster.
88
+
89
+ The keys should be tuples ``(currency, cost)``.
90
+ """
91
+
92
+ def is_empty(self) -> bool:
93
+ """Check if the inventory is empty."""
94
+ return not bool(self)
95
+
96
+ def add(self, key: InventoryKey, number: Decimal) -> None:
97
+ """Add a number to key."""
98
+ new_num = number + self.get(key, ZERO)
99
+ if new_num == ZERO:
100
+ self.pop(key, None)
101
+ else:
102
+ self[key] = new_num
103
+
104
+ def __iter__(self) -> Iterator[InventoryKey]:
105
+ raise NotImplementedError
106
+
107
+ def to_strings(self) -> list[str]:
108
+ """Print as a list of strings (e.g. for snapshot tests)."""
109
+ strings = []
110
+ for (currency, cost), number in self.items():
111
+ if cost is None:
112
+ strings.append(f"{number} {currency}")
113
+ else:
114
+ cost_str = cost_to_string(cost)
115
+ strings.append(f"{number} {currency} {{{cost_str}}}")
116
+ return strings
117
+
118
+ def reduce(
119
+ self,
120
+ reducer: Callable[Concatenate[Position, P], Amount],
121
+ *args: P.args,
122
+ **_kwargs: P.kwargs,
123
+ ) -> SimpleCounterInventory:
124
+ """Reduce inventory.
125
+
126
+ Note that this returns a simple :class:`CounterInventory` with just
127
+ currencies as keys.
128
+ """
129
+ counter = SimpleCounterInventory()
130
+ for (currency, cost), number in self.items():
131
+ pos = _Position(_Amount(number, currency), cost)
132
+ amount = reducer(pos, *args) # type: ignore[call-arg]
133
+ counter.add(amount.currency, amount.number)
134
+ return counter
135
+
136
+ def add_amount(self, amount: Amount, cost: Cost | None = None) -> None:
137
+ """Add an Amount to the inventory."""
138
+ key = (amount.currency, cost)
139
+ self.add(key, amount.number)
140
+
141
+ def add_position(self, pos: Position) -> None:
142
+ """Add a Position or Posting to the inventory."""
143
+ # Skip positions with missing units (can happen with parse errors)
144
+ if pos.units is None:
145
+ return
146
+ self.add_amount(pos.units, pos.cost)
147
+
148
+ def __neg__(self) -> CounterInventory:
149
+ return CounterInventory({key: -num for key, num in self.items()})
150
+
151
+ def __add__(self, other: CounterInventory) -> CounterInventory:
152
+ counter = CounterInventory(self)
153
+ counter.add_inventory(other)
154
+ return counter
155
+
156
+ def add_inventory(self, counter: CounterInventory) -> None:
157
+ """Add another :class:`CounterInventory`."""
158
+ if not self:
159
+ self.update(counter)
160
+ else:
161
+ self_get = self.get
162
+ for key, num in counter.items():
163
+ new_num = num + self_get(key, ZERO)
164
+ if new_num == ZERO:
165
+ self.pop(key, None)
166
+ else:
167
+ self[key] = new_num