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/util/date.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""Date-related functionality.
|
|
2
|
+
|
|
3
|
+
Note:
|
|
4
|
+
Date ranges are always tuples (start, end) from the (inclusive) start date
|
|
5
|
+
to the (exclusive) end date.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime
|
|
11
|
+
import re
|
|
12
|
+
from abc import ABC
|
|
13
|
+
from abc import abstractmethod
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import timedelta
|
|
16
|
+
from itertools import tee
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from flask_babel import gettext
|
|
20
|
+
|
|
21
|
+
from rustfava.util import listify
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from typing import override
|
|
25
|
+
except ImportError: # pragma: no cover
|
|
26
|
+
from typing import override
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
29
|
+
from collections.abc import Iterable
|
|
30
|
+
from collections.abc import Iterator
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
IS_RANGE_RE = re.compile(r"(.*?)(?:-|to)(?=\s*(?:fy)*\d{4})(.*)")
|
|
34
|
+
|
|
35
|
+
# these match dates of the form 'year-month-day'
|
|
36
|
+
# day or month and day may be omitted
|
|
37
|
+
YEAR_RE = re.compile(r"^\d{4}$")
|
|
38
|
+
MONTH_RE = re.compile(r"^(\d{4})-(\d{2})$")
|
|
39
|
+
DAY_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
|
|
40
|
+
|
|
41
|
+
# this matches a week like 2016-W02 for the second week of 2016
|
|
42
|
+
WEEK_RE = re.compile(r"^(\d{4})-w(\d{2})$")
|
|
43
|
+
|
|
44
|
+
# this matches a quarter like 2016-Q1 for the first quarter of 2016
|
|
45
|
+
QUARTER_RE = re.compile(r"^(\d{4})-q([1234])$")
|
|
46
|
+
|
|
47
|
+
# this matches a financial year like FY2018 for the financial year ending 2018
|
|
48
|
+
FY_RE = re.compile(r"^fy(\d{4})$")
|
|
49
|
+
|
|
50
|
+
# this matches a quarter in a financial year like FY2018-Q2
|
|
51
|
+
FY_QUARTER_RE = re.compile(r"^fy(\d{4})-q([1234])$")
|
|
52
|
+
|
|
53
|
+
VARIABLE_RE = re.compile(
|
|
54
|
+
r"\(?(fiscal_year|year|fiscal_quarter|quarter"
|
|
55
|
+
r"|month|week|day)(?:([-+])(\d+))?\)?",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class FiscalYearEnd:
|
|
61
|
+
"""Month and day that specify the end of the fiscal year."""
|
|
62
|
+
|
|
63
|
+
month: int
|
|
64
|
+
day: int
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def month_of_year(self) -> int:
|
|
68
|
+
"""Actual month of the year."""
|
|
69
|
+
return (self.month - 1) % 12 + 1
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def year_offset(self) -> int:
|
|
73
|
+
"""Number of years that this is offset into the future."""
|
|
74
|
+
return (self.month - 1) // 12
|
|
75
|
+
|
|
76
|
+
def has_quarters(self) -> bool:
|
|
77
|
+
"""Whether this fiscal year end supports fiscal quarters."""
|
|
78
|
+
return (
|
|
79
|
+
datetime.date(2001, self.month_of_year, self.day) + ONE_DAY
|
|
80
|
+
).day == 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FyeHasNoQuartersError(ValueError):
|
|
84
|
+
"""Only fiscal year that start on the first of a month have quarters."""
|
|
85
|
+
|
|
86
|
+
def __init__(self) -> None:
|
|
87
|
+
super().__init__(
|
|
88
|
+
"Cannot use fiscal quarter if fiscal year "
|
|
89
|
+
"does not start on first of the month"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
END_OF_YEAR = FiscalYearEnd(12, 31)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Interval(ABC):
|
|
97
|
+
"""An interval."""
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
@abstractmethod
|
|
101
|
+
def label(self) -> str:
|
|
102
|
+
"""The label for the interval."""
|
|
103
|
+
|
|
104
|
+
@abstractmethod
|
|
105
|
+
def format_date(self, date: datetime.date) -> str:
|
|
106
|
+
"""Format a date for this interval for the Fava time filter."""
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
def get_prev(self, date: datetime.date) -> datetime.date:
|
|
110
|
+
"""Get the start date of the interval in which the date falls."""
|
|
111
|
+
|
|
112
|
+
@abstractmethod
|
|
113
|
+
def get_next(self, date: datetime.date) -> datetime.date:
|
|
114
|
+
"""Get the start date of the next interval following the date."""
|
|
115
|
+
|
|
116
|
+
def number_of_days(self, date: datetime.date) -> int:
|
|
117
|
+
"""Get number of days in the surrounding interval."""
|
|
118
|
+
start = self.get_prev(date)
|
|
119
|
+
end = self.get_next(start)
|
|
120
|
+
return (end - start).days
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _IntervalYear(Interval):
|
|
124
|
+
"""A year interval."""
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def label(self) -> str:
|
|
128
|
+
return gettext("Yearly")
|
|
129
|
+
|
|
130
|
+
def format_date(self, date: datetime.date) -> str:
|
|
131
|
+
return date.strftime("%Y")
|
|
132
|
+
|
|
133
|
+
def get_prev(self, date: datetime.date) -> datetime.date:
|
|
134
|
+
return datetime.date(date.year, 1, 1)
|
|
135
|
+
|
|
136
|
+
def get_next(self, date: datetime.date) -> datetime.date:
|
|
137
|
+
try:
|
|
138
|
+
return datetime.date(date.year + 1, 1, 1)
|
|
139
|
+
except ValueError:
|
|
140
|
+
return datetime.date.max
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _IntervalQuarter(Interval):
|
|
144
|
+
"""A quarter interval."""
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def label(self) -> str:
|
|
148
|
+
return gettext("Quarterly")
|
|
149
|
+
|
|
150
|
+
def format_date(self, date: datetime.date) -> str:
|
|
151
|
+
return f"{date.year}-Q{(date.month - 1) // 3 + 1}"
|
|
152
|
+
|
|
153
|
+
def get_prev(self, date: datetime.date) -> datetime.date:
|
|
154
|
+
for i in [10, 7, 4]:
|
|
155
|
+
if date.month > i:
|
|
156
|
+
return datetime.date(date.year, i, 1)
|
|
157
|
+
return datetime.date(date.year, 1, 1)
|
|
158
|
+
|
|
159
|
+
def get_next(self, date: datetime.date) -> datetime.date:
|
|
160
|
+
for i in [4, 7, 10]:
|
|
161
|
+
if date.month < i:
|
|
162
|
+
return datetime.date(date.year, i, 1)
|
|
163
|
+
try:
|
|
164
|
+
return datetime.date(date.year + 1, 1, 1)
|
|
165
|
+
except ValueError:
|
|
166
|
+
return datetime.date.max
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _IntervalMonth(Interval):
|
|
170
|
+
"""A month interval."""
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def label(self) -> str:
|
|
174
|
+
return gettext("Monthly")
|
|
175
|
+
|
|
176
|
+
def format_date(self, date: datetime.date) -> str:
|
|
177
|
+
return date.strftime("%Y-%m")
|
|
178
|
+
|
|
179
|
+
def get_prev(self, date: datetime.date) -> datetime.date:
|
|
180
|
+
return datetime.date(date.year, date.month, 1)
|
|
181
|
+
|
|
182
|
+
def get_next(self, date: datetime.date) -> datetime.date:
|
|
183
|
+
try:
|
|
184
|
+
month = (date.month % 12) + 1
|
|
185
|
+
year = date.year + (date.month + 1 > 12)
|
|
186
|
+
return datetime.date(year, month, 1)
|
|
187
|
+
except ValueError:
|
|
188
|
+
return datetime.date.max
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _IntervalWeek(Interval):
|
|
192
|
+
"""A week interval."""
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def label(self) -> str:
|
|
196
|
+
return gettext("Weekly")
|
|
197
|
+
|
|
198
|
+
def format_date(self, date: datetime.date) -> str:
|
|
199
|
+
return date.strftime("%G-W%V")
|
|
200
|
+
|
|
201
|
+
def get_prev(self, date: datetime.date) -> datetime.date:
|
|
202
|
+
return date - timedelta(date.weekday())
|
|
203
|
+
|
|
204
|
+
def get_next(self, date: datetime.date) -> datetime.date:
|
|
205
|
+
try:
|
|
206
|
+
return date + timedelta(7 - date.weekday())
|
|
207
|
+
except OverflowError:
|
|
208
|
+
return datetime.date.max
|
|
209
|
+
|
|
210
|
+
@override
|
|
211
|
+
def number_of_days(self, date: datetime.date) -> int:
|
|
212
|
+
"""Get number of days in the surrounding interval."""
|
|
213
|
+
return 7
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class _IntervalDay(Interval):
|
|
217
|
+
"""A day interval."""
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def label(self) -> str:
|
|
221
|
+
return gettext("Daily")
|
|
222
|
+
|
|
223
|
+
def format_date(self, date: datetime.date) -> str:
|
|
224
|
+
return date.strftime("%Y-%m-%d")
|
|
225
|
+
|
|
226
|
+
def get_prev(self, date: datetime.date) -> datetime.date:
|
|
227
|
+
return date
|
|
228
|
+
|
|
229
|
+
def get_next(self, date: datetime.date) -> datetime.date:
|
|
230
|
+
try:
|
|
231
|
+
return date + timedelta(1)
|
|
232
|
+
except OverflowError:
|
|
233
|
+
return datetime.date.max
|
|
234
|
+
|
|
235
|
+
@override
|
|
236
|
+
def number_of_days(self, date: datetime.date) -> int:
|
|
237
|
+
return 1
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
Year = _IntervalYear()
|
|
241
|
+
Quarter = _IntervalQuarter()
|
|
242
|
+
Month = _IntervalMonth()
|
|
243
|
+
Week = _IntervalWeek()
|
|
244
|
+
Day = _IntervalDay()
|
|
245
|
+
|
|
246
|
+
INTERVALS = {
|
|
247
|
+
"year": Year,
|
|
248
|
+
"yearly": Year,
|
|
249
|
+
"quarter": Quarter,
|
|
250
|
+
"quarterly": Quarter,
|
|
251
|
+
"month": Month,
|
|
252
|
+
"monthly": Month,
|
|
253
|
+
"week": Week,
|
|
254
|
+
"weekly": Week,
|
|
255
|
+
"day": Day,
|
|
256
|
+
"daily": Day,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class InvalidDateRangeError(ValueError):
|
|
261
|
+
"""End date needs to be after begin date."""
|
|
262
|
+
|
|
263
|
+
def __init__(self) -> None:
|
|
264
|
+
super().__init__("End date needs to be after begin date.")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def interval_ends(
|
|
268
|
+
begin: datetime.date,
|
|
269
|
+
end: datetime.date,
|
|
270
|
+
interval: Interval,
|
|
271
|
+
*,
|
|
272
|
+
complete: bool,
|
|
273
|
+
) -> Iterator[datetime.date]:
|
|
274
|
+
"""Get interval ends.
|
|
275
|
+
|
|
276
|
+
Yields:
|
|
277
|
+
The ends of the intervals.
|
|
278
|
+
"""
|
|
279
|
+
if begin >= end:
|
|
280
|
+
raise InvalidDateRangeError
|
|
281
|
+
current = interval.get_prev(begin) if complete else begin
|
|
282
|
+
while current < end:
|
|
283
|
+
yield current
|
|
284
|
+
current = interval.get_next(current)
|
|
285
|
+
yield current if complete else end
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
ONE_DAY = timedelta(days=1)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class DateRange:
|
|
293
|
+
"""A range of dates, usually matching an interval."""
|
|
294
|
+
|
|
295
|
+
#: The inclusive start date of this range of dates.
|
|
296
|
+
begin: datetime.date
|
|
297
|
+
#: The exclusive end date of this range of dates.
|
|
298
|
+
end: datetime.date
|
|
299
|
+
|
|
300
|
+
def __post_init__(self) -> None:
|
|
301
|
+
if self.begin >= self.end:
|
|
302
|
+
raise InvalidDateRangeError
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def end_inclusive(self) -> datetime.date:
|
|
306
|
+
"""The last day of this interval."""
|
|
307
|
+
return self.end - ONE_DAY
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@listify
|
|
311
|
+
def dateranges(
|
|
312
|
+
begin: datetime.date,
|
|
313
|
+
end: datetime.date,
|
|
314
|
+
interval: Interval,
|
|
315
|
+
*,
|
|
316
|
+
complete: bool,
|
|
317
|
+
) -> Iterable[DateRange]:
|
|
318
|
+
"""Get date ranges for the given begin and end date.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
begin: The begin date - the first interval date range will
|
|
322
|
+
include this date
|
|
323
|
+
end: The end date - the last interval will end on or after
|
|
324
|
+
date
|
|
325
|
+
interval: The type of interval to generate ranges for.
|
|
326
|
+
complete: Whether to complete starting and ending intervals.
|
|
327
|
+
|
|
328
|
+
Yields:
|
|
329
|
+
Date ranges for all intervals of the given in the
|
|
330
|
+
"""
|
|
331
|
+
ends = interval_ends(begin, end, interval, complete=complete)
|
|
332
|
+
left, right = tee(ends)
|
|
333
|
+
next(right, None)
|
|
334
|
+
for interval_begin, interval_end in zip(left, right, strict=False):
|
|
335
|
+
yield DateRange(interval_begin, interval_end)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def local_today() -> datetime.date:
|
|
339
|
+
"""Today as a date in the local timezone."""
|
|
340
|
+
return datetime.date.today() # noqa: DTZ011
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def substitute(
|
|
344
|
+
string: str,
|
|
345
|
+
fye: FiscalYearEnd | None = None,
|
|
346
|
+
) -> str:
|
|
347
|
+
"""Replace variables referring to the current day.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
string: A string, possibly containing variables for today.
|
|
351
|
+
fye: Use a specific fiscal-year-end
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
A string, where variables referring to the current day, like 'year' or
|
|
355
|
+
'week' have been replaced by the corresponding string understood by
|
|
356
|
+
:func:`parse_date`. Can compute addition and subtraction.
|
|
357
|
+
"""
|
|
358
|
+
today = local_today()
|
|
359
|
+
fye = fye or END_OF_YEAR
|
|
360
|
+
|
|
361
|
+
for match in VARIABLE_RE.finditer(string):
|
|
362
|
+
complete_match, interval, plusminus_, mod_ = match.group(0, 1, 2, 3)
|
|
363
|
+
mod = int(mod_) if mod_ else 0
|
|
364
|
+
offset = mod if plusminus_ == "+" else -mod
|
|
365
|
+
if interval == "fiscal_year":
|
|
366
|
+
after_fye = (today.month, today.day) > (fye.month_of_year, fye.day)
|
|
367
|
+
year = today.year + (1 if after_fye else 0) - fye.year_offset
|
|
368
|
+
string = string.replace(complete_match, f"FY{year + offset}")
|
|
369
|
+
if interval == "year":
|
|
370
|
+
string = string.replace(complete_match, str(today.year + offset))
|
|
371
|
+
if interval == "fiscal_quarter":
|
|
372
|
+
if not fye.has_quarters():
|
|
373
|
+
raise FyeHasNoQuartersError
|
|
374
|
+
target = month_offset(today.replace(day=1), offset * 3)
|
|
375
|
+
after_fye = (target.month) > (fye.month_of_year)
|
|
376
|
+
year = target.year + (1 if after_fye else 0) - fye.year_offset
|
|
377
|
+
quarter = ((target.month - fye.month_of_year - 1) // 3) % 4 + 1
|
|
378
|
+
string = string.replace(complete_match, f"FY{year}-Q{quarter}")
|
|
379
|
+
if interval == "quarter":
|
|
380
|
+
quarter_today = (today.month - 1) // 3 + 1
|
|
381
|
+
year = today.year + (quarter_today + offset - 1) // 4
|
|
382
|
+
quarter = (quarter_today + offset - 1) % 4 + 1
|
|
383
|
+
string = string.replace(complete_match, f"{year}-Q{quarter}")
|
|
384
|
+
if interval == "month":
|
|
385
|
+
year = today.year + (today.month + offset - 1) // 12
|
|
386
|
+
month = (today.month + offset - 1) % 12 + 1
|
|
387
|
+
string = string.replace(complete_match, f"{year}-{month:02}")
|
|
388
|
+
if interval == "week":
|
|
389
|
+
string = string.replace(
|
|
390
|
+
complete_match,
|
|
391
|
+
(today + timedelta(offset * 7)).strftime("%G-W%V"),
|
|
392
|
+
)
|
|
393
|
+
if interval == "day":
|
|
394
|
+
string = string.replace(
|
|
395
|
+
complete_match,
|
|
396
|
+
(today + timedelta(offset)).isoformat(),
|
|
397
|
+
)
|
|
398
|
+
return string
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def parse_date( # noqa: PLR0911
|
|
402
|
+
string: str,
|
|
403
|
+
fye: FiscalYearEnd | None = None,
|
|
404
|
+
) -> tuple[datetime.date | None, datetime.date | None]:
|
|
405
|
+
"""Parse a date.
|
|
406
|
+
|
|
407
|
+
Example of supported formats:
|
|
408
|
+
|
|
409
|
+
- 2010-03-15, 2010-03, 2010
|
|
410
|
+
- 2010-W01, 2010-Q3
|
|
411
|
+
- FY2012, FY2012-Q2
|
|
412
|
+
|
|
413
|
+
Ranges of dates can be expressed in the following forms:
|
|
414
|
+
|
|
415
|
+
- start - end
|
|
416
|
+
- start to end
|
|
417
|
+
|
|
418
|
+
where start and end look like one of the above examples
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
string: A date(range) in our custom format.
|
|
422
|
+
fye: The fiscal year end to consider.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
A tuple (start, end) of dates.
|
|
426
|
+
"""
|
|
427
|
+
string = string.strip().lower()
|
|
428
|
+
if not string:
|
|
429
|
+
return None, None
|
|
430
|
+
|
|
431
|
+
string = substitute(string, fye).lower()
|
|
432
|
+
|
|
433
|
+
match = IS_RANGE_RE.match(string)
|
|
434
|
+
if match:
|
|
435
|
+
return (
|
|
436
|
+
parse_date(match.group(1), fye)[0],
|
|
437
|
+
parse_date(match.group(2), fye)[1],
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
match = YEAR_RE.match(string)
|
|
441
|
+
if match:
|
|
442
|
+
year = int(match.group(0))
|
|
443
|
+
start = datetime.date(year, 1, 1)
|
|
444
|
+
return start, Year.get_next(start)
|
|
445
|
+
|
|
446
|
+
match = MONTH_RE.match(string)
|
|
447
|
+
if match:
|
|
448
|
+
year, month = map(int, match.group(1, 2))
|
|
449
|
+
start = datetime.date(year, month, 1)
|
|
450
|
+
return start, Month.get_next(start)
|
|
451
|
+
|
|
452
|
+
match = DAY_RE.match(string)
|
|
453
|
+
if match:
|
|
454
|
+
year, month, day = map(int, match.group(1, 2, 3))
|
|
455
|
+
start = datetime.date(year, month, day)
|
|
456
|
+
return start, Day.get_next(start)
|
|
457
|
+
|
|
458
|
+
match = WEEK_RE.match(string)
|
|
459
|
+
if match:
|
|
460
|
+
year, week = map(int, match.group(1, 2))
|
|
461
|
+
start = (
|
|
462
|
+
datetime.datetime.strptime(f"{year}-W{week}-1", "%G-W%V-%w")
|
|
463
|
+
.replace(tzinfo=datetime.timezone.utc)
|
|
464
|
+
.date()
|
|
465
|
+
)
|
|
466
|
+
return start, Week.get_next(start)
|
|
467
|
+
|
|
468
|
+
match = QUARTER_RE.match(string)
|
|
469
|
+
if match:
|
|
470
|
+
year, quarter = map(int, match.group(1, 2))
|
|
471
|
+
quarter_first_day = datetime.date(year, (quarter - 1) * 3 + 1, 1)
|
|
472
|
+
return (
|
|
473
|
+
quarter_first_day,
|
|
474
|
+
Quarter.get_next(quarter_first_day),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
match = FY_RE.match(string)
|
|
478
|
+
if match:
|
|
479
|
+
year = int(match.group(1))
|
|
480
|
+
return get_fiscal_period(year, fye)
|
|
481
|
+
|
|
482
|
+
match = FY_QUARTER_RE.match(string)
|
|
483
|
+
if match:
|
|
484
|
+
year, quarter = map(int, match.group(1, 2))
|
|
485
|
+
return get_fiscal_period(year, fye, quarter)
|
|
486
|
+
|
|
487
|
+
return None, None
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def month_offset(date: datetime.date, months: int) -> datetime.date:
|
|
491
|
+
"""Offsets a date by a given number of months.
|
|
492
|
+
|
|
493
|
+
Maintains the day, unless that day is invalid when it will
|
|
494
|
+
raise a ValueError
|
|
495
|
+
|
|
496
|
+
"""
|
|
497
|
+
year_delta, month = divmod(date.month - 1 + months, 12)
|
|
498
|
+
|
|
499
|
+
return date.replace(year=date.year + year_delta, month=month + 1)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def parse_fye_string(fye: str) -> FiscalYearEnd | None:
|
|
503
|
+
"""Parse a string option for the fiscal year end.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
fye: The end of the fiscal year to parse.
|
|
507
|
+
"""
|
|
508
|
+
match = re.match(r"^(?P<month>\d{2})-(?P<day>\d{2})$", fye)
|
|
509
|
+
if not match:
|
|
510
|
+
return None
|
|
511
|
+
month = int(match.group("month"))
|
|
512
|
+
day = int(match.group("day"))
|
|
513
|
+
try:
|
|
514
|
+
_ = datetime.date(2001, (month - 1) % 12 + 1, day)
|
|
515
|
+
return FiscalYearEnd(month, day)
|
|
516
|
+
except ValueError:
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def get_fiscal_period(
|
|
521
|
+
year: int,
|
|
522
|
+
fye: FiscalYearEnd | None,
|
|
523
|
+
quarter: int | None = None,
|
|
524
|
+
) -> tuple[datetime.date | None, datetime.date | None]:
|
|
525
|
+
"""Calculate fiscal periods.
|
|
526
|
+
|
|
527
|
+
Uses the fava option "fiscal-year-end" which should be in "%m-%d" format.
|
|
528
|
+
Defaults to calendar year [12-31]
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
year: An integer year
|
|
532
|
+
fye: End date for period in "%m-%d" format
|
|
533
|
+
quarter: one of [None, 1, 2, 3 or 4]
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
A tuple (start, end) of dates.
|
|
537
|
+
|
|
538
|
+
"""
|
|
539
|
+
fye = fye or END_OF_YEAR
|
|
540
|
+
start = (
|
|
541
|
+
datetime.date(year - 1 + fye.year_offset, fye.month_of_year, fye.day)
|
|
542
|
+
+ ONE_DAY
|
|
543
|
+
)
|
|
544
|
+
# Special case 02-28 because of leap years
|
|
545
|
+
if fye.month_of_year == 2 and fye.day == 28:
|
|
546
|
+
start = start.replace(month=3, day=1)
|
|
547
|
+
|
|
548
|
+
if quarter is None:
|
|
549
|
+
return start, start.replace(year=start.year + 1)
|
|
550
|
+
|
|
551
|
+
if not fye.has_quarters():
|
|
552
|
+
return None, None
|
|
553
|
+
|
|
554
|
+
if quarter < 1 or quarter > 4:
|
|
555
|
+
return None, None
|
|
556
|
+
|
|
557
|
+
start = month_offset(start, (quarter - 1) * 3)
|
|
558
|
+
|
|
559
|
+
return start, month_offset(start, 3)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def days_in_daterange(
|
|
563
|
+
start_date: datetime.date,
|
|
564
|
+
end_date: datetime.date,
|
|
565
|
+
) -> Iterator[datetime.date]:
|
|
566
|
+
"""Yield a datetime for every day in the specified interval.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
start_date: A start date.
|
|
570
|
+
end_date: An end date (exclusive).
|
|
571
|
+
|
|
572
|
+
Yields:
|
|
573
|
+
All days between `start_date` to `end_date`.
|
|
574
|
+
"""
|
|
575
|
+
for diff in range((end_date - start_date).days):
|
|
576
|
+
yield start_date + timedelta(diff)
|
rustfava/util/excel.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Writing query results to CSV and spreadsheet documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import datetime
|
|
7
|
+
import io
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from importlib.util import find_spec
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from rustfava.rustledger.query import ColumnDescription as Column
|
|
16
|
+
|
|
17
|
+
ResultRow = tuple[Any, ...]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Just check whether it's installed here without importing.
|
|
21
|
+
HAVE_EXCEL = find_spec("pyexcel") is not None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidResultFormatError(ValueError): # noqa: D101
|
|
25
|
+
def __init__(self, result_format: str) -> None: # pragma: no cover
|
|
26
|
+
super().__init__(f"Invalid result format: {result_format}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def to_excel(
|
|
30
|
+
types: list[Column],
|
|
31
|
+
rows: list[ResultRow],
|
|
32
|
+
result_format: str,
|
|
33
|
+
query_string: str,
|
|
34
|
+
) -> io.BytesIO:
|
|
35
|
+
"""Save result to spreadsheet document.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
types: query result_types.
|
|
39
|
+
rows: query result_rows.
|
|
40
|
+
result_format: 'xlsx' or 'ods'.
|
|
41
|
+
query_string: The query string (is written to the document).
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The (binary) file contents.
|
|
45
|
+
"""
|
|
46
|
+
if result_format not in {"xlsx", "ods"}: # pragma: no cover
|
|
47
|
+
raise InvalidResultFormatError(result_format)
|
|
48
|
+
resp = io.BytesIO()
|
|
49
|
+
# Lazily import pyexcel
|
|
50
|
+
# since this is a conditional dependency, there will be different mypy
|
|
51
|
+
# errors depending on whether it's installed
|
|
52
|
+
import pyexcel # type: ignore # noqa: PGH003, PLC0415
|
|
53
|
+
|
|
54
|
+
book = pyexcel.Book(
|
|
55
|
+
{
|
|
56
|
+
"Results": _result_array(types, rows),
|
|
57
|
+
"Query": [["Query"], [query_string]],
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
book.save_to_memory(result_format, resp)
|
|
61
|
+
resp.seek(0)
|
|
62
|
+
return resp
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def to_csv(types: list[Column], rows: list[ResultRow]) -> io.BytesIO:
|
|
66
|
+
"""Save result to CSV.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
types: query result_types.
|
|
70
|
+
rows: query result_rows.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The (binary) file contents.
|
|
74
|
+
"""
|
|
75
|
+
resp = io.StringIO()
|
|
76
|
+
result_array = _result_array(types, rows)
|
|
77
|
+
csv.writer(resp).writerows(result_array)
|
|
78
|
+
return io.BytesIO(resp.getvalue().encode("utf-8"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _result_array(
|
|
82
|
+
types: list[Column],
|
|
83
|
+
rows: list[ResultRow],
|
|
84
|
+
) -> list[list[str]]:
|
|
85
|
+
result_array = [[t.name for t in types]]
|
|
86
|
+
result_array.extend(_row_to_pyexcel(row, types) for row in rows)
|
|
87
|
+
return result_array
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _row_to_pyexcel(row: ResultRow, header: list[Column]) -> list[str]:
|
|
91
|
+
result = []
|
|
92
|
+
for idx, column in enumerate(header):
|
|
93
|
+
value = row[idx]
|
|
94
|
+
if not value:
|
|
95
|
+
result.append(value)
|
|
96
|
+
continue
|
|
97
|
+
type_ = column.datatype
|
|
98
|
+
if type_ is Decimal:
|
|
99
|
+
result.append(float(value))
|
|
100
|
+
elif type_ is int:
|
|
101
|
+
result.append(value)
|
|
102
|
+
elif type_ is set:
|
|
103
|
+
result.append(" ".join(value))
|
|
104
|
+
elif type_ is datetime.date:
|
|
105
|
+
result.append(str(value))
|
|
106
|
+
elif type_ is dict or isinstance(value, dict):
|
|
107
|
+
# Handle inventory dicts from rustledger
|
|
108
|
+
if isinstance(value, dict):
|
|
109
|
+
parts = [f"{v} {k}" for k, v in value.items()]
|
|
110
|
+
result.append(", ".join(parts))
|
|
111
|
+
else:
|
|
112
|
+
result.append(str(value))
|
|
113
|
+
else:
|
|
114
|
+
if not isinstance(value, str): # pragma: no cover
|
|
115
|
+
msg = f"unexpected type {type(value)}"
|
|
116
|
+
raise TypeError(msg)
|
|
117
|
+
result.append(value)
|
|
118
|
+
return result
|