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/ingest.py
ADDED
|
@@ -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
|