datatypes 0.12.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.
- datatypes/__init__.py +153 -0
- datatypes/collections/__init__.py +22 -0
- datatypes/collections/container.py +150 -0
- datatypes/collections/mapping.py +614 -0
- datatypes/collections/sequence.py +469 -0
- datatypes/command.py +572 -0
- datatypes/compat.py +205 -0
- datatypes/config/__init__.py +9 -0
- datatypes/config/environ.py +302 -0
- datatypes/config/parser.py +255 -0
- datatypes/copy.py +151 -0
- datatypes/csv.py +322 -0
- datatypes/datetime.py +888 -0
- datatypes/decorators/__init__.py +23 -0
- datatypes/decorators/base.py +343 -0
- datatypes/decorators/descriptor.py +389 -0
- datatypes/decorators/misc.py +118 -0
- datatypes/email.py +351 -0
- datatypes/enum.py +191 -0
- datatypes/event.py +207 -0
- datatypes/filetypes/__init__.py +12 -0
- datatypes/filetypes/html.py +556 -0
- datatypes/filetypes/toml.py +260 -0
- datatypes/http.py +723 -0
- datatypes/logging.py +384 -0
- datatypes/number.py +187 -0
- datatypes/parse.py +246 -0
- datatypes/path.py +3637 -0
- datatypes/profile.py +232 -0
- datatypes/reflection.py +1498 -0
- datatypes/server.py +408 -0
- datatypes/service.py +212 -0
- datatypes/string.py +1328 -0
- datatypes/token/__init__.py +16 -0
- datatypes/token/abnf.py +1704 -0
- datatypes/token/base.py +475 -0
- datatypes/token/word.py +259 -0
- datatypes/url.py +841 -0
- datatypes/utils.py +142 -0
- datatypes-0.12.0.dist-info/LICENSE.txt +21 -0
- datatypes-0.12.0.dist-info/METADATA +52 -0
- datatypes-0.12.0.dist-info/RECORD +44 -0
- datatypes-0.12.0.dist-info/WHEEL +5 -0
- datatypes-0.12.0.dist-info/top_level.txt +1 -0
datatypes/datetime.py
ADDED
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from __future__ import unicode_literals, division, print_function, absolute_import
|
|
3
|
+
import datetime
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
import math
|
|
9
|
+
import calendar
|
|
10
|
+
|
|
11
|
+
from .compat import *
|
|
12
|
+
from .string import String
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Datetime(datetime.datetime):
|
|
19
|
+
"""Wrapper around standard datetime.datetime class that assures UTC time and
|
|
20
|
+
full ISO8601 date strings with Z timezone.
|
|
21
|
+
|
|
22
|
+
You can create an object multiple ways:
|
|
23
|
+
|
|
24
|
+
d = Datetime() # current utc time
|
|
25
|
+
d = Datetime("2019-10-01T23:26:22.079296Z") # parses ISO8601 date from string
|
|
26
|
+
d = Datetime(2019, 10, 1) # standard creation syntax
|
|
27
|
+
|
|
28
|
+
https://docs.python.org/3/library/datetime.html#datetime-objects
|
|
29
|
+
"""
|
|
30
|
+
FORMAT_ISO8601 = "%Y-%m-%dT%H:%M:%S.%f"
|
|
31
|
+
FORMAT_ISO8601_TZ = "%Y-%m-%dT%H:%M:%S.%f%Z"
|
|
32
|
+
FORMAT_ISO8601_OFFSET = "%Y-%m-%dT%H:%M:%S.%f%z"
|
|
33
|
+
FORMAT_ISO8601_SECONDS = "%Y-%m-%dT%H:%M:%S"
|
|
34
|
+
FORMAT_ISO8601_SECONDS_TZ = "%Y-%m-%dT%H:%M:%S%Z"
|
|
35
|
+
FORMAT_ISO8601_SECONDS_OFFSET = "%Y-%m-%dT%H:%M:%S%z"
|
|
36
|
+
FORMAT_ISO8601_DAY = "%Y-%m-%d"
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def yearname(self):
|
|
40
|
+
"""return the full 4 digit year"""
|
|
41
|
+
return self.strftime("%Y")
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def monthname(self):
|
|
45
|
+
"""return the full month name"""
|
|
46
|
+
return self.strftime("%B")
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def dayname(self):
|
|
50
|
+
"""return the full day name"""
|
|
51
|
+
return self.strftime("%A")
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def formats(cls):
|
|
55
|
+
is_valid = lambda k, v: k.startswith("FORMAT")
|
|
56
|
+
formats = set(v for k, v in inspect.getmembers(cls) if is_valid(k, v))
|
|
57
|
+
return formats
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def parse(cls, d):
|
|
61
|
+
dt = cls.parse_formats(d)
|
|
62
|
+
|
|
63
|
+
if dt is None:
|
|
64
|
+
dt = cls.parse_regex(d)
|
|
65
|
+
|
|
66
|
+
if dt is None:
|
|
67
|
+
raise ValueError("Cannot infer datetime value from {}".format(d))
|
|
68
|
+
|
|
69
|
+
return dt
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def parse_formats(cls, d):
|
|
73
|
+
fs = cls.formats()
|
|
74
|
+
for f in fs:
|
|
75
|
+
try:
|
|
76
|
+
logger.debug("Attempting to parse date: {} with format: {}".format(
|
|
77
|
+
d,
|
|
78
|
+
f,
|
|
79
|
+
))
|
|
80
|
+
|
|
81
|
+
ret = cls.strptime(d, f)
|
|
82
|
+
if ("%z" in f) or ("%Z" in f):
|
|
83
|
+
ret = ret.astimezone(datetime.timezone.utc)
|
|
84
|
+
|
|
85
|
+
else:
|
|
86
|
+
ret = ret.replace(tzinfo=datetime.timezone.utc)
|
|
87
|
+
|
|
88
|
+
logger.debug(f"Date: {d} parsed with format: {f}")
|
|
89
|
+
return ret
|
|
90
|
+
|
|
91
|
+
except ValueError as e:
|
|
92
|
+
logger.debug(e)
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def parse_regex(cls, d):
|
|
97
|
+
dt = None
|
|
98
|
+
if re.match(r"^\-?\d+\.\d+$", d):
|
|
99
|
+
# account for unix timestamps with microseconds
|
|
100
|
+
logger.debug("Date: {} parsed with float unix timestamp regex".format(d))
|
|
101
|
+
dt = cls.fromtimestamp(float(d), tz=datetime.timezone.utc)
|
|
102
|
+
|
|
103
|
+
elif re.match(r"^\-?\d+$", d):
|
|
104
|
+
# account for unix timestamps without microseconds
|
|
105
|
+
logger.debug("Date: {} parsed with integer unix timestamp regex".format(d))
|
|
106
|
+
val = int(d)
|
|
107
|
+
dt = cls.fromtimestamp(val, tz=datetime.timezone.utc)
|
|
108
|
+
|
|
109
|
+
else:
|
|
110
|
+
# in 3.11+ we can maybe replace this with:
|
|
111
|
+
# https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat
|
|
112
|
+
#
|
|
113
|
+
# ISO 8601 is not very strict with the date format and this tries to
|
|
114
|
+
# capture most of that leniency, with the one exception that the
|
|
115
|
+
# date must be in UTC
|
|
116
|
+
# https://en.wikipedia.org/wiki/ISO_8601
|
|
117
|
+
m = re.match(
|
|
118
|
+
r"""
|
|
119
|
+
^ # match from the beginning of the string
|
|
120
|
+
(\d{4}) # 0 - YYYY (year)
|
|
121
|
+
.? # deliminator
|
|
122
|
+
(\d{2}) # 1 - MM (month)
|
|
123
|
+
.? # deliminator
|
|
124
|
+
(\d{2}) # 2 - DD (day)
|
|
125
|
+
. # Date and time separator (usually T)
|
|
126
|
+
(\d{2}) # 3 - HH (hour)
|
|
127
|
+
(?::?(\d{2}))? # 4 - MM (minute)
|
|
128
|
+
(?::?(\d{2}))? # 5 - SS (second)
|
|
129
|
+
(?:\.(\d+))? # 6 - MS (milliseconds)
|
|
130
|
+
""",
|
|
131
|
+
d,
|
|
132
|
+
flags=re.X
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if m:
|
|
136
|
+
logger.debug("Date: {} parsed with ISO regex".format(d))
|
|
137
|
+
|
|
138
|
+
parsed_dateparts = m.groups()
|
|
139
|
+
dateparts = list(map(lambda x: int(x) if x else 0, parsed_dateparts[:6]))
|
|
140
|
+
dt = cls(*dateparts, tzinfo=datetime.timezone.utc)
|
|
141
|
+
|
|
142
|
+
# account for ms with leading zeros
|
|
143
|
+
if parsed_dateparts[6]:
|
|
144
|
+
ms_len = len(parsed_dateparts[6])
|
|
145
|
+
if ms_len >= 3:
|
|
146
|
+
millis = parsed_dateparts[6][:3]
|
|
147
|
+
micros = parsed_dateparts[6][3:] or 0
|
|
148
|
+
|
|
149
|
+
else:
|
|
150
|
+
millis = parsed_dateparts[6] or 0
|
|
151
|
+
micros = 0
|
|
152
|
+
|
|
153
|
+
# make sure each part is 3 digits by zero padding on the right
|
|
154
|
+
if millis:
|
|
155
|
+
millis = "{:0<3.3}".format(millis)
|
|
156
|
+
if micros:
|
|
157
|
+
micros = "{:0<3.3}".format(micros)
|
|
158
|
+
|
|
159
|
+
dt += datetime.timedelta(
|
|
160
|
+
milliseconds=int(millis),
|
|
161
|
+
microseconds=int(micros)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
tzoffset = d[m.regs[m.lastindex][1]:]
|
|
165
|
+
if tzoffset:
|
|
166
|
+
# https://en.wikipedia.org/wiki/ISO_8601#Time_zone_designators
|
|
167
|
+
if tzoffset != "Z":
|
|
168
|
+
moffset = re.match(
|
|
169
|
+
r"""
|
|
170
|
+
([+-]) # 1 - positive or negative offset
|
|
171
|
+
(\d{2}) # 2 - HH (hour)
|
|
172
|
+
(?::?(\d{2}))? # 3 - MM (minute)
|
|
173
|
+
$
|
|
174
|
+
""",
|
|
175
|
+
tzoffset,
|
|
176
|
+
flags=re.X
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if moffset:
|
|
180
|
+
sign = moffset.group(1)
|
|
181
|
+
seconds = 0
|
|
182
|
+
if hours := moffset.group(2):
|
|
183
|
+
seconds += int(hours) * 3600
|
|
184
|
+
|
|
185
|
+
if minutes := moffset.group(3):
|
|
186
|
+
seconds += int(minutes) * 60
|
|
187
|
+
|
|
188
|
+
if seconds:
|
|
189
|
+
if sign == "+":
|
|
190
|
+
dt -= datetime.timedelta(seconds=seconds)
|
|
191
|
+
else:
|
|
192
|
+
dt += datetime.timedelta(seconds=seconds)
|
|
193
|
+
|
|
194
|
+
return dt
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def parse_args(self, args, kwargs):
|
|
198
|
+
"""Parse out custom arguments so you can pass *args, **kwargs to the
|
|
199
|
+
standard datetime class initializer
|
|
200
|
+
|
|
201
|
+
:param args: tuple, the *args passed to a function
|
|
202
|
+
:param kwargs: dict, the **kwargs passed to a function
|
|
203
|
+
:returns: tuple, (args, kwargs, custom)
|
|
204
|
+
"""
|
|
205
|
+
timedelta_kwargs = {}
|
|
206
|
+
replace_kwargs = {}
|
|
207
|
+
|
|
208
|
+
timedelta_names = {
|
|
209
|
+
"years": ["years", "yrs"],
|
|
210
|
+
"months": ["months"],
|
|
211
|
+
"weeks": ["weeks", "week"],
|
|
212
|
+
"days": ["days"],
|
|
213
|
+
"hours": ["hours"],
|
|
214
|
+
"minutes": ["minutes", "mins"],
|
|
215
|
+
"seconds": ["seconds", "secs"],
|
|
216
|
+
"microseconds": ["microseconds", "usecs"],
|
|
217
|
+
"milliseconds": ["milliseconds", "msecs"],
|
|
218
|
+
}
|
|
219
|
+
for nk, ks in timedelta_names.items():
|
|
220
|
+
for k in ks:
|
|
221
|
+
if k in kwargs:
|
|
222
|
+
timedelta_kwargs.setdefault(nk, 0)
|
|
223
|
+
timedelta_kwargs[nk] += kwargs.pop(k)
|
|
224
|
+
|
|
225
|
+
if timedelta := kwargs.pop("timedelta", None):
|
|
226
|
+
timedelta_kwargs["timedelta"] = timedelta
|
|
227
|
+
|
|
228
|
+
if args and isinstance(args[0], datetime.timedelta):
|
|
229
|
+
if "timedelta" in timedelta_kwargs:
|
|
230
|
+
timedelta_kwargs["timedelta"] += args[0]
|
|
231
|
+
|
|
232
|
+
else:
|
|
233
|
+
timedelta_kwargs["timedelta"] = args[0]
|
|
234
|
+
|
|
235
|
+
args = args[1:]
|
|
236
|
+
|
|
237
|
+
replace_names = {
|
|
238
|
+
"year": ["year", "yr", "y", "Y"],
|
|
239
|
+
"month": ["month", "m"],
|
|
240
|
+
"day": ["day", "d"],
|
|
241
|
+
"hour": ["hour", "hr", "H"],
|
|
242
|
+
"minute": ["minute", "min", "M"],
|
|
243
|
+
"second": ["second", "sec", "S"],
|
|
244
|
+
"microsecond": ["microsecond", "us", "usec"],
|
|
245
|
+
"millisecond": ["millisecond", "ms", "msec"],
|
|
246
|
+
}
|
|
247
|
+
for nk, ks in replace_names.items():
|
|
248
|
+
for k in ks:
|
|
249
|
+
if k in kwargs:
|
|
250
|
+
replace_kwargs.setdefault(nk, 0)
|
|
251
|
+
replace_kwargs[nk] += kwargs.pop(k)
|
|
252
|
+
|
|
253
|
+
if len(args) < 8:
|
|
254
|
+
replace_kwargs.setdefault(
|
|
255
|
+
"tzinfo",
|
|
256
|
+
kwargs.pop("tzinfo", datetime.timezone.utc)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
else:
|
|
260
|
+
if tzinfo := kwargs.pop("tzinfo", None):
|
|
261
|
+
replace_kwargs["tzinfo"] = tzinfo
|
|
262
|
+
|
|
263
|
+
if not args and "now" in kwargs:
|
|
264
|
+
args = [kwargs.pop("now")]
|
|
265
|
+
|
|
266
|
+
return args, replace_kwargs, timedelta_kwargs
|
|
267
|
+
|
|
268
|
+
def __new__(cls, *args, **kwargs):
|
|
269
|
+
# remove any custom keywords
|
|
270
|
+
args, replace_kwargs, timedelta_kwargs = cls.parse_args(args, kwargs)
|
|
271
|
+
|
|
272
|
+
if not args:
|
|
273
|
+
instance = cls.now(datetime.timezone.utc)
|
|
274
|
+
|
|
275
|
+
elif len(args) == 1:
|
|
276
|
+
if isinstance(args[0], datetime.datetime):
|
|
277
|
+
dt = args[0].astimezone(datetime.timezone.utc)
|
|
278
|
+
instance = super().__new__(
|
|
279
|
+
cls,
|
|
280
|
+
dt.year,
|
|
281
|
+
dt.month,
|
|
282
|
+
dt.day,
|
|
283
|
+
dt.hour,
|
|
284
|
+
dt.minute,
|
|
285
|
+
dt.second,
|
|
286
|
+
dt.microsecond,
|
|
287
|
+
tzinfo=dt.tzinfo,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
elif isinstance(args[0], datetime.date):
|
|
291
|
+
instance = super().__new__(
|
|
292
|
+
cls,
|
|
293
|
+
args[0].year,
|
|
294
|
+
args[0].month,
|
|
295
|
+
args[0].day,
|
|
296
|
+
0,
|
|
297
|
+
0,
|
|
298
|
+
0,
|
|
299
|
+
0,
|
|
300
|
+
tzinfo=datetime.timezone.utc,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
elif isinstance(args[0], (int, float)):
|
|
304
|
+
try:
|
|
305
|
+
instance = cls.fromtimestamp(
|
|
306
|
+
args[0],
|
|
307
|
+
tz=datetime.timezone.utc
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
except ValueError as e:
|
|
311
|
+
raise ValueError(
|
|
312
|
+
f"timestamp {args[0]} is out of bounds"
|
|
313
|
+
) from e
|
|
314
|
+
|
|
315
|
+
except OSError:
|
|
316
|
+
if isinstance(args[0], int):
|
|
317
|
+
timestamp = str(args[0])
|
|
318
|
+
s, ms = str(time.time()).split(".")
|
|
319
|
+
|
|
320
|
+
seconds = timestamp[:len(s)]
|
|
321
|
+
milliseconds = timestamp[len(s):]
|
|
322
|
+
instance = cls.fromtimestamp(
|
|
323
|
+
float(f"{seconds}.{milliseconds}"),
|
|
324
|
+
tz=datetime.timezone.utc
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
else:
|
|
328
|
+
raise
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
else:
|
|
332
|
+
if args[0]:
|
|
333
|
+
try:
|
|
334
|
+
# if the object is pickled we would get the pickled
|
|
335
|
+
# string as our one passed in value
|
|
336
|
+
instance = super().__new__(cls, *args)
|
|
337
|
+
|
|
338
|
+
except TypeError:
|
|
339
|
+
fs = cls.parse(args[0])
|
|
340
|
+
if fs is None:
|
|
341
|
+
raise
|
|
342
|
+
|
|
343
|
+
else:
|
|
344
|
+
instance = fs
|
|
345
|
+
|
|
346
|
+
else:
|
|
347
|
+
instance = cls.now(datetime.timezone.utc)
|
|
348
|
+
|
|
349
|
+
else:
|
|
350
|
+
instance = super().__new__(cls, *args, **replace_kwargs)
|
|
351
|
+
replace_kwargs = {} # we've consumed them
|
|
352
|
+
|
|
353
|
+
#instance = instance.replace_timedelta(**kw)
|
|
354
|
+
if replace_kwargs or timedelta_kwargs:
|
|
355
|
+
instance = instance.replace(**replace_kwargs, **timedelta_kwargs)
|
|
356
|
+
|
|
357
|
+
return instance
|
|
358
|
+
|
|
359
|
+
def __str__(self):
|
|
360
|
+
return self.isoformat()
|
|
361
|
+
|
|
362
|
+
def __add__(self, other):
|
|
363
|
+
return type(self)(super().__add__(other))
|
|
364
|
+
|
|
365
|
+
def __sub__(self, other):
|
|
366
|
+
if isinstance(other, datetime.timedelta):
|
|
367
|
+
return type(self)(super().__sub__(other))
|
|
368
|
+
else:
|
|
369
|
+
return super(Datetime, self).__sub__(other)
|
|
370
|
+
|
|
371
|
+
def __eq__(self, other):
|
|
372
|
+
if isinstance(other, datetime.date) and \
|
|
373
|
+
not isinstance(other, datetime.datetime):
|
|
374
|
+
return self.date() == other
|
|
375
|
+
|
|
376
|
+
else:
|
|
377
|
+
return super().__eq__(other.astimezone(self.tzinfo))
|
|
378
|
+
|
|
379
|
+
def __lt__(self, other):
|
|
380
|
+
if isinstance(other, datetime.date) and \
|
|
381
|
+
not isinstance(other, datetime.datetime):
|
|
382
|
+
return self.date() < other
|
|
383
|
+
|
|
384
|
+
else:
|
|
385
|
+
return super(Datetime, self).__lt__(other.astimezone(self.tzinfo))
|
|
386
|
+
|
|
387
|
+
def __gt__(self, other):
|
|
388
|
+
return not self.__lt__(other) and not self.__eq__(other)
|
|
389
|
+
|
|
390
|
+
def __ge__(self, other):
|
|
391
|
+
return not self.__lt__(other) or self.__eq__(other)
|
|
392
|
+
|
|
393
|
+
def __le__(self, other):
|
|
394
|
+
return self.__lt__(other) or self.__eq__(other)
|
|
395
|
+
|
|
396
|
+
def __ne__(self, other):
|
|
397
|
+
return not self.__eq__(other)
|
|
398
|
+
|
|
399
|
+
def __hash__(self):
|
|
400
|
+
# https://stackoverflow.com/questions/10254594/what-makes-a-user-defined-class-unhashable
|
|
401
|
+
return super().__hash__()
|
|
402
|
+
|
|
403
|
+
def has_time(self):
|
|
404
|
+
return not (
|
|
405
|
+
self.hour == 0 and
|
|
406
|
+
self.minute == 0 and
|
|
407
|
+
self.second == 0 and
|
|
408
|
+
self.microsecond == 0
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def timestamp(self):
|
|
412
|
+
"""
|
|
413
|
+
return the current utc timestamp of self
|
|
414
|
+
|
|
415
|
+
http://crazytechthoughts.blogspot.com/2012/02/get-current-utc-timestamp-in-python.html
|
|
416
|
+
|
|
417
|
+
:returns: float, the current utc timestamp with microsecond precision
|
|
418
|
+
"""
|
|
419
|
+
# this only returns second precision, which is why we don't use it
|
|
420
|
+
#now = calendar.timegm(datetime.datetime.utcnow().utctimetuple())
|
|
421
|
+
|
|
422
|
+
# this returns microsecond precision
|
|
423
|
+
# http://bugs.python.org/msg180110
|
|
424
|
+
epoch = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
|
|
425
|
+
return (self - epoch).total_seconds()
|
|
426
|
+
|
|
427
|
+
def timestamp_ns(self):
|
|
428
|
+
"""Similar to .timestamp() but returns time as an integer number of
|
|
429
|
+
nanoseconds since the epoch
|
|
430
|
+
|
|
431
|
+
see time.time_ns()
|
|
432
|
+
|
|
433
|
+
:returns: int
|
|
434
|
+
"""
|
|
435
|
+
size = len(str(time.time_ns()))
|
|
436
|
+
timestamp = str(self.timestamp()).replace(".", "")
|
|
437
|
+
return int("{{:0<{}}}".format(size).format(timestamp))
|
|
438
|
+
|
|
439
|
+
def since(self, now=None, chunks=2):
|
|
440
|
+
"""Returns a description of the amount of time that has passed from self
|
|
441
|
+
to now. This is more exact than .estsince() but does a lot more
|
|
442
|
+
computation to make it accurate
|
|
443
|
+
|
|
444
|
+
:param now: datetime, if None will default to the current datetime
|
|
445
|
+
:param chunks: int, if a postive value only that many chunks will be
|
|
446
|
+
returned, if None or -1 then all found chunks will be returned
|
|
447
|
+
:returns: str, the elapsed time in english (eg, x years, xx months, or
|
|
448
|
+
x days, xx hours)
|
|
449
|
+
"""
|
|
450
|
+
output = []
|
|
451
|
+
|
|
452
|
+
# time period chunks that aren't variable
|
|
453
|
+
period_chunks = [
|
|
454
|
+
(60 * 60 * 24 * 7, 'week', 'weeks'),
|
|
455
|
+
(60 * 60 * 24 , 'day', 'days'),
|
|
456
|
+
(60 * 60 , 'hour', 'hours'),
|
|
457
|
+
(60 , 'minute', 'minutes'),
|
|
458
|
+
(1 , 'second', 'seconds'),
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
# now will equal None if we want to know the time elapsed between self
|
|
462
|
+
# and the current time, otherwise it will be between self and now
|
|
463
|
+
if not chunks or chunks < 0:
|
|
464
|
+
chunks = len(period_chunks) + 2
|
|
465
|
+
|
|
466
|
+
now = Datetime(now)
|
|
467
|
+
months = 0
|
|
468
|
+
month_days = 0
|
|
469
|
+
for month in self.months(now, inclusive=False):
|
|
470
|
+
months += 1
|
|
471
|
+
month_days += month.days_in_month()
|
|
472
|
+
|
|
473
|
+
if months:
|
|
474
|
+
years = months // 12
|
|
475
|
+
if years:
|
|
476
|
+
name = "year" if years == 1 else "years"
|
|
477
|
+
output.append(f"{years} {name}")
|
|
478
|
+
chunks -= 1
|
|
479
|
+
|
|
480
|
+
if chunks > 0:
|
|
481
|
+
name = "month" if months == 1 else "months"
|
|
482
|
+
output.append(f"{months} {name}")
|
|
483
|
+
chunks -= 1
|
|
484
|
+
|
|
485
|
+
# difference in seconds
|
|
486
|
+
since = (now - self).total_seconds()
|
|
487
|
+
since -= (period_chunks[1][0] * month_days)
|
|
488
|
+
|
|
489
|
+
for seconds, name, names in period_chunks:
|
|
490
|
+
if count := math.floor(since / seconds):
|
|
491
|
+
output.append(f"1 {name}" if count == 1 else f"{count} {names}")
|
|
492
|
+
since -= (seconds * count)
|
|
493
|
+
chunks -= 1
|
|
494
|
+
if since <= 0 or chunks <= 0:
|
|
495
|
+
break
|
|
496
|
+
|
|
497
|
+
return ", ".join(output) if output else ""
|
|
498
|
+
|
|
499
|
+
def estsince(self, now=None, chunks=2):
|
|
500
|
+
"""Returns a ballpark/estimate description of the amount of time that
|
|
501
|
+
has passed from self to now
|
|
502
|
+
|
|
503
|
+
This is based on Plancast's Formatting.php timeSince method
|
|
504
|
+
|
|
505
|
+
NOTE -- this can drift since it uses 30 days for the months, you can see
|
|
506
|
+
this by doing:
|
|
507
|
+
|
|
508
|
+
d = Datetime(months=-5, days=-3)
|
|
509
|
+
d.since(now=Datetime(month=8, day=1, year=2023)) # 5 months, 6 days
|
|
510
|
+
|
|
511
|
+
:param now: datetime, if None will default to the current datetime
|
|
512
|
+
:param chunks: int, if a postive value only that many chunks will be
|
|
513
|
+
returned, if None or -1 then all found chunks will be returned
|
|
514
|
+
:returns: str, the elapsed time in english (eg, x years, xx months, or
|
|
515
|
+
x days, xx hours)
|
|
516
|
+
"""
|
|
517
|
+
output = []
|
|
518
|
+
|
|
519
|
+
# time period chunks
|
|
520
|
+
period_chunks = [
|
|
521
|
+
(60 * 60 * 24 * 365 , 'year', 'years'),
|
|
522
|
+
(60 * 60 * 24 * 30 , 'month', 'months'),
|
|
523
|
+
(60 * 60 * 24 * 7, 'week', 'weeks'),
|
|
524
|
+
(60 * 60 * 24 , 'day', 'days'),
|
|
525
|
+
(60 * 60 , 'hour', 'hours'),
|
|
526
|
+
(60 , 'minute', 'minutes'),
|
|
527
|
+
(1 , 'second', 'seconds'),
|
|
528
|
+
]
|
|
529
|
+
|
|
530
|
+
# now will equal None if we want to know the time elapsed between self
|
|
531
|
+
# and the current time, otherwise it will be between self and now
|
|
532
|
+
now = now or self.now(self.tzinfo)
|
|
533
|
+
if not chunks or chunks < 0:
|
|
534
|
+
chunks = len(period_chunks)
|
|
535
|
+
|
|
536
|
+
# difference in seconds
|
|
537
|
+
since = (now - self).total_seconds()
|
|
538
|
+
|
|
539
|
+
for seconds, name, names in period_chunks:
|
|
540
|
+
if count := math.floor(since / seconds):
|
|
541
|
+
output.append(f"1 {name}" if count == 1 else f"{count} {names}")
|
|
542
|
+
since -= (seconds * count)
|
|
543
|
+
chunks -= 1
|
|
544
|
+
if since <= 0 or chunks <= 0:
|
|
545
|
+
break
|
|
546
|
+
|
|
547
|
+
return ", ".join(output) if output else ""
|
|
548
|
+
def esince(self, *args, **kwargs):
|
|
549
|
+
return self.estsince(*args, **kwargs)
|
|
550
|
+
|
|
551
|
+
def datehash(self):
|
|
552
|
+
"""Return a datehash, kind of similar to a geohash where each character
|
|
553
|
+
represents a more exact time
|
|
554
|
+
|
|
555
|
+
This is something I've noodled on since Plancast days, and I wanted to
|
|
556
|
+
finally have a canonical place where I could mess with it and track
|
|
557
|
+
changes
|
|
558
|
+
|
|
559
|
+
:returns: str, A 7 character string, with indexes that represent:
|
|
560
|
+
0,1 - the year
|
|
561
|
+
2 - month
|
|
562
|
+
3 - day
|
|
563
|
+
4 - hour
|
|
564
|
+
5 - minute
|
|
565
|
+
6 - second
|
|
566
|
+
"""
|
|
567
|
+
h = ""
|
|
568
|
+
chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
569
|
+
|
|
570
|
+
# year
|
|
571
|
+
h += chars[self.year // 100]
|
|
572
|
+
h += chars[self.year % 100]
|
|
573
|
+
|
|
574
|
+
# month
|
|
575
|
+
h += chars[self.month]
|
|
576
|
+
|
|
577
|
+
# day
|
|
578
|
+
h += chars[self.day]
|
|
579
|
+
|
|
580
|
+
if self.has_time():
|
|
581
|
+
h += chars[self.hour]
|
|
582
|
+
h += chars[self.minute]
|
|
583
|
+
h += chars[self.second]
|
|
584
|
+
|
|
585
|
+
return h
|
|
586
|
+
|
|
587
|
+
@classmethod
|
|
588
|
+
def fromdatehash(cls, h):
|
|
589
|
+
chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
590
|
+
indexes = {v:k for k, v in enumerate(chars)}
|
|
591
|
+
|
|
592
|
+
year = int(str(indexes[h[0]]) + str(indexes[h[1]]))
|
|
593
|
+
month = indexes[h[2]]
|
|
594
|
+
day = indexes[h[3]]
|
|
595
|
+
|
|
596
|
+
hour = minute = second = 0
|
|
597
|
+
if len(h) >= 4:
|
|
598
|
+
hour = indexes[h[4]]
|
|
599
|
+
|
|
600
|
+
if len(h) >= 5:
|
|
601
|
+
minute = indexes[h[5]]
|
|
602
|
+
|
|
603
|
+
if len(h) >= 6:
|
|
604
|
+
second = indexes[h[6]]
|
|
605
|
+
|
|
606
|
+
return cls(year, month, day, hour, minute, second)
|
|
607
|
+
|
|
608
|
+
@classmethod
|
|
609
|
+
def now(cls, tz=datetime.timezone.utc):
|
|
610
|
+
"""Passthrough but sets timezone to utc by default instead of None, so
|
|
611
|
+
by default this now returns a timezone aware instance
|
|
612
|
+
|
|
613
|
+
https://docs.python.org/3/library/datetime.html#datetime.datetime.now
|
|
614
|
+
"""
|
|
615
|
+
return super().now(tz)
|
|
616
|
+
|
|
617
|
+
@classmethod
|
|
618
|
+
def utcnow(cls):
|
|
619
|
+
"""Overrides parent to return a timezone aware datetime instance with
|
|
620
|
+
the timezone UTC set
|
|
621
|
+
|
|
622
|
+
https://docs.python.org/3/library/datetime.html#datetime.datetime.utcnow
|
|
623
|
+
"""
|
|
624
|
+
return cls.now(datetime.timezone.utc)
|
|
625
|
+
|
|
626
|
+
def isodate(self):
|
|
627
|
+
"""returns datetime as ISO-8601 string with just YYYY-MM-DD"""
|
|
628
|
+
return self.strftime(self.FORMAT_ISO8601_DAY)
|
|
629
|
+
iso_day = isodate
|
|
630
|
+
isoday = isodate
|
|
631
|
+
iso_date = isodate
|
|
632
|
+
|
|
633
|
+
def isoseconds(self):
|
|
634
|
+
"""returns datetime as ISO-8601 string with no milliseconds"""
|
|
635
|
+
return self.isoformat(timespec="seconds")
|
|
636
|
+
iso_seconds = isoseconds
|
|
637
|
+
|
|
638
|
+
def iso8601(self):
|
|
639
|
+
"""returns datetime as a full ISO-8601 string with milliseconds"""
|
|
640
|
+
return self.isoformat(timespec="microseconds")
|
|
641
|
+
iso_8601 = iso8601
|
|
642
|
+
|
|
643
|
+
def isofull(self):
|
|
644
|
+
return self.isoformat(timespec="microseconds")
|
|
645
|
+
|
|
646
|
+
def isoformat(self, sep="T", timespec="auto"):
|
|
647
|
+
"""provides python 3 compatible isoformat even for py2
|
|
648
|
+
|
|
649
|
+
https://docs.python.org/3/library/datetime.html#datetime.datetime.isoformat
|
|
650
|
+
|
|
651
|
+
Differences between this and builtin datetime.isoformat:
|
|
652
|
+
|
|
653
|
+
* this will always include a timezone, defaulting to Z if no other
|
|
654
|
+
timezone is present
|
|
655
|
+
* This will return YYYY-MM-DD if no time information is set and
|
|
656
|
+
timespec is "auto"
|
|
657
|
+
|
|
658
|
+
:returns: string, the iso8601 date
|
|
659
|
+
"""
|
|
660
|
+
if self.has_time():
|
|
661
|
+
if self.utcoffset():
|
|
662
|
+
ret = super().isoformat(sep, timespec)
|
|
663
|
+
|
|
664
|
+
else:
|
|
665
|
+
ret = self.datetime().replace(tzinfo=None).isoformat(
|
|
666
|
+
sep,
|
|
667
|
+
timespec
|
|
668
|
+
)
|
|
669
|
+
ret += "Z"
|
|
670
|
+
|
|
671
|
+
else:
|
|
672
|
+
if timespec == "auto":
|
|
673
|
+
ret = self.date().isoformat()
|
|
674
|
+
|
|
675
|
+
else:
|
|
676
|
+
ret = self.datetime().replace(tzinfo=None).isoformat(
|
|
677
|
+
sep,
|
|
678
|
+
timespec
|
|
679
|
+
)
|
|
680
|
+
ret += "Z"
|
|
681
|
+
|
|
682
|
+
return ret
|
|
683
|
+
|
|
684
|
+
def within(self, start, stop, now=None):
|
|
685
|
+
"""return True if this datetime is within start and stop dates
|
|
686
|
+
|
|
687
|
+
:param start: int|timedelta|datetime, if int, then seconds from now, so a
|
|
688
|
+
negative integer would be in the past from now
|
|
689
|
+
:param stop: int|timedelta|datetime, same as start
|
|
690
|
+
:param now: datetime, what you want now to be
|
|
691
|
+
:returns: boolean, True if self is between start and stop
|
|
692
|
+
"""
|
|
693
|
+
if not now:
|
|
694
|
+
now = type(self)()
|
|
695
|
+
|
|
696
|
+
if isinstance(start, int):
|
|
697
|
+
start = now + datetime.timedelta(seconds=start)
|
|
698
|
+
start = type(self)(start)
|
|
699
|
+
|
|
700
|
+
if isinstance(stop, int):
|
|
701
|
+
stop = now + datetime.timedelta(seconds=stop)
|
|
702
|
+
stop = type(self)(stop)
|
|
703
|
+
|
|
704
|
+
return start <= self <= stop
|
|
705
|
+
|
|
706
|
+
def strftime(self, *args, **kwargs):
|
|
707
|
+
"""Make sure strftime always returns a unicode object"""
|
|
708
|
+
|
|
709
|
+
# this was moved here on 2021-5-5 from prom.config.Field.jsonable
|
|
710
|
+
try:
|
|
711
|
+
val = super(Datetime, self).strftime(*args, **kwargs)
|
|
712
|
+
|
|
713
|
+
except ValueError as e:
|
|
714
|
+
# strftime can fail on dates <1900
|
|
715
|
+
# Note that Python 2.7, 3.0 and 3.1 have errors before the year 1900,
|
|
716
|
+
# Python 3.2 has errors before the year 1000. Additionally, pre-3.2
|
|
717
|
+
# versions interpret years between 0 and 99 as between 1969 and 2068.
|
|
718
|
+
# Python versions from 3.3 onward support all positive years in
|
|
719
|
+
# datetime (and negative years in time.strftime), and time.strftime
|
|
720
|
+
# doesn't do any mapping of years between 0 and 99.
|
|
721
|
+
# https://stackoverflow.com/a/32206673/5006
|
|
722
|
+
logger.warning(e, exc_info=True)
|
|
723
|
+
|
|
724
|
+
# we correct this issue by just giving it a dumb year,
|
|
725
|
+
# creating the timestamp and then replacing the year, we can
|
|
726
|
+
# do this semi-confidently because our format_str doesn't have
|
|
727
|
+
# day of the week (eg, Monday), we account for leap years
|
|
728
|
+
# just in case
|
|
729
|
+
orig_year = self.year
|
|
730
|
+
if (orig_year % 4) == 0:
|
|
731
|
+
if (orig_year % 100) == 0:
|
|
732
|
+
if (orig_year % 400) == 0:
|
|
733
|
+
placeholder_year = 2000
|
|
734
|
+
|
|
735
|
+
else:
|
|
736
|
+
placeholder_year = 1900
|
|
737
|
+
|
|
738
|
+
else:
|
|
739
|
+
placeholder_year = 2012
|
|
740
|
+
|
|
741
|
+
else:
|
|
742
|
+
placeholder_year = 1997
|
|
743
|
+
|
|
744
|
+
dt = self.replace(year=placeholder_year)
|
|
745
|
+
val = dt.strftime(*args, **kwargs)
|
|
746
|
+
val = re.sub(r"^{}".format(placeholder_year), String(orig_year), val)
|
|
747
|
+
|
|
748
|
+
return String(val)
|
|
749
|
+
|
|
750
|
+
def replace(self, *args, **kwargs):
|
|
751
|
+
"""Overrides default behavior to account for custom behavior
|
|
752
|
+
|
|
753
|
+
https://docs.python.org/3/library/datetime.html#datetime.datetime.replace
|
|
754
|
+
|
|
755
|
+
Returns a new datetime instance with any time deltas applied
|
|
756
|
+
|
|
757
|
+
:param timedelta: datetime.timedelta
|
|
758
|
+
:param **kwargs: besides the standard keywords, this can also have keyword
|
|
759
|
+
values for: months, weeks, days, hours, seconds, and timedelta
|
|
760
|
+
:returns: Datetime
|
|
761
|
+
"""
|
|
762
|
+
args, replace_kwargs, timedelta_kwargs = self.parse_args(args, kwargs)
|
|
763
|
+
|
|
764
|
+
if replace_kwargs:
|
|
765
|
+
dt = super().replace(*args, **replace_kwargs)
|
|
766
|
+
|
|
767
|
+
else:
|
|
768
|
+
dt = super().replace(*args)
|
|
769
|
+
|
|
770
|
+
if timedelta := timedelta_kwargs.pop("timedelta", None):
|
|
771
|
+
dt = dt + timedelta
|
|
772
|
+
|
|
773
|
+
if timedelta_kwargs:
|
|
774
|
+
if "years" in timedelta_kwargs:
|
|
775
|
+
year = int(timedelta_kwargs.pop("years"))
|
|
776
|
+
dt = dt.replace(self.year + year)
|
|
777
|
+
|
|
778
|
+
if "months" in timedelta_kwargs:
|
|
779
|
+
months = int(timedelta_kwargs.pop("months"))
|
|
780
|
+
# https://stackoverflow.com/a/546354/5006
|
|
781
|
+
year = self.year + (
|
|
782
|
+
(
|
|
783
|
+
(
|
|
784
|
+
self.month + (months - 1 if months else months)
|
|
785
|
+
) or -1
|
|
786
|
+
) // 12
|
|
787
|
+
)
|
|
788
|
+
month = ((self.month + months) % 12) or 12
|
|
789
|
+
day = self.day - 1
|
|
790
|
+
dt = dt.replace(year, month, 1) + datetime.timedelta(days=day)
|
|
791
|
+
|
|
792
|
+
dt += datetime.timedelta(**timedelta_kwargs)
|
|
793
|
+
|
|
794
|
+
return dt
|
|
795
|
+
|
|
796
|
+
def datetime(self):
|
|
797
|
+
"""Similar to .date() but returns vanilla datetime instance"""
|
|
798
|
+
return datetime.datetime(
|
|
799
|
+
self.year,
|
|
800
|
+
self.month,
|
|
801
|
+
self.day,
|
|
802
|
+
self.hour,
|
|
803
|
+
self.minute,
|
|
804
|
+
self.second,
|
|
805
|
+
self.microsecond,
|
|
806
|
+
tzinfo=self.tzinfo,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
def days_in_month(self):
|
|
810
|
+
"""Return how many days there are in self.month
|
|
811
|
+
|
|
812
|
+
Months with 28 or 20 days:
|
|
813
|
+
* February
|
|
814
|
+
|
|
815
|
+
Months with 30 days:
|
|
816
|
+
* April
|
|
817
|
+
* June
|
|
818
|
+
* September
|
|
819
|
+
* November
|
|
820
|
+
|
|
821
|
+
Months with 31 days:
|
|
822
|
+
* January
|
|
823
|
+
* March
|
|
824
|
+
* May
|
|
825
|
+
* July
|
|
826
|
+
* August
|
|
827
|
+
* October
|
|
828
|
+
* December
|
|
829
|
+
"""
|
|
830
|
+
_, monthlen = calendar.monthrange(self.year, self.month)
|
|
831
|
+
return monthlen
|
|
832
|
+
|
|
833
|
+
def next_month(self):
|
|
834
|
+
"""Returns the first day of the next month from self"""
|
|
835
|
+
month = self.month
|
|
836
|
+
year = self.year
|
|
837
|
+
if month == 12:
|
|
838
|
+
month = 1
|
|
839
|
+
year += 1
|
|
840
|
+
|
|
841
|
+
else:
|
|
842
|
+
month += 1
|
|
843
|
+
|
|
844
|
+
return Datetime(year, month, 1)
|
|
845
|
+
|
|
846
|
+
def prev_month(self):
|
|
847
|
+
"""Returns the first day of the previous month from self"""
|
|
848
|
+
month = self.month
|
|
849
|
+
year = self.year
|
|
850
|
+
if month == 1:
|
|
851
|
+
month = 12
|
|
852
|
+
year -= 1
|
|
853
|
+
|
|
854
|
+
else:
|
|
855
|
+
month -= 1
|
|
856
|
+
|
|
857
|
+
return Datetime(year, month, 1)
|
|
858
|
+
|
|
859
|
+
def current_month(self):
|
|
860
|
+
"""Returns the first day of the current month from self"""
|
|
861
|
+
return Datetime(self.year, self.month, 1)
|
|
862
|
+
|
|
863
|
+
def months(self, now=None, inclusive=True):
|
|
864
|
+
"""Returns all the months between self and now
|
|
865
|
+
|
|
866
|
+
:param now: datetime, if None then defaults to now
|
|
867
|
+
:param inclusive: bool, by default, if self was Jan and now was Aug and
|
|
868
|
+
you iterated through the months it would be: Jan, Feb, Mar, Apr, May
|
|
869
|
+
June, July, Aug, because it includes the edges. If inclusive is
|
|
870
|
+
False then it would be: Feb, Mar, Apr, May, june, July
|
|
871
|
+
:returns: generator[datetime], each datetime instance will be midnight
|
|
872
|
+
on the first day of that month
|
|
873
|
+
"""
|
|
874
|
+
now = Datetime(now)
|
|
875
|
+
month = self.current_month() if inclusive else self.next_month()
|
|
876
|
+
now_month = now.current_month() if inclusive else now.prev_month()
|
|
877
|
+
|
|
878
|
+
if month >= now_month:
|
|
879
|
+
if inclusive:
|
|
880
|
+
yield self.current_month()
|
|
881
|
+
|
|
882
|
+
else:
|
|
883
|
+
while month < now_month:
|
|
884
|
+
yield month
|
|
885
|
+
month = month.next_month()
|
|
886
|
+
|
|
887
|
+
yield month
|
|
888
|
+
|