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.
Files changed (99) hide show
  1. morphir/__init__.py +14 -0
  2. morphir/ir/__init__.py +110 -0
  3. morphir/ir/access_controlled.py +15 -0
  4. morphir/ir/decorations.py +19 -0
  5. morphir/ir/diagnostics.py +105 -0
  6. morphir/ir/distribution.py +21 -0
  7. morphir/ir/document.py +37 -0
  8. morphir/ir/documented.py +11 -0
  9. morphir/ir/format_version.py +483 -0
  10. morphir/ir/fqname.py +9 -0
  11. morphir/ir/json.py +11 -0
  12. morphir/ir/literal.py +25 -0
  13. morphir/ir/meta.py +11 -0
  14. morphir/ir/migrate.py +1267 -0
  15. morphir/ir/module.py +17 -0
  16. morphir/ir/name.py +27 -0
  17. morphir/ir/package.py +15 -0
  18. morphir/ir/path.py +25 -0
  19. morphir/ir/qname.py +9 -0
  20. morphir/ir/ref.py +17 -0
  21. morphir/ir/type.py +37 -0
  22. morphir/ir/type_constraints.py +33 -0
  23. morphir/ir/type_def.py +19 -0
  24. morphir/ir/type_spec.py +25 -0
  25. morphir/ir/v3/__init__.py +64 -0
  26. morphir/ir/v3/_legacy_value.py +201 -0
  27. morphir/ir/v3/access_controlled.py +31 -0
  28. morphir/ir/v3/codec.py +1275 -0
  29. morphir/ir/v3/codec_v1.py +491 -0
  30. morphir/ir/v3/codec_v2.py +187 -0
  31. morphir/ir/v3/distribution.py +42 -0
  32. morphir/ir/v3/documented.py +13 -0
  33. morphir/ir/v3/fqname.py +30 -0
  34. morphir/ir/v3/literal.py +46 -0
  35. morphir/ir/v3/module.py +32 -0
  36. morphir/ir/v3/name.py +42 -0
  37. morphir/ir/v3/package.py +23 -0
  38. morphir/ir/v3/path.py +29 -0
  39. morphir/ir/v3/qname.py +16 -0
  40. morphir/ir/v3/type.py +126 -0
  41. morphir/ir/v3/value.py +235 -0
  42. morphir/ir/v4/__init__.py +160 -0
  43. morphir/ir/v4/access_controlled.py +17 -0
  44. morphir/ir/v4/annotation.py +75 -0
  45. morphir/ir/v4/codec.py +2161 -0
  46. morphir/ir/v4/decorations.py +88 -0
  47. morphir/ir/v4/distribution.py +56 -0
  48. morphir/ir/v4/document.py +69 -0
  49. morphir/ir/v4/documented.py +10 -0
  50. morphir/ir/v4/fqname.py +69 -0
  51. morphir/ir/v4/incompleteness.py +78 -0
  52. morphir/ir/v4/json.py +103 -0
  53. morphir/ir/v4/literal.py +51 -0
  54. morphir/ir/v4/meta.py +27 -0
  55. morphir/ir/v4/module.py +30 -0
  56. morphir/ir/v4/name.py +69 -0
  57. morphir/ir/v4/package.py +19 -0
  58. morphir/ir/v4/path.py +50 -0
  59. morphir/ir/v4/qname.py +34 -0
  60. morphir/ir/v4/ref.py +35 -0
  61. morphir/ir/v4/type.py +140 -0
  62. morphir/ir/v4/type_constraints.py +91 -0
  63. morphir/ir/v4/type_def.py +38 -0
  64. morphir/ir/v4/type_spec.py +64 -0
  65. morphir/ir/v4/value.py +480 -0
  66. morphir/ir/value.py +81 -0
  67. morphir/py.typed +0 -0
  68. morphir/sdk/__init__.py +110 -0
  69. morphir/sdk/_compare.py +187 -0
  70. morphir/sdk/aggregate.py +421 -0
  71. morphir/sdk/basics.py +504 -0
  72. morphir/sdk/char.py +110 -0
  73. morphir/sdk/decimal.py +311 -0
  74. morphir/sdk/dict.py +336 -0
  75. morphir/sdk/instant.py +118 -0
  76. morphir/sdk/int.py +86 -0
  77. morphir/sdk/key.py +522 -0
  78. morphir/sdk/list.py +356 -0
  79. morphir/sdk/local_date.py +436 -0
  80. morphir/sdk/local_time.py +267 -0
  81. morphir/sdk/maybe.py +135 -0
  82. morphir/sdk/number.py +237 -0
  83. morphir/sdk/regex.py +287 -0
  84. morphir/sdk/result.py +162 -0
  85. morphir/sdk/result_list.py +169 -0
  86. morphir/sdk/rule.py +103 -0
  87. morphir/sdk/set.py +214 -0
  88. morphir/sdk/stateful_app.py +49 -0
  89. morphir/sdk/string.py +427 -0
  90. morphir/sdk/tuple.py +43 -0
  91. morphir/sdk/uuid.py +209 -0
  92. morphir_python-0.0.1.dist-info/METADATA +269 -0
  93. morphir_python-0.0.1.dist-info/RECORD +99 -0
  94. morphir_python-0.0.1.dist-info/WHEEL +4 -0
  95. morphir_python-0.0.1.dist-info/entry_points.txt +2 -0
  96. morphir_python-0.0.1.dist-info/licenses/LICENSE +201 -0
  97. morphir_tools/__init__.py +13 -0
  98. morphir_tools/cli.py +223 -0
  99. 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
+ )