rustfava 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. rustfava/__init__.py +30 -0
  2. rustfava/_ctx_globals_class.py +55 -0
  3. rustfava/api_models.py +36 -0
  4. rustfava/application.py +534 -0
  5. rustfava/beans/__init__.py +6 -0
  6. rustfava/beans/abc.py +327 -0
  7. rustfava/beans/account.py +79 -0
  8. rustfava/beans/create.py +377 -0
  9. rustfava/beans/flags.py +20 -0
  10. rustfava/beans/funcs.py +38 -0
  11. rustfava/beans/helpers.py +52 -0
  12. rustfava/beans/ingest.py +75 -0
  13. rustfava/beans/load.py +31 -0
  14. rustfava/beans/prices.py +151 -0
  15. rustfava/beans/protocols.py +82 -0
  16. rustfava/beans/str.py +454 -0
  17. rustfava/beans/types.py +63 -0
  18. rustfava/cli.py +187 -0
  19. rustfava/context.py +13 -0
  20. rustfava/core/__init__.py +729 -0
  21. rustfava/core/accounts.py +161 -0
  22. rustfava/core/attributes.py +145 -0
  23. rustfava/core/budgets.py +207 -0
  24. rustfava/core/charts.py +301 -0
  25. rustfava/core/commodities.py +37 -0
  26. rustfava/core/conversion.py +229 -0
  27. rustfava/core/documents.py +87 -0
  28. rustfava/core/extensions.py +132 -0
  29. rustfava/core/fava_options.py +255 -0
  30. rustfava/core/file.py +542 -0
  31. rustfava/core/filters.py +484 -0
  32. rustfava/core/group_entries.py +97 -0
  33. rustfava/core/ingest.py +509 -0
  34. rustfava/core/inventory.py +167 -0
  35. rustfava/core/misc.py +105 -0
  36. rustfava/core/module_base.py +18 -0
  37. rustfava/core/number.py +106 -0
  38. rustfava/core/query.py +180 -0
  39. rustfava/core/query_shell.py +301 -0
  40. rustfava/core/tree.py +265 -0
  41. rustfava/core/watcher.py +219 -0
  42. rustfava/ext/__init__.py +232 -0
  43. rustfava/ext/auto_commit.py +61 -0
  44. rustfava/ext/portfolio_list/PortfolioList.js +34 -0
  45. rustfava/ext/portfolio_list/__init__.py +29 -0
  46. rustfava/ext/portfolio_list/templates/PortfolioList.html +15 -0
  47. rustfava/ext/rustfava_ext_test/RustfavaExtTest.js +42 -0
  48. rustfava/ext/rustfava_ext_test/__init__.py +207 -0
  49. rustfava/ext/rustfava_ext_test/templates/RustfavaExtTest.html +45 -0
  50. rustfava/ext/rustfava_ext_test/templates/RustfavaExtTestInclude.html +1 -0
  51. rustfava/help/__init__.py +15 -0
  52. rustfava/help/_index.md +29 -0
  53. rustfava/help/beancount_syntax.md +156 -0
  54. rustfava/help/budgets.md +31 -0
  55. rustfava/help/conversion.md +29 -0
  56. rustfava/help/extensions.md +111 -0
  57. rustfava/help/features.md +179 -0
  58. rustfava/help/filters.md +103 -0
  59. rustfava/help/import.md +27 -0
  60. rustfava/help/options.md +289 -0
  61. rustfava/helpers.py +30 -0
  62. rustfava/internal_api.py +221 -0
  63. rustfava/json_api.py +952 -0
  64. rustfava/plugins/__init__.py +3 -0
  65. rustfava/plugins/link_documents.py +107 -0
  66. rustfava/plugins/tag_discovered_documents.py +44 -0
  67. rustfava/py.typed +0 -0
  68. rustfava/rustledger/__init__.py +31 -0
  69. rustfava/rustledger/constants.py +76 -0
  70. rustfava/rustledger/engine.py +485 -0
  71. rustfava/rustledger/loader.py +273 -0
  72. rustfava/rustledger/options.py +202 -0
  73. rustfava/rustledger/query.py +331 -0
  74. rustfava/rustledger/types.py +830 -0
  75. rustfava/serialisation.py +220 -0
  76. rustfava/static/app.css +2988 -0
  77. rustfava/static/app.css.map +7 -0
  78. rustfava/static/app.js +12854 -0
  79. rustfava/static/app.js.map +7 -0
  80. rustfava/static/beancount-JFV44ZVZ.css +5 -0
  81. rustfava/static/beancount-JFV44ZVZ.css.map +7 -0
  82. rustfava/static/beancount-VTTKRGSK.js +4642 -0
  83. rustfava/static/beancount-VTTKRGSK.js.map +7 -0
  84. rustfava/static/bql-MGFRUMBP.js +333 -0
  85. rustfava/static/bql-MGFRUMBP.js.map +7 -0
  86. rustfava/static/chunk-E7ZF4ASL.js +23061 -0
  87. rustfava/static/chunk-E7ZF4ASL.js.map +7 -0
  88. rustfava/static/chunk-V24TLQHT.js +12673 -0
  89. rustfava/static/chunk-V24TLQHT.js.map +7 -0
  90. rustfava/static/favicon.ico +0 -0
  91. rustfava/static/fira-mono-cyrillic-400-normal-BLAGXRCE.woff2 +0 -0
  92. rustfava/static/fira-mono-cyrillic-500-normal-EN7JUAAW.woff2 +0 -0
  93. rustfava/static/fira-mono-cyrillic-ext-400-normal-EX7VARTS.woff2 +0 -0
  94. rustfava/static/fira-mono-cyrillic-ext-500-normal-ZDPTUPRR.woff2 +0 -0
  95. rustfava/static/fira-mono-greek-400-normal-COGHKMOA.woff2 +0 -0
  96. rustfava/static/fira-mono-greek-500-normal-4EN2PKZT.woff2 +0 -0
  97. rustfava/static/fira-mono-greek-ext-400-normal-DYEQIJH7.woff2 +0 -0
  98. rustfava/static/fira-mono-greek-ext-500-normal-SG73CVKQ.woff2 +0 -0
  99. rustfava/static/fira-mono-latin-400-normal-NA3VLV7E.woff2 +0 -0
  100. rustfava/static/fira-mono-latin-500-normal-YC77GFWD.woff2 +0 -0
  101. rustfava/static/fira-mono-latin-ext-400-normal-DIKTZ5PW.woff2 +0 -0
  102. rustfava/static/fira-mono-latin-ext-500-normal-ZWY4UO4V.woff2 +0 -0
  103. rustfava/static/fira-mono-symbols2-400-normal-UITXT77Q.woff2 +0 -0
  104. rustfava/static/fira-mono-symbols2-500-normal-VWPC2EFN.woff2 +0 -0
  105. rustfava/static/fira-sans-cyrillic-400-normal-KLQMBCA6.woff2 +0 -0
  106. rustfava/static/fira-sans-cyrillic-500-normal-NFG7UD6J.woff2 +0 -0
  107. rustfava/static/fira-sans-cyrillic-ext-400-normal-GWO44OPC.woff2 +0 -0
  108. rustfava/static/fira-sans-cyrillic-ext-500-normal-SP47E5SC.woff2 +0 -0
  109. rustfava/static/fira-sans-greek-400-normal-UMQBTLC3.woff2 +0 -0
  110. rustfava/static/fira-sans-greek-500-normal-4ZKHN4FQ.woff2 +0 -0
  111. rustfava/static/fira-sans-greek-ext-400-normal-O2DVJAJZ.woff2 +0 -0
  112. rustfava/static/fira-sans-greek-ext-500-normal-SK6GNWGO.woff2 +0 -0
  113. rustfava/static/fira-sans-latin-400-normal-OYYTPMAV.woff2 +0 -0
  114. rustfava/static/fira-sans-latin-500-normal-SMQPZW5A.woff2 +0 -0
  115. rustfava/static/fira-sans-latin-ext-400-normal-OAUP3WK5.woff2 +0 -0
  116. rustfava/static/fira-sans-latin-ext-500-normal-LY3YDR5Y.woff2 +0 -0
  117. rustfava/static/fira-sans-vietnamese-400-normal-OBMQ72MR.woff2 +0 -0
  118. rustfava/static/fira-sans-vietnamese-500-normal-Y4NZR5EU.woff2 +0 -0
  119. rustfava/static/source-code-pro-cyrillic-400-normal-TO22V6M3.woff2 +0 -0
  120. rustfava/static/source-code-pro-cyrillic-500-normal-OGBWWWYW.woff2 +0 -0
  121. rustfava/static/source-code-pro-cyrillic-ext-400-normal-XH44UCIA.woff2 +0 -0
  122. rustfava/static/source-code-pro-cyrillic-ext-500-normal-3Z6MMVM6.woff2 +0 -0
  123. rustfava/static/source-code-pro-greek-400-normal-OUXXUQWK.woff2 +0 -0
  124. rustfava/static/source-code-pro-greek-500-normal-JA2Z5UXO.woff2 +0 -0
  125. rustfava/static/source-code-pro-greek-ext-400-normal-WCDKMX7U.woff2 +0 -0
  126. rustfava/static/source-code-pro-greek-ext-500-normal-ZHVI4VKW.woff2 +0 -0
  127. rustfava/static/source-code-pro-latin-400-normal-QOGTXED5.woff2 +0 -0
  128. rustfava/static/source-code-pro-latin-500-normal-X57QEOLQ.woff2 +0 -0
  129. rustfava/static/source-code-pro-latin-ext-400-normal-QXC74NBF.woff2 +0 -0
  130. rustfava/static/source-code-pro-latin-ext-500-normal-QGOY7MTT.woff2 +0 -0
  131. rustfava/static/source-code-pro-vietnamese-400-normal-NPDCDTBA.woff2 +0 -0
  132. rustfava/static/source-code-pro-vietnamese-500-normal-M6PJKTR5.woff2 +0 -0
  133. rustfava/static/tree-sitter-beancount-MLXFQBZ5.wasm +0 -0
  134. rustfava/static/web-tree-sitter-RNOQ6E74.wasm +0 -0
  135. rustfava/template_filters.py +64 -0
  136. rustfava/templates/_journal_table.html +156 -0
  137. rustfava/templates/_layout.html +26 -0
  138. rustfava/templates/_query_table.html +88 -0
  139. rustfava/templates/beancount_file +18 -0
  140. rustfava/templates/help.html +23 -0
  141. rustfava/templates/macros/_account_macros.html +5 -0
  142. rustfava/templates/macros/_commodity_macros.html +13 -0
  143. rustfava/translations/bg/LC_MESSAGES/messages.mo +0 -0
  144. rustfava/translations/bg/LC_MESSAGES/messages.po +618 -0
  145. rustfava/translations/ca/LC_MESSAGES/messages.mo +0 -0
  146. rustfava/translations/ca/LC_MESSAGES/messages.po +618 -0
  147. rustfava/translations/de/LC_MESSAGES/messages.mo +0 -0
  148. rustfava/translations/de/LC_MESSAGES/messages.po +618 -0
  149. rustfava/translations/es/LC_MESSAGES/messages.mo +0 -0
  150. rustfava/translations/es/LC_MESSAGES/messages.po +619 -0
  151. rustfava/translations/fa/LC_MESSAGES/messages.mo +0 -0
  152. rustfava/translations/fa/LC_MESSAGES/messages.po +618 -0
  153. rustfava/translations/fr/LC_MESSAGES/messages.mo +0 -0
  154. rustfava/translations/fr/LC_MESSAGES/messages.po +618 -0
  155. rustfava/translations/ja/LC_MESSAGES/messages.mo +0 -0
  156. rustfava/translations/ja/LC_MESSAGES/messages.po +618 -0
  157. rustfava/translations/nl/LC_MESSAGES/messages.mo +0 -0
  158. rustfava/translations/nl/LC_MESSAGES/messages.po +617 -0
  159. rustfava/translations/pt/LC_MESSAGES/messages.mo +0 -0
  160. rustfava/translations/pt/LC_MESSAGES/messages.po +617 -0
  161. rustfava/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
  162. rustfava/translations/pt_BR/LC_MESSAGES/messages.po +618 -0
  163. rustfava/translations/ru/LC_MESSAGES/messages.mo +0 -0
  164. rustfava/translations/ru/LC_MESSAGES/messages.po +617 -0
  165. rustfava/translations/sk/LC_MESSAGES/messages.mo +0 -0
  166. rustfava/translations/sk/LC_MESSAGES/messages.po +623 -0
  167. rustfava/translations/sv/LC_MESSAGES/messages.mo +0 -0
  168. rustfava/translations/sv/LC_MESSAGES/messages.po +618 -0
  169. rustfava/translations/uk/LC_MESSAGES/messages.mo +0 -0
  170. rustfava/translations/uk/LC_MESSAGES/messages.po +618 -0
  171. rustfava/translations/zh/LC_MESSAGES/messages.mo +0 -0
  172. rustfava/translations/zh/LC_MESSAGES/messages.po +618 -0
  173. rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.mo +0 -0
  174. rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.po +618 -0
  175. rustfava/util/__init__.py +157 -0
  176. rustfava/util/date.py +576 -0
  177. rustfava/util/excel.py +118 -0
  178. rustfava/util/ranking.py +79 -0
  179. rustfava/util/sets.py +18 -0
  180. rustfava/util/unreachable.py +20 -0
  181. rustfava-0.1.0.dist-info/METADATA +102 -0
  182. rustfava-0.1.0.dist-info/RECORD +187 -0
  183. rustfava-0.1.0.dist-info/WHEEL +5 -0
  184. rustfava-0.1.0.dist-info/entry_points.txt +2 -0
  185. rustfava-0.1.0.dist-info/licenses/AUTHORS +11 -0
  186. rustfava-0.1.0.dist-info/licenses/LICENSE +21 -0
  187. rustfava-0.1.0.dist-info/top_level.txt +1 -0
rustfava/__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")
@@ -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&param2=456``, this view should redirect to
470
+ ``/example/page?param1=abc&param2=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([])
@@ -0,0 +1,6 @@
1
+ """Types, functions and wrappers to deal with Beancount types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Rustledger is compatible with beancount v3 API
6
+ BEANCOUNT_V3 = True