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/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Rustfava - A web interface for rustledger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
LOCALES = [
|
|
6
|
+
"bg",
|
|
7
|
+
"ca",
|
|
8
|
+
"de",
|
|
9
|
+
"es",
|
|
10
|
+
"fa",
|
|
11
|
+
"fr",
|
|
12
|
+
"ja",
|
|
13
|
+
"nl",
|
|
14
|
+
"pt",
|
|
15
|
+
"pt_BR",
|
|
16
|
+
"ru",
|
|
17
|
+
"sk",
|
|
18
|
+
"sv",
|
|
19
|
+
"uk",
|
|
20
|
+
"zh",
|
|
21
|
+
"zh_Hant_TW",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def __getattr__(name: str) -> str:
|
|
26
|
+
if name == "__version__":
|
|
27
|
+
from importlib.metadata import version
|
|
28
|
+
|
|
29
|
+
return version("rustfava")
|
|
30
|
+
raise AttributeError(name)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Specify types for the flask application context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from flask import request
|
|
9
|
+
|
|
10
|
+
from rustfava.core.conversion import conversion_from_str
|
|
11
|
+
from rustfava.util.date import INTERVALS
|
|
12
|
+
from rustfava.util.date import Month
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
15
|
+
from rustfava.core import RustfavaLedger
|
|
16
|
+
from rustfava.core import FilteredLedger
|
|
17
|
+
from rustfava.core.conversion import Conversion
|
|
18
|
+
from rustfava.ext import RustfavaExtensionBase
|
|
19
|
+
from rustfava.util.date import Interval
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Context:
|
|
23
|
+
"""The context values - this is used for `flask.g`."""
|
|
24
|
+
|
|
25
|
+
#: Slug for the active Beancount file.
|
|
26
|
+
beancount_file_slug: str | None
|
|
27
|
+
#: The ledger
|
|
28
|
+
ledger: RustfavaLedger
|
|
29
|
+
#: The current extension, if this is an extension endpoint
|
|
30
|
+
extension: RustfavaExtensionBase | None
|
|
31
|
+
|
|
32
|
+
@cached_property
|
|
33
|
+
def conversion(self) -> str:
|
|
34
|
+
"""Conversion to apply (raw string)."""
|
|
35
|
+
return request.args.get("conversion", "") or "at_cost"
|
|
36
|
+
|
|
37
|
+
@cached_property
|
|
38
|
+
def conv(self) -> Conversion:
|
|
39
|
+
"""Conversion to apply (parsed)."""
|
|
40
|
+
return conversion_from_str(self.conversion)
|
|
41
|
+
|
|
42
|
+
@cached_property
|
|
43
|
+
def interval(self) -> Interval:
|
|
44
|
+
"""Interval to group by."""
|
|
45
|
+
return INTERVALS.get(request.args.get("interval", "").lower(), Month)
|
|
46
|
+
|
|
47
|
+
@cached_property
|
|
48
|
+
def filtered(self) -> FilteredLedger:
|
|
49
|
+
"""The filtered ledger."""
|
|
50
|
+
args = request.args
|
|
51
|
+
return self.ledger.get_filtered(
|
|
52
|
+
account=args.get("account", ""),
|
|
53
|
+
filter=args.get("filter", ""),
|
|
54
|
+
time=args.get("time", ""),
|
|
55
|
+
)
|
rustfava/api_models.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Pydantic models for API request validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from pydantic import Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SaveSourceRequest(BaseModel):
|
|
10
|
+
"""Request to save source file contents."""
|
|
11
|
+
|
|
12
|
+
file_path: str = Field(min_length=1, description="Path to source file")
|
|
13
|
+
source: str = Field(description="New file contents")
|
|
14
|
+
sha256sum: str = Field(
|
|
15
|
+
min_length=64,
|
|
16
|
+
max_length=64,
|
|
17
|
+
description="SHA256 hash of original contents for conflict detection",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SaveEntrySliceRequest(BaseModel):
|
|
22
|
+
"""Request to save an entry source slice."""
|
|
23
|
+
|
|
24
|
+
entry_hash: str = Field(min_length=1, description="Hash of entry to modify")
|
|
25
|
+
source: str = Field(description="New entry source")
|
|
26
|
+
sha256sum: str = Field(
|
|
27
|
+
min_length=64,
|
|
28
|
+
max_length=64,
|
|
29
|
+
description="SHA256 hash of original slice for conflict detection",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FormatSourceRequest(BaseModel):
|
|
34
|
+
"""Request to format beancount source."""
|
|
35
|
+
|
|
36
|
+
source: str = Field(description="Source to format")
|
rustfava/application.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""rustfava's main WSGI application.
|
|
2
|
+
|
|
3
|
+
you can use `create_app` to create a rustfava WSGI app for a given list of files.
|
|
4
|
+
To start a simple server::
|
|
5
|
+
|
|
6
|
+
from rustfava.application import create_app
|
|
7
|
+
|
|
8
|
+
app = create_app(['/path/to/file.beancount'])
|
|
9
|
+
app.run('localhost', 5000)
|
|
10
|
+
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import gzip
|
|
16
|
+
import logging
|
|
17
|
+
import mimetypes
|
|
18
|
+
from datetime import date
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from datetime import timezone
|
|
21
|
+
from functools import lru_cache
|
|
22
|
+
from io import BytesIO
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from threading import Lock
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
from urllib.parse import parse_qsl
|
|
27
|
+
from urllib.parse import urlencode
|
|
28
|
+
from urllib.parse import urlparse
|
|
29
|
+
from urllib.parse import urlunparse
|
|
30
|
+
|
|
31
|
+
from flask import abort
|
|
32
|
+
from flask import current_app
|
|
33
|
+
from flask import Flask
|
|
34
|
+
from flask import redirect
|
|
35
|
+
from flask import render_template
|
|
36
|
+
from flask import render_template_string
|
|
37
|
+
from flask import request
|
|
38
|
+
from flask import send_file
|
|
39
|
+
from flask import url_for as flask_url_for
|
|
40
|
+
from flask_babel import Babel
|
|
41
|
+
from flask_babel import get_translations
|
|
42
|
+
from markupsafe import Markup
|
|
43
|
+
from werkzeug.utils import secure_filename
|
|
44
|
+
|
|
45
|
+
from rustfava import LOCALES
|
|
46
|
+
from rustfava import template_filters
|
|
47
|
+
from rustfava._ctx_globals_class import Context
|
|
48
|
+
from rustfava.beans import funcs
|
|
49
|
+
from rustfava.context import g
|
|
50
|
+
from rustfava.core import RustfavaLedger
|
|
51
|
+
from rustfava.core.charts import RustfavaJSONProvider
|
|
52
|
+
from rustfava.core.documents import is_document_or_import_file
|
|
53
|
+
from rustfava.help import HELP_PAGES
|
|
54
|
+
from rustfava.helpers import RustfavaAPIError
|
|
55
|
+
from rustfava.internal_api import ChartApi
|
|
56
|
+
from rustfava.internal_api import get_ledger_data
|
|
57
|
+
from rustfava.json_api import json_api
|
|
58
|
+
from rustfava.util import next_key
|
|
59
|
+
from rustfava.util import send_file_inline
|
|
60
|
+
from rustfava.util import setup_logging
|
|
61
|
+
from rustfava.util import slugify
|
|
62
|
+
from rustfava.util.excel import HAVE_EXCEL
|
|
63
|
+
|
|
64
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
65
|
+
from collections.abc import ItemsView
|
|
66
|
+
from collections.abc import Iterable
|
|
67
|
+
|
|
68
|
+
from flask.wrappers import Response
|
|
69
|
+
from werkzeug import Response as WerkzeugResponse
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
setup_logging()
|
|
73
|
+
|
|
74
|
+
CLIENT_SIDE_REPORTS = [
|
|
75
|
+
"balance_sheet",
|
|
76
|
+
"commodities",
|
|
77
|
+
"documents",
|
|
78
|
+
"editor",
|
|
79
|
+
"errors",
|
|
80
|
+
"events",
|
|
81
|
+
"holdings",
|
|
82
|
+
"import",
|
|
83
|
+
"journal",
|
|
84
|
+
"income_statement",
|
|
85
|
+
"options",
|
|
86
|
+
"query",
|
|
87
|
+
"statistics",
|
|
88
|
+
"trial_balance",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
log = logging.getLogger(__name__)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if not mimetypes.types_map.get(".js", "").endswith(
|
|
95
|
+
"/javascript"
|
|
96
|
+
): # pragma: no cover
|
|
97
|
+
# This is sometimes broken on windows, see
|
|
98
|
+
# https://github.com/beancount/fava/issues/1446
|
|
99
|
+
log.error("Invalid mimetype set for '.js', overriding")
|
|
100
|
+
mimetypes.add_type("text/javascript", ".js")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _slug(ledger: RustfavaLedger) -> str:
|
|
104
|
+
"""Slug for a ledger."""
|
|
105
|
+
title_slug = slugify(ledger.options["title"])
|
|
106
|
+
return title_slug or slugify(ledger.beancount_file_path)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _LedgerSlugLoader:
|
|
110
|
+
"""Load multiple ledgers and access them by their slug."""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
fava_app: Flask,
|
|
115
|
+
*,
|
|
116
|
+
load: bool = False,
|
|
117
|
+
poll_watcher: bool = False,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.fava_app = fava_app
|
|
120
|
+
self.poll_watcher = poll_watcher
|
|
121
|
+
|
|
122
|
+
self._lock = Lock()
|
|
123
|
+
|
|
124
|
+
# The loaded ledgers - lazily loaded unless load=True
|
|
125
|
+
self._ledgers = None
|
|
126
|
+
# The titles of the ledgers - used to check whether the ledgers_by_slug
|
|
127
|
+
# below needs to be re-computed
|
|
128
|
+
self._titles: list[str] | None = None
|
|
129
|
+
# Cache the dict of ledgers by their slugs
|
|
130
|
+
self._ledgers_by_slug: dict[str, RustfavaLedger] | None = None
|
|
131
|
+
|
|
132
|
+
if load:
|
|
133
|
+
with self._lock:
|
|
134
|
+
self._ledgers = self._load()
|
|
135
|
+
|
|
136
|
+
def _load(self) -> list[RustfavaLedger]:
|
|
137
|
+
return [
|
|
138
|
+
RustfavaLedger(path, poll_watcher=self.poll_watcher)
|
|
139
|
+
for path in self.fava_app.config["BEANCOUNT_FILES"]
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def ledgers(self) -> list[RustfavaLedger]:
|
|
144
|
+
"""Return the list of loaded ledgers (loading it if not yet done)."""
|
|
145
|
+
if self._ledgers is None:
|
|
146
|
+
with self._lock:
|
|
147
|
+
# avoid loading it already loaded while waiting for the lock
|
|
148
|
+
if self._ledgers is None: # pragma: no cover
|
|
149
|
+
self._ledgers = self._load()
|
|
150
|
+
return self._ledgers # ty:ignore[invalid-return-type]
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def ledgers_by_slug(self) -> dict[str, RustfavaLedger]:
|
|
154
|
+
"""A dict mapping slugs to the loaded ledgers."""
|
|
155
|
+
ledgers = self.ledgers
|
|
156
|
+
titles = [ledger.options["title"] for ledger in ledgers]
|
|
157
|
+
if self._ledgers_by_slug is None or self._titles != titles:
|
|
158
|
+
by_slug: dict[str, RustfavaLedger] = {}
|
|
159
|
+
for ledger in ledgers:
|
|
160
|
+
by_slug[next_key(_slug(ledger), by_slug)] = ledger
|
|
161
|
+
self._ledgers_by_slug = by_slug
|
|
162
|
+
self._titles = titles
|
|
163
|
+
return self._ledgers_by_slug
|
|
164
|
+
|
|
165
|
+
def first_slug(self) -> str:
|
|
166
|
+
"""Get the slug of the first ledger."""
|
|
167
|
+
return _slug(self.ledgers[0])
|
|
168
|
+
|
|
169
|
+
def items(self) -> ItemsView[str, RustfavaLedger]:
|
|
170
|
+
"""Get an items view of all the ledgers by slug."""
|
|
171
|
+
return self.ledgers_by_slug.items()
|
|
172
|
+
|
|
173
|
+
def __getitem__(self, slug: str) -> RustfavaLedger:
|
|
174
|
+
"""Get the ledger for the given slug."""
|
|
175
|
+
return self.ledgers_by_slug[slug]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def static_url(filename: str) -> str:
|
|
179
|
+
"""Return a static url with an mtime query string for cache busting."""
|
|
180
|
+
file_path = Path(__file__).parent / "static" / filename
|
|
181
|
+
try:
|
|
182
|
+
mtime = str(int(file_path.stat().st_mtime))
|
|
183
|
+
except FileNotFoundError:
|
|
184
|
+
mtime = "0"
|
|
185
|
+
return url_for("static", filename=filename, mtime=mtime)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
_cached_url_for = lru_cache(2048)(flask_url_for)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _inject_filters(endpoint: str, values: dict[str, str]) -> None:
|
|
192
|
+
if (
|
|
193
|
+
"bfile" not in values
|
|
194
|
+
and current_app.url_map.is_endpoint_expecting(endpoint, "bfile")
|
|
195
|
+
and g.beancount_file_slug is not None
|
|
196
|
+
):
|
|
197
|
+
values["bfile"] = g.beancount_file_slug
|
|
198
|
+
if endpoint in {"static", "index"}:
|
|
199
|
+
return
|
|
200
|
+
for name in ("conversion", "interval", "account", "filter", "time"):
|
|
201
|
+
if name not in values:
|
|
202
|
+
val = request.args.get(name)
|
|
203
|
+
if val is not None:
|
|
204
|
+
values[name] = val
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def url_for(endpoint: str, **values: str) -> str:
|
|
208
|
+
"""Wrap flask.url_for using a cache."""
|
|
209
|
+
_inject_filters(endpoint, values)
|
|
210
|
+
return _cached_url_for(endpoint, **values)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def translations() -> dict[str, str]:
|
|
214
|
+
"""Get translations catalog."""
|
|
215
|
+
catalog = get_translations()._catalog # noqa: SLF001
|
|
216
|
+
return {k: v for k, v in catalog.items() if isinstance(k, str) and k}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _setup_template_config(fava_app: Flask, *, incognito: bool) -> None:
|
|
220
|
+
"""Setup jinja, template filters and globals."""
|
|
221
|
+
# Jinja config
|
|
222
|
+
fava_app.jinja_options = {
|
|
223
|
+
"extensions": ["jinja2.ext.do", "jinja2.ext.loopcontrols"],
|
|
224
|
+
"trim_blocks": True,
|
|
225
|
+
"lstrip_blocks": True,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
# Add template filters
|
|
229
|
+
fava_app.add_template_filter(funcs.hash_entry)
|
|
230
|
+
fava_app.add_template_filter(template_filters.basename)
|
|
231
|
+
fava_app.add_template_filter(template_filters.flag_to_type)
|
|
232
|
+
fava_app.add_template_filter(template_filters.format_currency)
|
|
233
|
+
fava_app.add_template_filter(template_filters.meta_items)
|
|
234
|
+
fava_app.add_template_filter(
|
|
235
|
+
template_filters.replace_numbers
|
|
236
|
+
if incognito
|
|
237
|
+
else template_filters.passthrough_numbers,
|
|
238
|
+
"incognito",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Add template global functions
|
|
242
|
+
fava_app.add_template_global(static_url, "static_url")
|
|
243
|
+
fava_app.add_template_global(date.today, "today")
|
|
244
|
+
fava_app.add_template_global(url_for, "url_for")
|
|
245
|
+
fava_app.add_template_global(translations, "translations")
|
|
246
|
+
fava_app.add_template_global(get_ledger_data, "get_ledger_data")
|
|
247
|
+
|
|
248
|
+
@fava_app.context_processor
|
|
249
|
+
def _template_context() -> dict[str, RustfavaLedger | type[ChartApi]]:
|
|
250
|
+
"""Inject variables into the template context."""
|
|
251
|
+
return {"ledger": g.ledger, "chart_api": ChartApi}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _setup_filters(
|
|
255
|
+
fava_app: Flask,
|
|
256
|
+
*,
|
|
257
|
+
read_only: bool,
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Setup request handlers/filters."""
|
|
260
|
+
fava_app.url_defaults(_inject_filters)
|
|
261
|
+
|
|
262
|
+
@fava_app.before_request
|
|
263
|
+
def _perform_global_filters() -> None:
|
|
264
|
+
if request.endpoint in {"json_api.get_changed", "json_api.get_errors"}:
|
|
265
|
+
return
|
|
266
|
+
ledger = getattr(g, "ledger", None)
|
|
267
|
+
if ledger:
|
|
268
|
+
# check (and possibly reload) source file
|
|
269
|
+
if request.blueprint != "json_api":
|
|
270
|
+
ledger.changed()
|
|
271
|
+
|
|
272
|
+
ledger.extensions.before_request()
|
|
273
|
+
|
|
274
|
+
if read_only:
|
|
275
|
+
# Prevent any request that isn't a GET if read-only mode is active
|
|
276
|
+
@fava_app.before_request
|
|
277
|
+
def _read_only() -> None:
|
|
278
|
+
if request.method != "GET":
|
|
279
|
+
abort(401)
|
|
280
|
+
|
|
281
|
+
@fava_app.url_value_preprocessor
|
|
282
|
+
def _pull_beancount_file(
|
|
283
|
+
_: str | None,
|
|
284
|
+
values: dict[str, str] | None,
|
|
285
|
+
) -> None:
|
|
286
|
+
g.beancount_file_slug = values.pop("bfile", None) if values else None
|
|
287
|
+
if g.beancount_file_slug:
|
|
288
|
+
try:
|
|
289
|
+
ledgers: _LedgerSlugLoader = fava_app.config["LEDGERS"]
|
|
290
|
+
g.ledger = ledgers[g.beancount_file_slug]
|
|
291
|
+
except KeyError:
|
|
292
|
+
abort(404)
|
|
293
|
+
|
|
294
|
+
@fava_app.errorhandler(RustfavaAPIError)
|
|
295
|
+
def fava_api_exception(error: RustfavaAPIError) -> tuple[str, int]:
|
|
296
|
+
"""Handle API errors."""
|
|
297
|
+
return render_template(
|
|
298
|
+
"_layout.html", page_title="Error", content=error.message
|
|
299
|
+
), 500
|
|
300
|
+
|
|
301
|
+
@fava_app.after_request
|
|
302
|
+
def _compress_response(response: Response) -> Response:
|
|
303
|
+
"""Compress JSON responses with gzip if client supports it."""
|
|
304
|
+
# Only compress JSON responses over 500 bytes
|
|
305
|
+
if (
|
|
306
|
+
response.content_type
|
|
307
|
+
and "application/json" in response.content_type
|
|
308
|
+
and response.content_length
|
|
309
|
+
and response.content_length > 500
|
|
310
|
+
and any(enc == "gzip" for enc, _ in request.accept_encodings)
|
|
311
|
+
):
|
|
312
|
+
response.data = gzip.compress(response.data)
|
|
313
|
+
response.headers["Content-Encoding"] = "gzip"
|
|
314
|
+
response.headers["Content-Length"] = len(response.data)
|
|
315
|
+
return response
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _setup_routes(fava_app: Flask) -> None: # noqa: PLR0915
|
|
319
|
+
@fava_app.route("/")
|
|
320
|
+
@fava_app.route("/<bfile>/")
|
|
321
|
+
def index() -> WerkzeugResponse:
|
|
322
|
+
"""Redirect to the Income Statement (of the given or first file)."""
|
|
323
|
+
ledgers: _LedgerSlugLoader = fava_app.config["LEDGERS"]
|
|
324
|
+
if not g.beancount_file_slug:
|
|
325
|
+
g.beancount_file_slug = ledgers.first_slug()
|
|
326
|
+
index_url = url_for("index")
|
|
327
|
+
default_page = ledgers[g.beancount_file_slug].fava_options.default_page
|
|
328
|
+
return redirect(f"{index_url}{default_page}")
|
|
329
|
+
|
|
330
|
+
@fava_app.route("/<bfile>/account/<name>/")
|
|
331
|
+
def account(name: str) -> str: # noqa: ARG001
|
|
332
|
+
"""Get the account report."""
|
|
333
|
+
return render_template("_layout.html", content="")
|
|
334
|
+
|
|
335
|
+
@fava_app.route("/<bfile>/document/", methods=["GET"])
|
|
336
|
+
def document() -> Response:
|
|
337
|
+
"""Download a document."""
|
|
338
|
+
filename = request.args.get("filename", "")
|
|
339
|
+
if is_document_or_import_file(filename, g.ledger):
|
|
340
|
+
return send_file_inline(filename)
|
|
341
|
+
return abort(404)
|
|
342
|
+
|
|
343
|
+
@fava_app.route("/<bfile>/statement/", methods=["GET"])
|
|
344
|
+
def statement() -> Response:
|
|
345
|
+
"""Download a statement file."""
|
|
346
|
+
entry_hash = request.args.get("entry_hash", "")
|
|
347
|
+
key = request.args.get("key", "")
|
|
348
|
+
document_path = g.ledger.statement_path(entry_hash, key)
|
|
349
|
+
return send_file_inline(document_path)
|
|
350
|
+
|
|
351
|
+
@fava_app.route(
|
|
352
|
+
"/<bfile>/holdings"
|
|
353
|
+
"/by_<any(account,currency,cost_currency):aggregation_key>/",
|
|
354
|
+
)
|
|
355
|
+
def holdings_by(**_kwargs: str) -> str:
|
|
356
|
+
"""Get the client-side-rendered holdings report."""
|
|
357
|
+
return render_template("_layout.html", content="")
|
|
358
|
+
|
|
359
|
+
@fava_app.route("/<bfile>/<report_name>/")
|
|
360
|
+
def report(report_name: str) -> str:
|
|
361
|
+
"""Endpoint for most reports."""
|
|
362
|
+
if report_name in CLIENT_SIDE_REPORTS:
|
|
363
|
+
return render_template("_layout.html", content="")
|
|
364
|
+
return abort(404)
|
|
365
|
+
|
|
366
|
+
@fava_app.route(
|
|
367
|
+
"/<bfile>/extension/<extension_name>/<endpoint>",
|
|
368
|
+
methods=["GET", "POST", "PUT", "DELETE"],
|
|
369
|
+
)
|
|
370
|
+
def extension_endpoint(extension_name: str, endpoint: str) -> Response:
|
|
371
|
+
ext = g.ledger.extensions.get_extension(extension_name)
|
|
372
|
+
key = (endpoint, request.method)
|
|
373
|
+
if ext is None or key not in ext.endpoints:
|
|
374
|
+
return abort(404)
|
|
375
|
+
response = ext.endpoints[key](ext)
|
|
376
|
+
|
|
377
|
+
return (
|
|
378
|
+
fava_app.make_response(response)
|
|
379
|
+
if response is not None
|
|
380
|
+
else abort(404)
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
@fava_app.route("/<bfile>/extension_js_module/<extension_name>.js")
|
|
384
|
+
def extension_js_module(extension_name: str) -> Response:
|
|
385
|
+
"""Endpoint for extension module source."""
|
|
386
|
+
ext = g.ledger.extensions.get_extension(extension_name)
|
|
387
|
+
if ext is None or not ext.has_js_module:
|
|
388
|
+
return abort(404)
|
|
389
|
+
return send_file(ext.extension_dir / f"{ext.name}.js")
|
|
390
|
+
|
|
391
|
+
@fava_app.route("/<bfile>/extension/<extension_name>/")
|
|
392
|
+
def extension_report(extension_name: str) -> str:
|
|
393
|
+
"""Endpoint for extension reports."""
|
|
394
|
+
ext = g.ledger.extensions.get_extension(extension_name)
|
|
395
|
+
if ext is None or ext.report_title is None:
|
|
396
|
+
return abort(404)
|
|
397
|
+
|
|
398
|
+
g.extension = ext
|
|
399
|
+
template = ext.jinja_env.get_template(f"{ext.name}.html")
|
|
400
|
+
content = Markup(template.render(ledger=g.ledger, extension=ext)) # noqa: S704
|
|
401
|
+
return render_template(
|
|
402
|
+
"_layout.html",
|
|
403
|
+
content=content,
|
|
404
|
+
page_title=ext.report_title,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
@fava_app.route("/<bfile>/download-query/query_result.<result_format>")
|
|
408
|
+
def download_query(result_format: str) -> Response:
|
|
409
|
+
"""Download a query result."""
|
|
410
|
+
name, data = g.ledger.query_shell.query_to_file(
|
|
411
|
+
g.filtered.entries_with_all_prices,
|
|
412
|
+
request.args.get("query_string", ""),
|
|
413
|
+
result_format,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
filename = f"{secure_filename(name.strip())}.{result_format}"
|
|
417
|
+
return send_file(data, as_attachment=True, download_name=filename)
|
|
418
|
+
|
|
419
|
+
@fava_app.route("/<bfile>/download-journal/")
|
|
420
|
+
def download_journal() -> Response:
|
|
421
|
+
"""Download a Journal file."""
|
|
422
|
+
now = datetime.now(tz=timezone.utc).replace(microsecond=0)
|
|
423
|
+
filename = f"journal_{now.isoformat()}.beancount"
|
|
424
|
+
data = BytesIO(bytes(render_template("beancount_file"), "utf8"))
|
|
425
|
+
return send_file(data, as_attachment=True, download_name=filename)
|
|
426
|
+
|
|
427
|
+
@fava_app.route("/<bfile>/help/", defaults={"page_slug": "_index"})
|
|
428
|
+
@fava_app.route("/<bfile>/help/<page_slug>")
|
|
429
|
+
def help_page(page_slug: str) -> str:
|
|
430
|
+
"""rustfava's included documentation."""
|
|
431
|
+
from importlib.metadata import version
|
|
432
|
+
|
|
433
|
+
from markdown2 import markdown
|
|
434
|
+
|
|
435
|
+
# Validate against whitelist (defense-in-depth: also check for path traversal)
|
|
436
|
+
if page_slug not in HELP_PAGES or "/" in page_slug or "\\" in page_slug:
|
|
437
|
+
return abort(404)
|
|
438
|
+
help_dir = (Path(__file__).parent / "help").resolve()
|
|
439
|
+
help_path = (help_dir / (page_slug + ".md")).resolve()
|
|
440
|
+
# Ensure resolved path is within help directory
|
|
441
|
+
# Note: With whitelist check above, this is unreachable (defense-in-depth)
|
|
442
|
+
if not help_path.is_relative_to(help_dir): # pragma: no cover
|
|
443
|
+
return abort(404)
|
|
444
|
+
contents = help_path.read_text(encoding="utf-8")
|
|
445
|
+
html = markdown(
|
|
446
|
+
contents,
|
|
447
|
+
extras=["fenced-code-blocks", "tables", "header-ids"],
|
|
448
|
+
)
|
|
449
|
+
return render_template(
|
|
450
|
+
"help.html",
|
|
451
|
+
page_slug=page_slug,
|
|
452
|
+
help_html=Markup( # noqa: S704
|
|
453
|
+
render_template_string(
|
|
454
|
+
html,
|
|
455
|
+
rustfava_version=version("rustfava"),
|
|
456
|
+
),
|
|
457
|
+
),
|
|
458
|
+
HELP_PAGES=HELP_PAGES,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
@fava_app.route("/jump")
|
|
462
|
+
def jump() -> WerkzeugResponse:
|
|
463
|
+
"""Redirect back to the referer, replacing some parameters.
|
|
464
|
+
|
|
465
|
+
This is useful for sidebar links, e.g. a link ``/jump?time=year``
|
|
466
|
+
would set the time filter to `year` on the current page.
|
|
467
|
+
|
|
468
|
+
When accessing ``/jump?param1=abc`` from
|
|
469
|
+
``/example/page?param1=123¶m2=456``, this view should redirect to
|
|
470
|
+
``/example/page?param1=abc¶m2=456``.
|
|
471
|
+
|
|
472
|
+
"""
|
|
473
|
+
url = urlparse(request.referrer)
|
|
474
|
+
query_args = parse_qsl(url.query)
|
|
475
|
+
for key, values in request.args.lists():
|
|
476
|
+
query_args = [
|
|
477
|
+
key_value for key_value in query_args if key_value[0] != key
|
|
478
|
+
]
|
|
479
|
+
if values != [""]:
|
|
480
|
+
query_args.extend([(key, v) for v in values])
|
|
481
|
+
|
|
482
|
+
redirect_url = url._replace(query=urlencode(query_args))
|
|
483
|
+
return redirect(urlunparse(redirect_url))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _setup_babel(fava_app: Flask) -> None:
|
|
487
|
+
"""Configure the Babel Flask extension."""
|
|
488
|
+
|
|
489
|
+
def _get_locale() -> str | None:
|
|
490
|
+
"""Get locale."""
|
|
491
|
+
lang = g.ledger.fava_options.language
|
|
492
|
+
return lang or request.accept_languages.best_match(["en", *LOCALES])
|
|
493
|
+
|
|
494
|
+
Babel(fava_app, locale_selector=_get_locale) # type: ignore[no-untyped-call]
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def create_app(
|
|
498
|
+
files: Iterable[Path | str],
|
|
499
|
+
*,
|
|
500
|
+
load: bool = False,
|
|
501
|
+
incognito: bool = False,
|
|
502
|
+
read_only: bool = False,
|
|
503
|
+
poll_watcher: bool = False,
|
|
504
|
+
) -> Flask:
|
|
505
|
+
"""Create a rustfava Flask application.
|
|
506
|
+
|
|
507
|
+
Arguments:
|
|
508
|
+
files: The list of Beancount files (paths).
|
|
509
|
+
load: Whether to load the Beancount files directly.
|
|
510
|
+
incognito: Whether to run in incognito mode.
|
|
511
|
+
read_only: Whether to run in read-only mode.
|
|
512
|
+
poll_watcher: Whether to use old poll watcher
|
|
513
|
+
"""
|
|
514
|
+
fava_app = Flask("rustfava")
|
|
515
|
+
fava_app.register_blueprint(json_api, url_prefix="/<bfile>/api")
|
|
516
|
+
fava_app.json = RustfavaJSONProvider(fava_app)
|
|
517
|
+
fava_app.app_ctx_globals_class = Context # type: ignore[assignment]
|
|
518
|
+
_setup_template_config(fava_app, incognito=incognito)
|
|
519
|
+
_setup_babel(fava_app)
|
|
520
|
+
_setup_filters(fava_app, read_only=read_only)
|
|
521
|
+
_setup_routes(fava_app)
|
|
522
|
+
|
|
523
|
+
fava_app.config["HAVE_EXCEL"] = HAVE_EXCEL
|
|
524
|
+
fava_app.config["BEANCOUNT_FILES"] = [str(f) for f in files]
|
|
525
|
+
fava_app.config["INCOGNITO"] = incognito
|
|
526
|
+
fava_app.config["LEDGERS"] = _LedgerSlugLoader(
|
|
527
|
+
fava_app, load=load, poll_watcher=poll_watcher
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
return fava_app
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
#: This is still provided for compatibility but will be removed at some point.
|
|
534
|
+
app = create_app([])
|