python-dateutil 4.0.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.
@@ -0,0 +1,426 @@
1
+ """
2
+ This module offers a parser for ISO-8601 strings
3
+
4
+ It is intended to support all valid date, time and datetime formats per the
5
+ ISO-8601 specification.
6
+
7
+ ..versionadded:: 2.7.0
8
+ """
9
+ from datetime import datetime, timedelta, time, date
10
+ import calendar
11
+ from dateutil import tz
12
+
13
+ from functools import wraps
14
+
15
+ import re
16
+
17
+ __all__ = ["isoparse", "isoparser"]
18
+
19
+
20
+ def _takes_ascii(f):
21
+ @wraps(f)
22
+ def func(self, str_in, *args, **kwargs):
23
+ # If it's a stream, read the whole thing
24
+ str_in = getattr(str_in, 'read', lambda: str_in)()
25
+
26
+ # If it's unicode, turn it into bytes, since ISO-8601 only covers ASCII
27
+ if isinstance(str_in, str):
28
+ # ASCII is the same in UTF-8
29
+ try:
30
+ str_in = str_in.encode('ascii')
31
+ except UnicodeEncodeError as e:
32
+ msg = 'ISO-8601 strings should contain only ASCII characters'
33
+ raise ValueError(msg) from e
34
+
35
+ return f(self, str_in, *args, **kwargs)
36
+
37
+ return func
38
+
39
+
40
+ class isoparser:
41
+ def __init__(self, sep=None):
42
+ """
43
+ :param sep:
44
+ A single character that separates date and time portions. If
45
+ ``None``, the parser will accept any single character.
46
+ For strict ISO-8601 adherence, pass ``'T'``.
47
+ """
48
+ if sep is not None:
49
+ if (len(sep) != 1 or ord(sep) >= 128 or sep in '0123456789'):
50
+ raise ValueError('Separator must be a single, non-numeric ' +
51
+ 'ASCII character')
52
+
53
+ sep = sep.encode('ascii')
54
+
55
+ self._sep = sep
56
+
57
+ @_takes_ascii
58
+ def isoparse(self, dt_str):
59
+ """
60
+ Parse an ISO-8601 datetime string into a :class:`datetime.datetime`.
61
+
62
+ An ISO-8601 datetime string consists of a date portion, followed
63
+ optionally by a time portion - the date and time portions are separated
64
+ by a single character separator, which is ``T`` in the official
65
+ standard. Incomplete date formats (such as ``YYYY-MM``) may *not* be
66
+ combined with a time portion.
67
+
68
+ Supported date formats are:
69
+
70
+ Common:
71
+
72
+ - ``YYYY``
73
+ - ``YYYY-MM``
74
+ - ``YYYY-MM-DD`` or ``YYYYMMDD``
75
+
76
+ Uncommon:
77
+
78
+ - ``YYYY-Www`` or ``YYYYWww`` - ISO week (day defaults to 0)
79
+ - ``YYYY-Www-D`` or ``YYYYWwwD`` - ISO week and day
80
+
81
+ The ISO week and day numbering follows the same logic as
82
+ :func:`datetime.date.isocalendar`.
83
+
84
+ Supported time formats are:
85
+
86
+ - ``hh``
87
+ - ``hh:mm`` or ``hhmm``
88
+ - ``hh:mm:ss`` or ``hhmmss``
89
+ - ``hh:mm:ss.ssssss`` (Up to 6 sub-second digits)
90
+
91
+ Midnight is a special case for `hh`, as the standard supports both
92
+ 00:00 and 24:00 as a representation. The decimal separator can be
93
+ either a dot or a comma.
94
+
95
+
96
+ .. caution::
97
+
98
+ Support for fractional components other than seconds is part of the
99
+ ISO-8601 standard, but is not currently implemented in this parser.
100
+
101
+ Supported time zone offset formats are:
102
+
103
+ - `Z` (UTC)
104
+ - `±HH:MM`
105
+ - `±HHMM`
106
+ - `±HH`
107
+
108
+ Offsets will be represented as :class:`dateutil.tz.tzoffset` objects,
109
+ with the exception of UTC, which will be represented as
110
+ :class:`dateutil.tz.tzutc`. Time zone offsets equivalent to UTC (such
111
+ as `+00:00`) will also be represented as :class:`dateutil.tz.tzutc`.
112
+
113
+ :param dt_str:
114
+ A string or stream containing only an ISO-8601 datetime string
115
+
116
+ :return:
117
+ Returns a :class:`datetime.datetime` representing the string.
118
+ Unspecified components default to their lowest value.
119
+
120
+ .. warning::
121
+
122
+ As of version 2.7.0, the strictness of the parser should not be
123
+ considered a stable part of the contract. Any valid ISO-8601 string
124
+ that parses correctly with the default settings will continue to
125
+ parse correctly in future versions, but invalid strings that
126
+ currently fail (e.g. ``2017-01-01T00:00+00:00:00``) are not
127
+ guaranteed to continue failing in future versions if they encode
128
+ a valid date.
129
+
130
+ .. versionadded:: 2.7.0
131
+ """
132
+ components, pos = self._parse_isodate(dt_str)
133
+
134
+ if len(dt_str) > pos:
135
+ if self._sep is None or dt_str[pos:pos + 1] == self._sep:
136
+ components += self._parse_isotime(dt_str[pos + 1:])
137
+ else:
138
+ raise ValueError('String contains unknown ISO components')
139
+
140
+ if len(components) > 3 and components[3] == 24:
141
+ components[3] = 0
142
+ return datetime(*components) + timedelta(days=1)
143
+
144
+ return datetime(*components)
145
+
146
+ @_takes_ascii
147
+ def parse_isodate(self, datestr):
148
+ """
149
+ Parse the date portion of an ISO string.
150
+
151
+ :param datestr:
152
+ The string portion of an ISO string, without a separator
153
+
154
+ :return:
155
+ Returns a :class:`datetime.date` object
156
+ """
157
+ components, pos = self._parse_isodate(datestr)
158
+ if pos < len(datestr):
159
+ raise ValueError('String contains unknown ISO ' +
160
+ 'components: {!r}'.format(datestr.decode('ascii')))
161
+ return date(*components)
162
+
163
+ @_takes_ascii
164
+ def parse_isotime(self, timestr):
165
+ """
166
+ Parse the time portion of an ISO string.
167
+
168
+ :param timestr:
169
+ The time portion of an ISO string, without a separator
170
+
171
+ :return:
172
+ Returns a :class:`datetime.time` object
173
+ """
174
+ components = self._parse_isotime(timestr)
175
+ if components[0] == 24:
176
+ components[0] = 0
177
+ return time(*components)
178
+
179
+ @_takes_ascii
180
+ def parse_tzstr(self, tzstr, zero_as_utc=True):
181
+ """
182
+ Parse a valid ISO time zone string.
183
+
184
+ See :func:`isoparser.isoparse` for details on supported formats.
185
+
186
+ :param tzstr:
187
+ A string representing an ISO time zone offset
188
+
189
+ :param zero_as_utc:
190
+ Whether to return :class:`dateutil.tz.tzutc` for zero-offset zones
191
+
192
+ :return:
193
+ Returns :class:`dateutil.tz.tzoffset` for offsets and
194
+ :class:`dateutil.tz.tzutc` for ``Z`` and (if ``zero_as_utc`` is
195
+ specified) offsets equivalent to UTC.
196
+ """
197
+ return self._parse_tzstr(tzstr, zero_as_utc=zero_as_utc)
198
+
199
+ # Constants
200
+ _DATE_SEP = b'-'
201
+ _TIME_SEP = b':'
202
+ _FRACTION_REGEX = re.compile(b'[\\.,]([0-9]+)')
203
+
204
+ def _parse_digits(self, s, pos, n):
205
+ """Parse exactly ``n`` ASCII digits starting at ``pos``."""
206
+ digits = s[pos : pos + n]
207
+ if len(digits) != n or not digits.isdigit():
208
+ raise ValueError(
209
+ "Expected {} digits, got {!r}".format(n, digits.decode("ascii"))
210
+ )
211
+ return int(digits)
212
+
213
+ def _parse_isodate(self, dt_str):
214
+ try:
215
+ return self._parse_isodate_common(dt_str)
216
+ except ValueError:
217
+ return self._parse_isodate_uncommon(dt_str)
218
+
219
+ def _parse_isodate_common(self, dt_str):
220
+ len_str = len(dt_str)
221
+ components = [1, 1, 1]
222
+
223
+ if len_str < 4:
224
+ raise ValueError('ISO string too short')
225
+
226
+ # Year
227
+ components[0] = self._parse_digits(dt_str, 0, 4)
228
+ pos = 4
229
+ if pos >= len_str:
230
+ return components, pos
231
+
232
+ has_sep = dt_str[pos:pos + 1] == self._DATE_SEP
233
+ if has_sep:
234
+ pos += 1
235
+
236
+ # Month
237
+ if len_str - pos < 2:
238
+ raise ValueError('Invalid common month')
239
+
240
+ components[1] = self._parse_digits(dt_str, pos, 2)
241
+ pos += 2
242
+
243
+ if pos >= len_str:
244
+ if has_sep:
245
+ return components, pos
246
+ else:
247
+ raise ValueError('Invalid ISO format')
248
+
249
+ if has_sep:
250
+ if dt_str[pos:pos + 1] != self._DATE_SEP:
251
+ raise ValueError('Invalid separator in ISO string')
252
+ pos += 1
253
+
254
+ # Day
255
+ if len_str - pos < 2:
256
+ raise ValueError('Invalid common day')
257
+ components[2] = self._parse_digits(dt_str, pos, 2)
258
+ return components, pos + 2
259
+
260
+ def _parse_isodate_uncommon(self, dt_str):
261
+ if len(dt_str) < 4:
262
+ raise ValueError('ISO string too short')
263
+
264
+ # All ISO formats start with the year
265
+ year = self._parse_digits(dt_str, 0, 4)
266
+
267
+ has_sep = dt_str[4:5] == self._DATE_SEP
268
+
269
+ pos = 4 + has_sep # Skip '-' if it's there
270
+ if dt_str[pos:pos + 1] == b'W':
271
+ # YYYY-?Www-?D?
272
+ pos += 1
273
+ weekno = self._parse_digits(dt_str, pos, 2)
274
+ pos += 2
275
+
276
+ dayno = 1
277
+ if len(dt_str) > pos:
278
+ if (dt_str[pos:pos + 1] == self._DATE_SEP) != has_sep:
279
+ raise ValueError('Inconsistent use of dash separator')
280
+
281
+ pos += has_sep
282
+
283
+ dayno = self._parse_digits(dt_str, pos, 1)
284
+ pos += 1
285
+
286
+ base_date = self._calculate_weekdate(year, weekno, dayno)
287
+ else:
288
+ # YYYYDDD or YYYY-DDD
289
+ if len(dt_str) - pos < 3:
290
+ raise ValueError('Invalid ordinal day')
291
+
292
+ ordinal_day = self._parse_digits(dt_str, pos, 3)
293
+ pos += 3
294
+
295
+ if ordinal_day < 1 or ordinal_day > (365 + calendar.isleap(year)):
296
+ raise ValueError('Invalid ordinal day' +
297
+ ' {} for year {}'.format(ordinal_day, year))
298
+
299
+ base_date = date(year, 1, 1) + timedelta(days=ordinal_day - 1)
300
+
301
+ components = [base_date.year, base_date.month, base_date.day]
302
+ return components, pos
303
+
304
+ def _calculate_weekdate(self, year, week, day):
305
+ """
306
+ Calculate the day of corresponding to the ISO year-week-day calendar.
307
+
308
+ This function is effectively the inverse of
309
+ :func:`datetime.date.isocalendar`.
310
+
311
+ :param year:
312
+ The year in the ISO calendar
313
+
314
+ :param week:
315
+ The week in the ISO calendar - range is [1, 53]
316
+
317
+ :param day:
318
+ The day in the ISO calendar - range is [1 (MON), 7 (SUN)]
319
+
320
+ :return:
321
+ Returns a :class:`datetime.date`
322
+ """
323
+ if not 0 < week < 54:
324
+ raise ValueError('Invalid week: {}'.format(week))
325
+
326
+ if not 0 < day < 8: # Range is 1-7
327
+ raise ValueError('Invalid weekday: {}'.format(day))
328
+
329
+ # Get week 1 for the specific year:
330
+ jan_4 = date(year, 1, 4) # Week 1 always has January 4th in it
331
+ week_1 = jan_4 - timedelta(days=jan_4.isocalendar()[2] - 1)
332
+
333
+ # Now add the specific number of weeks and days to get what we want
334
+ week_offset = (week - 1) * 7 + (day - 1)
335
+ return week_1 + timedelta(days=week_offset)
336
+
337
+ def _parse_isotime(self, timestr):
338
+ len_str = len(timestr)
339
+ components = [0, 0, 0, 0, None]
340
+ pos = 0
341
+ comp = -1
342
+
343
+ if len_str < 2:
344
+ raise ValueError('ISO time too short')
345
+
346
+ has_sep = False
347
+
348
+ while pos < len_str and comp < 5:
349
+ comp += 1
350
+
351
+ if timestr[pos:pos + 1] in b'-+Zz':
352
+ # Detect time zone boundary
353
+ components[-1] = self._parse_tzstr(timestr[pos:])
354
+ pos = len_str
355
+ break
356
+
357
+ if comp == 1 and timestr[pos:pos+1] == self._TIME_SEP:
358
+ has_sep = True
359
+ pos += 1
360
+ elif comp == 2 and has_sep:
361
+ if timestr[pos:pos+1] != self._TIME_SEP:
362
+ raise ValueError('Inconsistent use of colon separator')
363
+ pos += 1
364
+
365
+ if comp < 3:
366
+ # Hour, minute, second
367
+ components[comp] = self._parse_digits(timestr, pos, 2)
368
+ pos += 2
369
+
370
+ if comp == 3:
371
+ # Fraction of a second
372
+ frac = self._FRACTION_REGEX.match(timestr[pos:])
373
+ if not frac:
374
+ continue
375
+
376
+ us_str = frac.group(1)[:6] # Truncate to microseconds
377
+ components[comp] = int(us_str) * 10**(6 - len(us_str))
378
+ pos += len(frac.group())
379
+
380
+ if pos < len_str:
381
+ raise ValueError('Unused components in ISO string')
382
+
383
+ if components[0] == 24:
384
+ # Standard supports 00:00 and 24:00 as representations of midnight
385
+ if any(component != 0 for component in components[1:4]):
386
+ raise ValueError('Hour may only be 24 at 24:00:00.000')
387
+
388
+ return components
389
+
390
+ def _parse_tzstr(self, tzstr, zero_as_utc=True):
391
+ if tzstr == b'Z' or tzstr == b'z':
392
+ return tz.UTC
393
+
394
+ if len(tzstr) not in {3, 5, 6}:
395
+ raise ValueError('Time zone offset must be 1, 3, 5 or 6 characters')
396
+
397
+ if tzstr[0:1] == b'-':
398
+ mult = -1
399
+ elif tzstr[0:1] == b'+':
400
+ mult = 1
401
+ else:
402
+ raise ValueError('Time zone offset requires sign')
403
+
404
+ hours = self._parse_digits(tzstr, 1, 2)
405
+ if len(tzstr) == 3:
406
+ minutes = 0
407
+ else:
408
+ pos = 4 if tzstr[3:4] == self._TIME_SEP else 3
409
+ if len(tzstr) != pos + 2:
410
+ raise ValueError("Invalid minutes in time zone offset")
411
+ minutes = self._parse_digits(tzstr, pos, 2)
412
+
413
+ if zero_as_utc and hours == 0 and minutes == 0:
414
+ return tz.UTC
415
+ else:
416
+ if minutes > 59:
417
+ raise ValueError('Invalid minutes in time zone offset')
418
+
419
+ if hours > 23:
420
+ raise ValueError('Invalid hours in time zone offset')
421
+
422
+ return tz.tzoffset(None, mult * (hours * 60 + minutes) * 60)
423
+
424
+
425
+ DEFAULT_ISOPARSER = isoparser()
426
+ isoparse = DEFAULT_ISOPARSER.isoparse