isoperiod 1.2.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.
- isoperiod/__init__.py +17 -0
- isoperiod/enums.py +13 -0
- isoperiod/exceptions.py +25 -0
- isoperiod/iso.py +233 -0
- isoperiod/parsing.py +256 -0
- isoperiod/periods.py +1170 -0
- isoperiod/properties.py +805 -0
- isoperiod/py.typed +0 -0
- isoperiod/timeline.py +124 -0
- isoperiod/words.py +181 -0
- isoperiod-1.2.1.dist-info/METADATA +171 -0
- isoperiod-1.2.1.dist-info/RECORD +14 -0
- isoperiod-1.2.1.dist-info/WHEEL +4 -0
- isoperiod-1.2.1.dist-info/licenses/LICENSE +21 -0
isoperiod/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
__version__ = version("isoperiod")
|
|
5
|
+
except PackageNotFoundError:
|
|
6
|
+
__version__ = "unknown"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
from isoperiod.exceptions import PeriodError, PeriodParsingError, PeriodValidationError
|
|
10
|
+
from isoperiod.periods import Period
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Period",
|
|
14
|
+
"PeriodError",
|
|
15
|
+
"PeriodParsingError",
|
|
16
|
+
"PeriodValidationError",
|
|
17
|
+
]
|
isoperiod/enums.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Step(IntEnum):
|
|
5
|
+
"""The fundamental units of a Period.
|
|
6
|
+
|
|
7
|
+
A Period's duration is always some multiplier of one of these steps, e.g. a 15-minute period is 900 seconds,
|
|
8
|
+
a 1-year period is 12 months.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
MICROSECONDS = 1
|
|
12
|
+
SECONDS = 2
|
|
13
|
+
MONTHS = 3
|
isoperiod/exceptions.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from isoperiod.enums import Step
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PeriodError(Exception):
|
|
5
|
+
"""Base exception for all period-related errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PeriodParsingError(PeriodError):
|
|
9
|
+
"""Raised when things like a period string or timedelta cannot be parsed."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PeriodValidationError(PeriodError):
|
|
13
|
+
"""Raised when a validation on objects in the Period module fails."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def illegal_step(step: int) -> PeriodValidationError:
|
|
17
|
+
"""Build the error raised when a Step value is not one of the three legal steps.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
step: The offending step value
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
A PeriodValidationError, for the caller to raise
|
|
24
|
+
"""
|
|
25
|
+
return PeriodValidationError(f"Illegal step: '{step}'. Must be one of: {list(Step)}")
|
isoperiod/iso.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Helpers for building ISO 8601 duration strings and the regex fragment used to parse ISO 8601 durations."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
|
|
5
|
+
from isoperiod.exceptions import PeriodParsingError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def period_regex(prefix: str) -> str:
|
|
9
|
+
"""Return a regular expression string for matching an ISO 8601 duration (but without the initial "P" character)
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
prefix: The name prefix used for Python regex group names
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
A string containing a regular expression that can be used to parse an ISO 8601 duration
|
|
16
|
+
"""
|
|
17
|
+
return (
|
|
18
|
+
rf"(?:(?P<{prefix}_years>\d+)[Yy])?"
|
|
19
|
+
rf"(?:(?P<{prefix}_months>\d+)[Mm])?"
|
|
20
|
+
rf"(?:(?P<{prefix}_days>\d+)[Dd])?"
|
|
21
|
+
r"(?:[Tt]"
|
|
22
|
+
rf"(?:(?P<{prefix}_hours>\d+)[Hh])?"
|
|
23
|
+
rf"(?:(?P<{prefix}_minutes>\d+)[Mm])?"
|
|
24
|
+
rf"(?:(?P<{prefix}_seconds>\d+)"
|
|
25
|
+
rf"(?:\.(?P<{prefix}_microseconds>\d{{1,6}}))?"
|
|
26
|
+
r"[Ss])?"
|
|
27
|
+
r")?"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def second_string(seconds: int, microseconds: int) -> str:
|
|
32
|
+
"""Convert seconds and microseconds (0-999_999) to a string that can be used to format an ISO 8601 duration string.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
seconds: The number of seconds
|
|
36
|
+
microseconds: The number of microseconds
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A string representing seconds and microseconds that can be used in an ISO 8601 duration string
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
>>> second_string(30, 0)
|
|
43
|
+
'30'
|
|
44
|
+
>>> second_string(1, 500_000)
|
|
45
|
+
'1.5'
|
|
46
|
+
>>> second_string(10, 123_400)
|
|
47
|
+
'10.1234'
|
|
48
|
+
"""
|
|
49
|
+
if microseconds == 0:
|
|
50
|
+
return str(seconds)
|
|
51
|
+
return f"{seconds}.{microseconds:06}".rstrip("0")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def append_second_elems(elems: list[str], seconds: int, microseconds: int) -> list[str]:
|
|
55
|
+
"""Append total seconds and microseconds to a list of strings that, when joined, produce an ISO 8601 duration
|
|
56
|
+
string.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
elems: The list of strings
|
|
60
|
+
seconds: The number of seconds
|
|
61
|
+
microseconds: The number of microseconds
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The amended list of strings
|
|
65
|
+
|
|
66
|
+
Examples (joining the result of appending onto ``["P"]``):
|
|
67
|
+
>>> "".join(append_second_elems(["P"], 90, 0))
|
|
68
|
+
'PT1M30S'
|
|
69
|
+
>>> "".join(append_second_elems(["P"], 90_061, 0))
|
|
70
|
+
'P1DT1H1M1S'
|
|
71
|
+
>>> "".join(append_second_elems(["P"], 0, 500_000))
|
|
72
|
+
'PT0.5S'
|
|
73
|
+
"""
|
|
74
|
+
days, seconds_in_day = divmod(seconds, 86_400)
|
|
75
|
+
if days > 0:
|
|
76
|
+
elems.append(f"{days}D")
|
|
77
|
+
if (seconds_in_day > 0) or (microseconds > 0):
|
|
78
|
+
elems.append("T")
|
|
79
|
+
hours, seconds_in_hour = divmod(seconds_in_day, 3_600)
|
|
80
|
+
if hours > 0:
|
|
81
|
+
elems.append(f"{hours}H")
|
|
82
|
+
minutes, seconds_in_minute = divmod(seconds_in_hour, 60)
|
|
83
|
+
if minutes > 0:
|
|
84
|
+
elems.append(f"{minutes}M")
|
|
85
|
+
if (seconds_in_minute > 0) or (microseconds > 0):
|
|
86
|
+
elems.append(second_string(seconds_in_minute, microseconds))
|
|
87
|
+
elems.append("S")
|
|
88
|
+
return elems
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def append_month_elems(elems: list[str], months: int) -> list[str]:
|
|
92
|
+
"""Append total number of months to a list of strings that, when joined, produce an ISO 8601 duration string.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
elems: The list of strings
|
|
96
|
+
months: The total number of months
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
The amended list of strings
|
|
100
|
+
|
|
101
|
+
Examples (joining the result of appending onto ``["P"]``):
|
|
102
|
+
>>> "".join(append_month_elems(["P"], 6))
|
|
103
|
+
'P6M'
|
|
104
|
+
>>> "".join(append_month_elems(["P"], 13))
|
|
105
|
+
'P1Y1M'
|
|
106
|
+
"""
|
|
107
|
+
years, months_in_year = divmod(months, 12)
|
|
108
|
+
if years > 0:
|
|
109
|
+
elems.append(f"{years}Y")
|
|
110
|
+
if months_in_year > 0:
|
|
111
|
+
elems.append(f"{months_in_year}M")
|
|
112
|
+
return elems
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def microsecond_period_name(total_microseconds: int) -> str:
|
|
116
|
+
"""Return an ISO 8601 duration string from a total number of microseconds in a period.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
total_microseconds: The total number of microseconds
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The ISO 8601 duration string representing a period
|
|
123
|
+
of n-microseconds
|
|
124
|
+
|
|
125
|
+
Examples:
|
|
126
|
+
>>> microsecond_period_name(1)
|
|
127
|
+
'PT0.000001S'
|
|
128
|
+
>>> microsecond_period_name(1_500_000)
|
|
129
|
+
'PT1.5S'
|
|
130
|
+
"""
|
|
131
|
+
seconds, microseconds = divmod(total_microseconds, 1_000_000)
|
|
132
|
+
return "".join(append_second_elems(["P"], seconds, microseconds))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def second_period_name(seconds: int) -> str:
|
|
136
|
+
"""Return an ISO 8601 duration string from a total number of seconds in a period.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
seconds: The total number of seconds
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
The ISO 8601 duration string representing a period
|
|
143
|
+
of n-seconds
|
|
144
|
+
|
|
145
|
+
Examples:
|
|
146
|
+
>>> second_period_name(3_600)
|
|
147
|
+
'PT1H'
|
|
148
|
+
>>> second_period_name(86_400)
|
|
149
|
+
'P1D'
|
|
150
|
+
"""
|
|
151
|
+
return "".join(append_second_elems(["P"], seconds, 0))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def month_period_name(months: int) -> str:
|
|
155
|
+
"""Return an ISO 8601 duration string from a total number of months in a period.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
months: The total number of months
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The ISO 8601 duration string representing a period
|
|
162
|
+
of n-months
|
|
163
|
+
|
|
164
|
+
Examples:
|
|
165
|
+
>>> month_period_name(3)
|
|
166
|
+
'P3M'
|
|
167
|
+
>>> month_period_name(18)
|
|
168
|
+
'P1Y6M'
|
|
169
|
+
"""
|
|
170
|
+
return "".join(append_month_elems(["P"], months))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# A modern, unambiguous instant at which to sample a fixed UTC offset. Sampling at datetime.min would pick up
|
|
174
|
+
# the Local Mean Time a zone used before standard time was adopted.
|
|
175
|
+
_TZ_REFERENCE = dt.datetime(2000, 1, 1)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def tz_label(tzinfo: dt.tzinfo) -> str:
|
|
179
|
+
"""Return a short, stable name for a tzinfo object, for use in a repr.
|
|
180
|
+
|
|
181
|
+
A named zone (:class:`zoneinfo.ZoneInfo`) is rendered by its key, a fixed-offset zone in ISO 8601 form, and
|
|
182
|
+
anything else by its class name. Never raises, and never returns an empty string, so that a period carrying a
|
|
183
|
+
timezone can always be told apart from a naive one.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
tzinfo: The timezone to name
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
A non-empty label for the timezone
|
|
190
|
+
|
|
191
|
+
Examples:
|
|
192
|
+
>>> from zoneinfo import ZoneInfo
|
|
193
|
+
>>> tz_label(ZoneInfo("Europe/London"))
|
|
194
|
+
'Europe/London'
|
|
195
|
+
>>> tz_label(dt.timezone.utc)
|
|
196
|
+
'Z'
|
|
197
|
+
>>> tz_label(dt.timezone(dt.timedelta(hours=5, minutes=30)))
|
|
198
|
+
'+05:30'
|
|
199
|
+
"""
|
|
200
|
+
key = getattr(tzinfo, "key", None)
|
|
201
|
+
if isinstance(key, str) and key:
|
|
202
|
+
return key
|
|
203
|
+
try:
|
|
204
|
+
delta = tzinfo.utcoffset(_TZ_REFERENCE)
|
|
205
|
+
if delta is not None:
|
|
206
|
+
return format_tzdelta(delta)
|
|
207
|
+
except Exception: # noqa: BLE001 - a repr helper must not raise, whatever a custom tzinfo does
|
|
208
|
+
pass
|
|
209
|
+
return type(tzinfo).__name__
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def format_tzdelta(delta: dt.timedelta) -> str:
|
|
213
|
+
"""Convert a timedelta which represents a timezone to a string that represents that timezone
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
delta: The timedelta
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
A string that can be used to represent a timezone in an ISO 8601 format string
|
|
220
|
+
|
|
221
|
+
Examples:
|
|
222
|
+
>>> format_tzdelta(dt.timedelta(0))
|
|
223
|
+
'Z'
|
|
224
|
+
>>> format_tzdelta(dt.timedelta(hours=5, minutes=30))
|
|
225
|
+
'+05:30'
|
|
226
|
+
>>> format_tzdelta(dt.timedelta(hours=-5))
|
|
227
|
+
'-05:00'
|
|
228
|
+
"""
|
|
229
|
+
try:
|
|
230
|
+
name = dt.timezone(delta).tzname(None)
|
|
231
|
+
except ValueError as err:
|
|
232
|
+
raise PeriodParsingError(f"Illegal tz delta: {delta}. Amount of time must be less than 1 day.") from err
|
|
233
|
+
return name.removeprefix("UTC") or "Z"
|
isoperiod/parsing.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from isoperiod.enums import Step
|
|
6
|
+
from isoperiod.exceptions import PeriodValidationError
|
|
7
|
+
from isoperiod.iso import period_regex
|
|
8
|
+
from isoperiod.properties import Properties
|
|
9
|
+
|
|
10
|
+
# Regular expressions used to detect a truncated ("reduced precision") ISO 8601 date - i.e. a bare year or year-month
|
|
11
|
+
_RE_TRUNCATED_YEAR = re.compile(r"\d{4}")
|
|
12
|
+
_RE_TRUNCATED_YEAR_MONTH = re.compile(r"\d{4}-\d{2}")
|
|
13
|
+
|
|
14
|
+
# A regular expression Pattern used to parse an ISO 8601 duration, such as P1Y, P7M, P5Y3M, P8D9H1M4S, etc.
|
|
15
|
+
_RE_PERIOD = re.compile(r"^" r"[Pp]" + period_regex("period") + r"$")
|
|
16
|
+
|
|
17
|
+
# A regular expression Pattern used to parse our custom extended ISO 8601 duration string, such as P2Y+3M,
|
|
18
|
+
# P1Y+9MT9H.
|
|
19
|
+
# This accounts for offsets on the period.
|
|
20
|
+
_RE_PERIOD_OFFSET = re.compile(r"^" r"[Pp]" + period_regex("period") + r"\+" + period_regex("offset") + r"$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def str_to_int(num: str | None, default: int = 0) -> int:
|
|
24
|
+
"""Convert string to int, None becomes 0 (or default if specified)
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
num: The string to be converted, or None
|
|
28
|
+
default: Default int value to use if num is None (defaults to 0)
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The int value read from the input string
|
|
32
|
+
"""
|
|
33
|
+
return default if num is None else int(num)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def str_to_microseconds(num: str | None, default: int = 0) -> int:
|
|
37
|
+
"""Convert string to microseconds, None becomes 0 (or default if specified)
|
|
38
|
+
|
|
39
|
+
A seconds + microseconds string looks like "nn.uuu". This function converts the "uuu" part (from 1 to 6 digits)
|
|
40
|
+
to an int value between 0 and 999_999.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
num: The string to be converted, or None
|
|
44
|
+
default: Default int value to use if num is None (defaults to 0)
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The int value read from the input string
|
|
48
|
+
"""
|
|
49
|
+
return default if num is None else int((num + "00000")[:6])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class MonthsSeconds:
|
|
54
|
+
"""A basic specification for either a period or a period offset where the period/offset amount is a number of
|
|
55
|
+
months and/or seconds.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
string: str
|
|
59
|
+
months: int
|
|
60
|
+
seconds: int
|
|
61
|
+
microseconds: int
|
|
62
|
+
|
|
63
|
+
def __post_init__(self) -> None:
|
|
64
|
+
if (self.months < 0) or (self.seconds < 0) or (self.microseconds < 0) or (self.microseconds > 999_999):
|
|
65
|
+
raise PeriodValidationError(f"Illegal period: {self.string}")
|
|
66
|
+
if (self.months == 0) and (self.seconds == 0) and (self.microseconds == 0):
|
|
67
|
+
raise PeriodValidationError(f"Illegal period: {self.string}")
|
|
68
|
+
|
|
69
|
+
def get_step_and_multiplier(self) -> tuple[int, int]:
|
|
70
|
+
"""Return a tuple of two integers containing the
|
|
71
|
+
calculated step and multiplier
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
A tuple of (step,multiplier)
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError if a valid step and multiplier
|
|
78
|
+
cannot be created. This happens, for example,
|
|
79
|
+
if boths months and seconds are >0.
|
|
80
|
+
"""
|
|
81
|
+
if (self.months > 0) and (self.seconds == 0) and (self.microseconds == 0):
|
|
82
|
+
return Step.MONTHS, self.months
|
|
83
|
+
if (self.months == 0) and (self.seconds > 0) and (self.microseconds == 0):
|
|
84
|
+
return Step.SECONDS, self.seconds
|
|
85
|
+
if (self.months == 0) and (self.microseconds > 0):
|
|
86
|
+
return Step.MICROSECONDS, self.total_microseconds()
|
|
87
|
+
raise PeriodValidationError(f"Illegal period: {self.string}")
|
|
88
|
+
|
|
89
|
+
def total_microseconds(self) -> int:
|
|
90
|
+
"""Return total microseconds from the seconds
|
|
91
|
+
and microseconds fields
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The total number of microseconds
|
|
95
|
+
"""
|
|
96
|
+
return self.seconds * 1_000_000 + self.microseconds
|
|
97
|
+
|
|
98
|
+
def get_base_properties(self) -> Properties:
|
|
99
|
+
"""Return a basic Properties object from the months
|
|
100
|
+
and (seconds,microseconds) fields
|
|
101
|
+
|
|
102
|
+
Raise an error if it is not possible to create
|
|
103
|
+
a valid Properties object. This happens if,
|
|
104
|
+
for example, both months and seconds are >0.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
A Properties object
|
|
108
|
+
"""
|
|
109
|
+
step, multiplier = self.get_step_and_multiplier()
|
|
110
|
+
return Properties(
|
|
111
|
+
step=step, multiplier=multiplier, month_offset=0, microsecond_offset=0, tzinfo=None, ordinal_shift=0
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class PeriodFields:
|
|
117
|
+
"""A set of fields that can be read from an ISO 8601 format duration string."""
|
|
118
|
+
|
|
119
|
+
string: str
|
|
120
|
+
years: int
|
|
121
|
+
months: int
|
|
122
|
+
days: int
|
|
123
|
+
hours: int
|
|
124
|
+
minutes: int
|
|
125
|
+
seconds: int
|
|
126
|
+
microseconds: int
|
|
127
|
+
|
|
128
|
+
def __post_init__(self) -> None:
|
|
129
|
+
if (
|
|
130
|
+
(self.years < 0)
|
|
131
|
+
or (self.months < 0)
|
|
132
|
+
or (self.days < 0)
|
|
133
|
+
or (self.hours < 0)
|
|
134
|
+
or (self.minutes < 0)
|
|
135
|
+
or (self.seconds < 0)
|
|
136
|
+
or (self.microseconds < 0)
|
|
137
|
+
or (self.microseconds > 999_999)
|
|
138
|
+
):
|
|
139
|
+
raise PeriodValidationError(f"Illegal period: {self.string}")
|
|
140
|
+
|
|
141
|
+
def get_months_seconds(self) -> MonthsSeconds:
|
|
142
|
+
"""Return a MonthsSeconds object from the fields of an ISO 8601 duration
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
A MonthsSeconds object
|
|
146
|
+
"""
|
|
147
|
+
months = self.years * 12 + self.months
|
|
148
|
+
seconds = (self.days * 86_400) + (self.hours * 3_600) + (self.minutes * 60) + self.seconds
|
|
149
|
+
return MonthsSeconds(string=self.string, months=months, seconds=seconds, microseconds=self.microseconds)
|
|
150
|
+
|
|
151
|
+
def get_base_properties(self) -> Properties:
|
|
152
|
+
"""Return a basic Properties object from the fields of an ISO 8601 duration
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
A Properties object
|
|
156
|
+
"""
|
|
157
|
+
return self.get_months_seconds().get_base_properties()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _period_match(prefix: str, matcher: re.Match[str]) -> PeriodFields:
|
|
161
|
+
"""Return a PeriodFields object from a regex Matcher object
|
|
162
|
+
|
|
163
|
+
The regex Pattern that create the matcher is assumed to have been created from a string returned by the
|
|
164
|
+
period_regex function.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
prefix: The name prefix used when creating the regex
|
|
168
|
+
matcher: The regex Matcher object
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
The PeriodFields object
|
|
172
|
+
"""
|
|
173
|
+
return PeriodFields(
|
|
174
|
+
string=matcher.string,
|
|
175
|
+
years=str_to_int(matcher.group(f"{prefix}_years")),
|
|
176
|
+
months=str_to_int(matcher.group(f"{prefix}_months")),
|
|
177
|
+
days=str_to_int(matcher.group(f"{prefix}_days")),
|
|
178
|
+
hours=str_to_int(matcher.group(f"{prefix}_hours")),
|
|
179
|
+
minutes=str_to_int(matcher.group(f"{prefix}_minutes")),
|
|
180
|
+
seconds=str_to_int(matcher.group(f"{prefix}_seconds")),
|
|
181
|
+
microseconds=str_to_microseconds(matcher.group(f"{prefix}_microseconds")),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _pad_truncated_date(text: str) -> str:
|
|
186
|
+
"""Pad a truncated ISO 8601 date ("yyyy" or "yyyy-mm") out to a full "yyyy-mm-dd", defaulting the missing month
|
|
187
|
+
and/or day to "01" - matching ISO 8601's convention that a reduced-precision date/time represents its first instant.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
text: The (possibly truncated) date/datetime string
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
`text` unchanged if it wasn't truncated, otherwise the padded string
|
|
194
|
+
"""
|
|
195
|
+
if _RE_TRUNCATED_YEAR.fullmatch(text):
|
|
196
|
+
return f"{text}-01-01"
|
|
197
|
+
if _RE_TRUNCATED_YEAR_MONTH.fullmatch(text):
|
|
198
|
+
return f"{text}-01"
|
|
199
|
+
return text
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _parse_start_datetime(text: str) -> dt.datetime | None:
|
|
203
|
+
"""Parse the "<start>" half of a "<start>/<duration>" string into a datetime, or return None if it isn't a valid
|
|
204
|
+
(possibly truncated) ISO 8601 date/datetime.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
text: The "<start>" string
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
A datetime object, or None
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
return dt.datetime.fromisoformat(_pad_truncated_date(text))
|
|
214
|
+
except ValueError:
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def parse_iso_duration(iso_8601_duration: str) -> Properties | None:
|
|
219
|
+
"""Parse a plain ISO 8601 duration string (e.g. "P1Y" or "PT15M") into a Properties object, or return None if the
|
|
220
|
+
string doesn't match this format.
|
|
221
|
+
"""
|
|
222
|
+
matcher = _RE_PERIOD.match(iso_8601_duration)
|
|
223
|
+
if matcher is None:
|
|
224
|
+
return None
|
|
225
|
+
return _period_match("period", matcher).get_base_properties()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def parse_period_offset(duration: str) -> Properties | None:
|
|
229
|
+
"""Parse the extended "<duration>+<offset>" form (e.g. "P1D+T9H") into a Properties object, or return None if
|
|
230
|
+
the string doesn't match this format.
|
|
231
|
+
"""
|
|
232
|
+
matcher = _RE_PERIOD_OFFSET.match(duration)
|
|
233
|
+
if matcher is None:
|
|
234
|
+
return None
|
|
235
|
+
period_fields = _period_match("period", matcher)
|
|
236
|
+
offset_fields = _period_match("offset", matcher)
|
|
237
|
+
return period_fields.get_base_properties().with_offset_period_fields(offset_fields)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def parse_date_and_duration(date_duration: str) -> tuple[Properties, dt.datetime] | None:
|
|
241
|
+
"""Parse a "<start>/<duration>" string (e.g. "1883-01-01/P1D") into a (Properties, origin datetime) pair, or
|
|
242
|
+
return None if the string doesn't match this format.
|
|
243
|
+
|
|
244
|
+
The caller is responsible for applying the origin (via Period.with_origin) to the Period built from the Properties.
|
|
245
|
+
"""
|
|
246
|
+
start_text, sep, duration_text = date_duration.partition("/")
|
|
247
|
+
if not sep:
|
|
248
|
+
return None
|
|
249
|
+
origin = _parse_start_datetime(start_text)
|
|
250
|
+
if origin is None:
|
|
251
|
+
return None
|
|
252
|
+
matcher = _RE_PERIOD.match(duration_text)
|
|
253
|
+
if matcher is None:
|
|
254
|
+
return None
|
|
255
|
+
properties = _period_match("period", matcher).get_base_properties()
|
|
256
|
+
return properties, origin
|