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/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