morphir-python 0.0.1__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.
- morphir/__init__.py +14 -0
- morphir/ir/__init__.py +110 -0
- morphir/ir/access_controlled.py +15 -0
- morphir/ir/decorations.py +19 -0
- morphir/ir/diagnostics.py +105 -0
- morphir/ir/distribution.py +21 -0
- morphir/ir/document.py +37 -0
- morphir/ir/documented.py +11 -0
- morphir/ir/format_version.py +483 -0
- morphir/ir/fqname.py +9 -0
- morphir/ir/json.py +11 -0
- morphir/ir/literal.py +25 -0
- morphir/ir/meta.py +11 -0
- morphir/ir/migrate.py +1267 -0
- morphir/ir/module.py +17 -0
- morphir/ir/name.py +27 -0
- morphir/ir/package.py +15 -0
- morphir/ir/path.py +25 -0
- morphir/ir/qname.py +9 -0
- morphir/ir/ref.py +17 -0
- morphir/ir/type.py +37 -0
- morphir/ir/type_constraints.py +33 -0
- morphir/ir/type_def.py +19 -0
- morphir/ir/type_spec.py +25 -0
- morphir/ir/v3/__init__.py +64 -0
- morphir/ir/v3/_legacy_value.py +201 -0
- morphir/ir/v3/access_controlled.py +31 -0
- morphir/ir/v3/codec.py +1275 -0
- morphir/ir/v3/codec_v1.py +491 -0
- morphir/ir/v3/codec_v2.py +187 -0
- morphir/ir/v3/distribution.py +42 -0
- morphir/ir/v3/documented.py +13 -0
- morphir/ir/v3/fqname.py +30 -0
- morphir/ir/v3/literal.py +46 -0
- morphir/ir/v3/module.py +32 -0
- morphir/ir/v3/name.py +42 -0
- morphir/ir/v3/package.py +23 -0
- morphir/ir/v3/path.py +29 -0
- morphir/ir/v3/qname.py +16 -0
- morphir/ir/v3/type.py +126 -0
- morphir/ir/v3/value.py +235 -0
- morphir/ir/v4/__init__.py +160 -0
- morphir/ir/v4/access_controlled.py +17 -0
- morphir/ir/v4/annotation.py +75 -0
- morphir/ir/v4/codec.py +2161 -0
- morphir/ir/v4/decorations.py +88 -0
- morphir/ir/v4/distribution.py +56 -0
- morphir/ir/v4/document.py +69 -0
- morphir/ir/v4/documented.py +10 -0
- morphir/ir/v4/fqname.py +69 -0
- morphir/ir/v4/incompleteness.py +78 -0
- morphir/ir/v4/json.py +103 -0
- morphir/ir/v4/literal.py +51 -0
- morphir/ir/v4/meta.py +27 -0
- morphir/ir/v4/module.py +30 -0
- morphir/ir/v4/name.py +69 -0
- morphir/ir/v4/package.py +19 -0
- morphir/ir/v4/path.py +50 -0
- morphir/ir/v4/qname.py +34 -0
- morphir/ir/v4/ref.py +35 -0
- morphir/ir/v4/type.py +140 -0
- morphir/ir/v4/type_constraints.py +91 -0
- morphir/ir/v4/type_def.py +38 -0
- morphir/ir/v4/type_spec.py +64 -0
- morphir/ir/v4/value.py +480 -0
- morphir/ir/value.py +81 -0
- morphir/py.typed +0 -0
- morphir/sdk/__init__.py +110 -0
- morphir/sdk/_compare.py +187 -0
- morphir/sdk/aggregate.py +421 -0
- morphir/sdk/basics.py +504 -0
- morphir/sdk/char.py +110 -0
- morphir/sdk/decimal.py +311 -0
- morphir/sdk/dict.py +336 -0
- morphir/sdk/instant.py +118 -0
- morphir/sdk/int.py +86 -0
- morphir/sdk/key.py +522 -0
- morphir/sdk/list.py +356 -0
- morphir/sdk/local_date.py +436 -0
- morphir/sdk/local_time.py +267 -0
- morphir/sdk/maybe.py +135 -0
- morphir/sdk/number.py +237 -0
- morphir/sdk/regex.py +287 -0
- morphir/sdk/result.py +162 -0
- morphir/sdk/result_list.py +169 -0
- morphir/sdk/rule.py +103 -0
- morphir/sdk/set.py +214 -0
- morphir/sdk/stateful_app.py +49 -0
- morphir/sdk/string.py +427 -0
- morphir/sdk/tuple.py +43 -0
- morphir/sdk/uuid.py +209 -0
- morphir_python-0.0.1.dist-info/METADATA +269 -0
- morphir_python-0.0.1.dist-info/RECORD +99 -0
- morphir_python-0.0.1.dist-info/WHEEL +4 -0
- morphir_python-0.0.1.dist-info/entry_points.txt +2 -0
- morphir_python-0.0.1.dist-info/licenses/LICENSE +201 -0
- morphir_tools/__init__.py +13 -0
- morphir_tools/cli.py +223 -0
- morphir_tools/py.typed +0 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Morphir.SDK.LocalDate: a date with no time zone.
|
|
2
|
+
|
|
3
|
+
A `LocalDate` is the standard library `datetime.date`, a date in the proleptic
|
|
4
|
+
Gregorian calendar. The reference behaviour is the Elm runtime, which uses
|
|
5
|
+
justinmimbs/date.
|
|
6
|
+
|
|
7
|
+
>>> from morphir.sdk import local_date as LocalDate
|
|
8
|
+
>>> LocalDate.from_iso("2020-01-31")
|
|
9
|
+
Just(value=datetime.date(2020, 1, 31))
|
|
10
|
+
>>> LocalDate.to_iso_string(LocalDate.add_months(1, datetime.date(2020, 1, 31)))
|
|
11
|
+
'2020-02-29'
|
|
12
|
+
|
|
13
|
+
`from_iso` accepts the ISO 8601 date forms that the Elm parser accepts: calendar
|
|
14
|
+
dates (`2018-09-26`, `20180926`, `2018-09`, `201809`), ordinal dates
|
|
15
|
+
(`2018-269`, `2018269`), week dates (`2018-W39-3`, `2018W393`, `2018-W39`,
|
|
16
|
+
`2018W39`) and a year alone (`2018`). Text with a time part gives `Nothing`.
|
|
17
|
+
|
|
18
|
+
`int_to_month` is in the Elm runtime but not in the IR specification.
|
|
19
|
+
|
|
20
|
+
Departures from the Elm runtime:
|
|
21
|
+
|
|
22
|
+
* A `datetime.date` holds the years 1 to 9999 only. `from_iso` and `from_parts`
|
|
23
|
+
give `Nothing` for a year outside these limits. `from_calendar_date` and
|
|
24
|
+
`from_ordinal_date` raise `ValueError`. The `add_*` functions raise
|
|
25
|
+
`OverflowError` when the result is outside these limits.
|
|
26
|
+
* `diff_in_months` and `diff_in_years` use integer arithmetic. The Elm runtime
|
|
27
|
+
uses a float formula, which in rare cases loses one month when the two dates
|
|
28
|
+
have the same day of the month (for example between the years 1366 and 1367).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import calendar
|
|
32
|
+
import datetime
|
|
33
|
+
import re
|
|
34
|
+
from enum import Enum
|
|
35
|
+
|
|
36
|
+
from morphir.sdk.maybe import Just, Maybe, Nothing
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"DayOfWeek",
|
|
40
|
+
"LocalDate",
|
|
41
|
+
"Month",
|
|
42
|
+
"add_days",
|
|
43
|
+
"add_months",
|
|
44
|
+
"add_weeks",
|
|
45
|
+
"add_years",
|
|
46
|
+
"day",
|
|
47
|
+
"day_of_week",
|
|
48
|
+
"diff_in_days",
|
|
49
|
+
"diff_in_months",
|
|
50
|
+
"diff_in_weeks",
|
|
51
|
+
"diff_in_years",
|
|
52
|
+
"from_calendar_date",
|
|
53
|
+
"from_iso",
|
|
54
|
+
"from_ordinal_date",
|
|
55
|
+
"from_parts",
|
|
56
|
+
"int_to_month",
|
|
57
|
+
"is_weekday",
|
|
58
|
+
"is_weekend",
|
|
59
|
+
"month",
|
|
60
|
+
"month_number",
|
|
61
|
+
"month_to_int",
|
|
62
|
+
"to_iso_string",
|
|
63
|
+
"year",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
LocalDate = datetime.date
|
|
67
|
+
"""The standard library date type."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DayOfWeek(Enum):
|
|
71
|
+
"""A day of the week."""
|
|
72
|
+
|
|
73
|
+
Monday = "Monday"
|
|
74
|
+
Tuesday = "Tuesday"
|
|
75
|
+
Wednesday = "Wednesday"
|
|
76
|
+
Thursday = "Thursday"
|
|
77
|
+
Friday = "Friday"
|
|
78
|
+
Saturday = "Saturday"
|
|
79
|
+
Sunday = "Sunday"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Month(Enum):
|
|
83
|
+
"""A month of the Gregorian calendar."""
|
|
84
|
+
|
|
85
|
+
January = "January"
|
|
86
|
+
February = "February"
|
|
87
|
+
March = "March"
|
|
88
|
+
April = "April"
|
|
89
|
+
May = "May"
|
|
90
|
+
June = "June"
|
|
91
|
+
July = "July"
|
|
92
|
+
August = "August"
|
|
93
|
+
September = "September"
|
|
94
|
+
October = "October"
|
|
95
|
+
November = "November"
|
|
96
|
+
December = "December"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_MONTHS = tuple(Month)
|
|
100
|
+
_DAYS_OF_WEEK = tuple(DayOfWeek)
|
|
101
|
+
|
|
102
|
+
# The year can have a minus sign. Only ASCII digits are accepted, as in Elm.
|
|
103
|
+
_ISO = re.compile(
|
|
104
|
+
r"(?P<year>-?[0-9]{4})"
|
|
105
|
+
r"(?:"
|
|
106
|
+
r"-(?P<ordinal>[0-9]{3})"
|
|
107
|
+
r"|-(?P<month>[0-9]{2})(?:-(?P<day>[0-9]{2}))?"
|
|
108
|
+
r"|-W(?P<week>[0-9]{2})(?:-(?P<weekday>[0-9]))?"
|
|
109
|
+
r"|(?P<basic_month>[0-9]{2})(?P<basic_day>[0-9]{2})?"
|
|
110
|
+
r"|(?P<basic_ordinal>[0-9]{3})"
|
|
111
|
+
r"|W(?P<basic_week>[0-9]{2})(?P<basic_weekday>[0-9])?"
|
|
112
|
+
r")?"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _check_year(year: int, owner: str) -> None:
|
|
117
|
+
if not datetime.MINYEAR <= year <= datetime.MAXYEAR:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"{owner}: year {year} is not in the range "
|
|
120
|
+
f"{datetime.MINYEAR} to {datetime.MAXYEAR}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _days_in_month(year: int, month_number: int) -> int:
|
|
125
|
+
return calendar.monthrange(year, month_number)[1]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _days_in_year(year: int) -> int:
|
|
129
|
+
return 366 if calendar.isleap(year) else 365
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _truncated_div(a: int, b: int) -> int:
|
|
133
|
+
quotient = abs(a) // abs(b)
|
|
134
|
+
return quotient if (a >= 0) == (b >= 0) else -quotient
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _month_units(date: LocalDate) -> int:
|
|
138
|
+
# Whole months since year 1, scaled by 100, plus the day of the month. The
|
|
139
|
+
# day is at most 31, so the day part never carries into the month part.
|
|
140
|
+
return (12 * (date.year - 1) + (date.month - 1)) * 100 + date.day
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def diff_in_days(date1: LocalDate, date2: LocalDate) -> int:
|
|
144
|
+
"""Find the number of days from the first date to the second date.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
date1: The start date.
|
|
148
|
+
date2: The end date.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
The number of days. It is negative when `date2` is before `date1`.
|
|
152
|
+
"""
|
|
153
|
+
return date2.toordinal() - date1.toordinal()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def diff_in_weeks(date1: LocalDate, date2: LocalDate) -> int:
|
|
157
|
+
"""Find the number of whole weeks from the first date to the second date.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
date1: The start date.
|
|
161
|
+
date2: The end date.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The number of whole weeks, rounded toward zero.
|
|
165
|
+
"""
|
|
166
|
+
return _truncated_div(diff_in_days(date1, date2), 7)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def diff_in_months(date1: LocalDate, date2: LocalDate) -> int:
|
|
170
|
+
"""Find the number of whole months from the first date to the second date.
|
|
171
|
+
|
|
172
|
+
A month is complete when the day of the month of `date2` is equal to or
|
|
173
|
+
after the day of the month of `date1`.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
date1: The start date.
|
|
177
|
+
date2: The end date.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
The number of whole months, rounded toward zero.
|
|
181
|
+
"""
|
|
182
|
+
return _truncated_div(_month_units(date2) - _month_units(date1), 100)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def diff_in_years(date1: LocalDate, date2: LocalDate) -> int:
|
|
186
|
+
"""Find the number of whole years from the first date to the second date.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
date1: The start date.
|
|
190
|
+
date2: The end date.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
The number of whole years, rounded toward zero.
|
|
194
|
+
"""
|
|
195
|
+
return _truncated_div(diff_in_months(date1, date2), 12)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def add_days(offset: int, start_date: LocalDate) -> LocalDate:
|
|
199
|
+
"""Add a number of days to a date.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
offset: The number of days. It can be negative.
|
|
203
|
+
start_date: The date to start from.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
The new date.
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
OverflowError: If the result is not in the years 1 to 9999.
|
|
210
|
+
"""
|
|
211
|
+
return start_date + datetime.timedelta(days=offset)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def add_weeks(offset: int, start_date: LocalDate) -> LocalDate:
|
|
215
|
+
"""Add a number of weeks to a date.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
offset: The number of weeks. It can be negative.
|
|
219
|
+
start_date: The date to start from.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
The new date.
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
OverflowError: If the result is not in the years 1 to 9999.
|
|
226
|
+
"""
|
|
227
|
+
return start_date + datetime.timedelta(weeks=offset)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def add_months(offset: int, start_date: LocalDate) -> LocalDate:
|
|
231
|
+
"""Add a number of months to a date.
|
|
232
|
+
|
|
233
|
+
When the new month has fewer days than the day of the start date, the result
|
|
234
|
+
is the last day of the new month: one month after 31 January is 28 or 29
|
|
235
|
+
February.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
offset: The number of months. It can be negative.
|
|
239
|
+
start_date: The date to start from.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
The new date.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
OverflowError: If the result is not in the years 1 to 9999.
|
|
246
|
+
"""
|
|
247
|
+
whole_months = 12 * (start_date.year - 1) + (start_date.month - 1) + offset
|
|
248
|
+
new_year = whole_months // 12 + 1
|
|
249
|
+
new_month = whole_months % 12 + 1
|
|
250
|
+
if not datetime.MINYEAR <= new_year <= datetime.MAXYEAR:
|
|
251
|
+
raise OverflowError("add_months: date value out of range")
|
|
252
|
+
new_day = min(start_date.day, _days_in_month(new_year, new_month))
|
|
253
|
+
return datetime.date(new_year, new_month, new_day)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def add_years(offset: int, start_date: LocalDate) -> LocalDate:
|
|
257
|
+
"""Add a number of years to a date.
|
|
258
|
+
|
|
259
|
+
One year after 29 February is 28 February.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
offset: The number of years. It can be negative.
|
|
263
|
+
start_date: The date to start from.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
The new date.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
OverflowError: If the result is not in the years 1 to 9999.
|
|
270
|
+
"""
|
|
271
|
+
return add_months(12 * offset, start_date)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def from_calendar_date(y: int, m: Month, d: int) -> LocalDate:
|
|
275
|
+
"""Make a date from a year, a month and a day of the month.
|
|
276
|
+
|
|
277
|
+
A day that is out of range is clamped to the first or the last day of the
|
|
278
|
+
month.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
y: The year.
|
|
282
|
+
m: The month.
|
|
283
|
+
d: The day of the month.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
The date.
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
ValueError: If the year is not in the range 1 to 9999.
|
|
290
|
+
"""
|
|
291
|
+
_check_year(y, "from_calendar_date")
|
|
292
|
+
month_num = month_to_int(m)
|
|
293
|
+
return datetime.date(y, month_num, max(1, min(d, _days_in_month(y, month_num))))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def from_ordinal_date(y: int, day_of_year: int) -> LocalDate:
|
|
297
|
+
"""Make a date from a year and a day of the year.
|
|
298
|
+
|
|
299
|
+
A day that is out of range is clamped to the first or the last day of the
|
|
300
|
+
year.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
y: The year.
|
|
304
|
+
day_of_year: The day of the year, where 1 January is day 1.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
The date.
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
ValueError: If the year is not in the range 1 to 9999.
|
|
311
|
+
"""
|
|
312
|
+
_check_year(y, "from_ordinal_date")
|
|
313
|
+
ordinal = max(1, min(day_of_year, _days_in_year(y)))
|
|
314
|
+
return datetime.date(y, 1, 1) + datetime.timedelta(days=ordinal - 1)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _from_iso_match(match: re.Match[str]) -> LocalDate:
|
|
318
|
+
# Raises ValueError when a part is out of range.
|
|
319
|
+
year_num = int(match["year"])
|
|
320
|
+
if not datetime.MINYEAR <= year_num <= datetime.MAXYEAR:
|
|
321
|
+
raise ValueError("year out of range")
|
|
322
|
+
month_text = match["month"] or match["basic_month"]
|
|
323
|
+
if month_text is not None:
|
|
324
|
+
day_text = match["day"] or match["basic_day"] or "01"
|
|
325
|
+
return datetime.date(year_num, int(month_text), int(day_text))
|
|
326
|
+
week_text = match["week"] or match["basic_week"]
|
|
327
|
+
if week_text is not None:
|
|
328
|
+
weekday_text = match["weekday"] or match["basic_weekday"] or "1"
|
|
329
|
+
return datetime.date.fromisocalendar(
|
|
330
|
+
year_num, int(week_text), int(weekday_text)
|
|
331
|
+
)
|
|
332
|
+
ordinal = int(match["ordinal"] or match["basic_ordinal"] or "001")
|
|
333
|
+
if not 1 <= ordinal <= _days_in_year(year_num):
|
|
334
|
+
raise ValueError("day of year out of range")
|
|
335
|
+
return datetime.date(year_num, 1, 1) + datetime.timedelta(days=ordinal - 1)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def from_iso(iso: str) -> Maybe[LocalDate]:
|
|
339
|
+
"""Read a date from ISO 8601 text.
|
|
340
|
+
|
|
341
|
+
The module docstring lists the accepted forms. Parts that are out of range
|
|
342
|
+
are not clamped: `2020-01-55` gives `Nothing`.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
iso: The text to read.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
`Just` the date, or `Nothing` when the text is not a valid date.
|
|
349
|
+
"""
|
|
350
|
+
match = _ISO.fullmatch(iso)
|
|
351
|
+
if match is None:
|
|
352
|
+
return Nothing()
|
|
353
|
+
try:
|
|
354
|
+
return Just(_from_iso_match(match))
|
|
355
|
+
except ValueError, OverflowError:
|
|
356
|
+
return Nothing()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def to_iso_string(date: LocalDate) -> str:
|
|
360
|
+
"""Convert a date to ISO 8601 text, in the form `YYYY-MM-DD`."""
|
|
361
|
+
return f"{date.year:04d}-{date.month:02d}-{date.day:02d}"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def from_parts(year: int, month: int, day: int) -> Maybe[LocalDate]:
|
|
365
|
+
"""Make a date from a year, a month number and a day of the month.
|
|
366
|
+
|
|
367
|
+
No value is clamped: `from_parts(2000, 2, 30)` gives `Nothing`.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
year: The year.
|
|
371
|
+
month: The month number, where January is 1.
|
|
372
|
+
day: The day of the month.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
`Just` the date, or `Nothing` when a part is out of range.
|
|
376
|
+
"""
|
|
377
|
+
try:
|
|
378
|
+
return Just(datetime.date(year, month, day))
|
|
379
|
+
except ValueError, OverflowError:
|
|
380
|
+
return Nothing()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def year(local_date: LocalDate) -> int:
|
|
384
|
+
"""Return the year of a date."""
|
|
385
|
+
return local_date.year
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def month(local_date: LocalDate) -> Month:
|
|
389
|
+
"""Return the month of a date."""
|
|
390
|
+
return _MONTHS[local_date.month - 1]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def month_number(local_date: LocalDate) -> int:
|
|
394
|
+
"""Return the month of a date as a number, where January is 1."""
|
|
395
|
+
return local_date.month
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def month_to_int(m: Month) -> int:
|
|
399
|
+
"""Convert a month to a number, where January is 1 and December is 12."""
|
|
400
|
+
return _MONTHS.index(m) + 1
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def int_to_month(i: int) -> Maybe[Month]:
|
|
404
|
+
"""Convert a number to a month, where January is 1.
|
|
405
|
+
|
|
406
|
+
This function is in the Elm runtime but not in the IR specification.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
i: The month number.
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
`Just` the month, or `Nothing` when the number is not in 1 to 12.
|
|
413
|
+
"""
|
|
414
|
+
if 1 <= i <= 12:
|
|
415
|
+
return Just(_MONTHS[i - 1])
|
|
416
|
+
return Nothing()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def day(local_date: LocalDate) -> int:
|
|
420
|
+
"""Return the day of the month of a date (1 to 31)."""
|
|
421
|
+
return local_date.day
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def day_of_week(local_date: LocalDate) -> DayOfWeek:
|
|
425
|
+
"""Return the day of the week of a date."""
|
|
426
|
+
return _DAYS_OF_WEEK[local_date.weekday()]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def is_weekend(local_date: LocalDate) -> bool:
|
|
430
|
+
"""Check that a date is a Saturday or a Sunday."""
|
|
431
|
+
return local_date.weekday() >= 5
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def is_weekday(local_date: LocalDate) -> bool:
|
|
435
|
+
"""Check that a date is not a Saturday or a Sunday."""
|
|
436
|
+
return not is_weekend(local_date)
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Morphir.SDK.LocalTime: a point in time with no time zone.
|
|
2
|
+
|
|
3
|
+
A `LocalTime` is an instant: a whole number of milliseconds from the Unix epoch,
|
|
4
|
+
1970-01-01T00:00:00Z. It holds a date and a time of day. This is the data of the
|
|
5
|
+
Elm runtime, where `LocalTime` is an alias of `Time.Posix`. Thus arithmetic does
|
|
6
|
+
not go around midnight: 25 hours after a time is on the next day, and the
|
|
7
|
+
difference between two times can be more than 24 hours.
|
|
8
|
+
|
|
9
|
+
>>> from morphir.sdk import local_time as LocalTime
|
|
10
|
+
>>> LocalTime.from_iso("2022-01-28T12:56:30")
|
|
11
|
+
Just(value=LocalTime(milliseconds=1643374590000))
|
|
12
|
+
>>> start = LocalTime.from_milliseconds(1643374590000)
|
|
13
|
+
>>> LocalTime.diff_in_hours(LocalTime.add_hours(25, start), start)
|
|
14
|
+
25
|
|
15
|
+
|
|
16
|
+
The number of milliseconds is a Python `int` with no limit. `LocalTime` values
|
|
17
|
+
have value equality, a hash and an order (`<`, `sorted`).
|
|
18
|
+
|
|
19
|
+
`from_iso` reads the text that `Iso8601.toTime` of
|
|
20
|
+
rtfeldman/elm-iso8601-date-strings reads: a date `YYYY-MM-DD`, then optionally
|
|
21
|
+
`T` and a time. Text with a time and no date (`12:56:30`) gives `Nothing`.
|
|
22
|
+
|
|
23
|
+
Additions to the specification: `to_milliseconds` and `compare`.
|
|
24
|
+
|
|
25
|
+
Departures from the Elm runtime:
|
|
26
|
+
|
|
27
|
+
* `from_iso` gives `Nothing` for an hour above 23, a minute or a second above
|
|
28
|
+
59, a UTC offset above 23:59 and the day number 00. The Elm parser accepts
|
|
29
|
+
these values and carries them into the result.
|
|
30
|
+
* For a negative UTC offset with minutes (`-05:30`), `from_iso` subtracts the
|
|
31
|
+
minutes. The Elm parser adds them.
|
|
32
|
+
* `from_iso` gives `Nothing` for the year 0000. The text form permits the years
|
|
33
|
+
0000 to 9999 only, in Elm also. A UTC offset can move the result a small
|
|
34
|
+
distance out of the years 1 to 9999, and that result is given.
|
|
35
|
+
* A value has no upper or lower limit. In Elm the number is a JavaScript number,
|
|
36
|
+
which is exact only to 2^53.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import datetime
|
|
40
|
+
import math
|
|
41
|
+
import re
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
|
|
44
|
+
from morphir.sdk._compare import Order
|
|
45
|
+
from morphir.sdk.maybe import Just, Maybe, Nothing
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"LocalTime",
|
|
49
|
+
"add_hours",
|
|
50
|
+
"add_minutes",
|
|
51
|
+
"add_seconds",
|
|
52
|
+
"compare",
|
|
53
|
+
"diff_in_hours",
|
|
54
|
+
"diff_in_minutes",
|
|
55
|
+
"diff_in_seconds",
|
|
56
|
+
"from_iso",
|
|
57
|
+
"from_milliseconds",
|
|
58
|
+
"to_milliseconds",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True, order=True)
|
|
63
|
+
class LocalTime:
|
|
64
|
+
"""A point in time, as milliseconds from 1970-01-01T00:00:00Z."""
|
|
65
|
+
|
|
66
|
+
milliseconds: int
|
|
67
|
+
"""The number of milliseconds from the epoch. It can be negative."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_MILLIS_PER_SECOND = 1000
|
|
71
|
+
_MILLIS_PER_MINUTE = 60_000
|
|
72
|
+
_MILLIS_PER_HOUR = 3_600_000
|
|
73
|
+
_MILLIS_PER_DAY = 86_400_000
|
|
74
|
+
_EPOCH_ORDINAL = datetime.date(1970, 1, 1).toordinal()
|
|
75
|
+
|
|
76
|
+
# Only ASCII digits are accepted, as in Elm.
|
|
77
|
+
_ISO = re.compile(
|
|
78
|
+
r"(?P<year>[0-9]{4})-?(?P<month>[0-9]{2})-?(?P<day>[0-9]{2})"
|
|
79
|
+
r"(?:T(?P<hour>[0-9]{2}):?(?P<minute>[0-9]{2})(?::?(?P<second>[0-9]{2}))?"
|
|
80
|
+
r"(?:\.(?P<fraction>[0-9]{0,9}))?"
|
|
81
|
+
r"(?P<offset>Z|(?P<sign>[+-])(?P<offset_hour>[0-9]{2})"
|
|
82
|
+
r"(?::?(?P<offset_minute>[0-9]{2}))?)?"
|
|
83
|
+
r")?"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _truncated_div(a: int, b: int) -> int:
|
|
88
|
+
# Elm's `//` rounds toward zero. Python's `//` rounds down.
|
|
89
|
+
quotient = abs(a) // abs(b)
|
|
90
|
+
return quotient if (a >= 0) == (b >= 0) else -quotient
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def from_milliseconds(millis: int) -> LocalTime:
|
|
94
|
+
"""Make a time from a number of milliseconds from the epoch.
|
|
95
|
+
|
|
96
|
+
The number is kept as it is, with no limit and no wrap at the end of a day.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
millis: The number of milliseconds from 1970-01-01T00:00:00Z. It can be
|
|
100
|
+
negative. A `float` is truncated toward zero.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The time.
|
|
104
|
+
"""
|
|
105
|
+
return LocalTime(int(millis))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def to_milliseconds(time: LocalTime) -> int:
|
|
109
|
+
"""Return the number of milliseconds from the epoch to a time.
|
|
110
|
+
|
|
111
|
+
This function is an addition to the specification.
|
|
112
|
+
"""
|
|
113
|
+
return time.milliseconds
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def compare(time_a: LocalTime, time_b: LocalTime) -> Order:
|
|
117
|
+
"""Compare two times. The earlier time is the smaller one.
|
|
118
|
+
|
|
119
|
+
This function is an addition to the specification.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
time_a: The first time.
|
|
123
|
+
time_b: The second time.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
`Order.LT`, `Order.EQ` or `Order.GT`.
|
|
127
|
+
"""
|
|
128
|
+
if time_a.milliseconds < time_b.milliseconds:
|
|
129
|
+
return Order.LT
|
|
130
|
+
if time_a.milliseconds > time_b.milliseconds:
|
|
131
|
+
return Order.GT
|
|
132
|
+
return Order.EQ
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def add_hours(hours: int, time: LocalTime) -> LocalTime:
|
|
136
|
+
"""Add a number of hours to a time. The result can be on a different day.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
hours: The number of hours. It can be negative.
|
|
140
|
+
time: The time to start from.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The new time.
|
|
144
|
+
"""
|
|
145
|
+
return LocalTime(time.milliseconds + hours * _MILLIS_PER_HOUR)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def add_minutes(minutes: int, time: LocalTime) -> LocalTime:
|
|
149
|
+
"""Add a number of minutes to a time. The result can be on a different day.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
minutes: The number of minutes. It can be negative.
|
|
153
|
+
time: The time to start from.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The new time.
|
|
157
|
+
"""
|
|
158
|
+
return LocalTime(time.milliseconds + minutes * _MILLIS_PER_MINUTE)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def add_seconds(seconds: int, time: LocalTime) -> LocalTime:
|
|
162
|
+
"""Add a number of seconds to a time. The result can be on a different day.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
seconds: The number of seconds. It can be negative.
|
|
166
|
+
time: The time to start from.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
The new time.
|
|
170
|
+
"""
|
|
171
|
+
return LocalTime(time.milliseconds + seconds * _MILLIS_PER_SECOND)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def diff_in_hours(time_a: LocalTime, time_b: LocalTime) -> int:
|
|
175
|
+
"""Find the number of whole hours from the second time to the first time.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
time_a: The time to subtract from.
|
|
179
|
+
time_b: The time that is subtracted.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
`time_a - time_b` in hours, rounded toward zero. It is negative when
|
|
183
|
+
`time_a` is before `time_b`.
|
|
184
|
+
"""
|
|
185
|
+
return _truncated_div(time_a.milliseconds - time_b.milliseconds, _MILLIS_PER_HOUR)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def diff_in_minutes(time_a: LocalTime, time_b: LocalTime) -> int:
|
|
189
|
+
"""Find the number of whole minutes from the second time to the first time.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
time_a: The time to subtract from.
|
|
193
|
+
time_b: The time that is subtracted.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
`time_a - time_b` in minutes, rounded toward zero. It is negative when
|
|
197
|
+
`time_a` is before `time_b`.
|
|
198
|
+
"""
|
|
199
|
+
return _truncated_div(time_a.milliseconds - time_b.milliseconds, _MILLIS_PER_MINUTE)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def diff_in_seconds(time_a: LocalTime, time_b: LocalTime) -> int:
|
|
203
|
+
"""Find the number of whole seconds from the second time to the first time.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
time_a: The time to subtract from.
|
|
207
|
+
time_b: The time that is subtracted.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
`time_a - time_b` in seconds, rounded toward zero. It is negative when
|
|
211
|
+
`time_a` is before `time_b`.
|
|
212
|
+
"""
|
|
213
|
+
return _truncated_div(time_a.milliseconds - time_b.milliseconds, _MILLIS_PER_SECOND)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def from_iso(iso: str) -> Maybe[LocalTime]:
|
|
217
|
+
"""Read a time from ISO 8601 text that has a date.
|
|
218
|
+
|
|
219
|
+
The accepted text is `YYYY-MM-DD`, then optionally `T`, `HH:MM`, optional
|
|
220
|
+
`:SS`, an optional fraction of a second with at most 9 digits, and an
|
|
221
|
+
optional UTC offset (`Z`, `+HH:MM`, `+HHMM` or `+HH`). The `-` and `:`
|
|
222
|
+
separators are optional. A fraction is rounded to the nearest millisecond.
|
|
223
|
+
Text with no UTC offset is read as UTC. Text with no time part gives
|
|
224
|
+
midnight.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
iso: The text to read.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
`Just` the time, or `Nothing` when the text is not valid, a part is out
|
|
231
|
+
of range, or the year is 0000.
|
|
232
|
+
"""
|
|
233
|
+
match = _ISO.fullmatch(iso)
|
|
234
|
+
if match is None:
|
|
235
|
+
return Nothing()
|
|
236
|
+
try:
|
|
237
|
+
date = datetime.date(int(match["year"]), int(match["month"]), int(match["day"]))
|
|
238
|
+
except ValueError:
|
|
239
|
+
return Nothing()
|
|
240
|
+
hour = int(match["hour"] or "0")
|
|
241
|
+
minute = int(match["minute"] or "0")
|
|
242
|
+
second = int(match["second"] or "0")
|
|
243
|
+
offset_hour = int(match["offset_hour"] or "0")
|
|
244
|
+
offset_minute = int(match["offset_minute"] or "0")
|
|
245
|
+
if (
|
|
246
|
+
hour > 23
|
|
247
|
+
or minute > 59
|
|
248
|
+
or second > 59
|
|
249
|
+
or offset_hour > 23
|
|
250
|
+
or offset_minute > 59
|
|
251
|
+
):
|
|
252
|
+
return Nothing()
|
|
253
|
+
fraction = match["fraction"]
|
|
254
|
+
millisecond = math.floor(float(f"0.{fraction}") * 1000 + 0.5) if fraction else 0
|
|
255
|
+
offset = offset_hour * _MILLIS_PER_HOUR + offset_minute * _MILLIS_PER_MINUTE
|
|
256
|
+
if match["sign"] == "-":
|
|
257
|
+
offset = -offset
|
|
258
|
+
return Just(
|
|
259
|
+
LocalTime(
|
|
260
|
+
(date.toordinal() - _EPOCH_ORDINAL) * _MILLIS_PER_DAY
|
|
261
|
+
+ hour * _MILLIS_PER_HOUR
|
|
262
|
+
+ minute * _MILLIS_PER_MINUTE
|
|
263
|
+
+ second * _MILLIS_PER_SECOND
|
|
264
|
+
+ millisecond
|
|
265
|
+
- offset
|
|
266
|
+
)
|
|
267
|
+
)
|