Repository navigation
Expand file tree
/
Copy pathtime.py
More file actions
577 lines (480 loc) · 20.6 KB
/
Copy pathtime.py
File metadata and controls
577 lines (480 loc) · 20.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
#! /usr/bin/env python3
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author: Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
# https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.
# https://github.com/Linuxfabrik/lib/blob/main/CONTRIBUTING.md
"""Provides datetime functions."""
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
__version__ = '2026101202'
import datetime
import email.utils
import re
import time
try:
import zoneinfo
except ImportError:
# zoneinfo is part of the standard library since Python 3.9. On older
# interpreters (such as the system Python 3.6 on RHEL 8 / Rocky 8) it is
# missing; degrade to UTC in get_timezone() instead of failing at import,
# so consumers that do not need named time zones keep working.
zoneinfo = None
def datetime2local(dt):
"""
Convert a datetime that carries an offset into the local time of the host, without one.
Consumers compare times with `now(as_type='datetime')`, which is local and naive. A time
read with its offset (`Z`, `+02:00`, `GMT`) has to be moved into that zone before the
offset is dropped, otherwise the comparison is off by the difference between the two.
Parameters
----------
dt : datetime.datetime
The time, with or without an offset.
Returns
-------
datetime.datetime
The same moment in local time, naive. A value without an offset is returned unchanged,
because there is nothing to tell which zone it is in.
Raises
------
ValueError
If the moment lies outside what a datetime can hold in local time, as the zero
value of Go's `time.Time` (`0001-01-01T00:00:00Z`) does on a host west of UTC.
Examples
--------
>>> datetime2local(timestr2datetime('2026-04-01T10:00:00Z', pattern='iso8601'))
datetime.datetime(2026, 4, 1, 12, 0) # on a host in Central European Summer Time
"""
if dt.tzinfo is None:
return dt
try:
return dt.astimezone().replace(tzinfo=None)
except OverflowError as e:
# the same exception the parsers raise, so one `except ValueError` covers both
raise ValueError(f'{dt.isoformat()} cannot be expressed in local time') from e
def epoch2iso(timestamp):
"""
Converts a UNIX epoch timestamp to an ISO-formatted date and time string.
This function takes a UNIX timestamp (int or float) and returns a string representing the local
time in ISO 8601 format (YYYY-MM-DD HH:MM:SS).
Parameters
----------
timestamp : int or float
UNIX epoch timestamp (seconds since 1970-01-01).
Returns
-------
str
Local date and time in ISO 8601 format, or the empty string for a value that is
not a timestamp or lies outside what the platform can represent.
Examples
--------
>>> epoch2iso(1620459129)
'2021-05-08 09:32:09'
>>> epoch2iso(float('inf'))
''
"""
try:
ts = float(timestamp)
return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))
except (OSError, OverflowError, TypeError, ValueError):
# OverflowError beyond the platform's time_t, OSError where localtime()
# itself refuses the value (on Windows a negative timestamp, for example)
return ''
def get_timezone(tz_name):
"""
Load an IANA time-zone by name and return a ZoneInfo object, defaulting to UTC if invalid.
This function takes an IANA time-zone name (str) and returns the corresponding
zoneinfo.ZoneInfo object. If loading fails, UTC ("Etc/UTC") is returned.
Parameters
----------
tz_name : str
IANA time-zone identifier (e.g. "Europe/London").
Returns
-------
ZoneInfo
A `zoneinfo.ZoneInfo` object for the requested zone, or UTC if not found.
Examples
--------
>>> get_timezone('Europe/London').key
'Europe/London'
>>> get_timezone('Invalid/Zone').key
'Etc/UTC'
"""
if zoneinfo is None:
# Python < 3.9 without the zoneinfo backport
return datetime.timezone.utc
try:
return zoneinfo.ZoneInfo(tz_name)
except Exception:
# Fallback to UTC if the name isn't found
try:
return zoneinfo.ZoneInfo('Etc/UTC')
except Exception:
return datetime.timezone.utc
def get_weekday(epoch):
"""
Convert a UNIX epoch timestamp to a lowercase three-letter weekday abbreviation.
This function takes a UNIX timestamp (int or float) and returns the local weekday
as a three-letter lowercase string: 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', or 'sun'.
Parameters
----------
epoch : int or float
UNIX epoch timestamp (seconds since 1970-01-01).
Returns
-------
str
Lowercase three-letter abbreviation of the weekday corresponding to the local
date, or the empty string for a value that is not a timestamp or lies outside
what the platform can represent, like `epoch2iso()`.
Examples
--------
>>> get_weekday(1620459129)
'sat'
"""
_WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
try:
return _WEEKDAYS[time.localtime(float(epoch)).tm_wday]
except (OSError, OverflowError, TypeError, ValueError):
return ''
_MACRO_COMPONENT = re.compile(r'\{%([YymdHMS])\}')
def macro2timestr(s, format=''):
"""
Expand time macros in a string.
Supported macros (case-sensitive, strftime-faithful):
- `{today}` → current date, formatted with `format` (default `%Y-%m-%d`).
- `{yesterday}` → current date minus one day, formatted with `format`.
- `{%Y}` / `{%y}` → 4-digit / 2-digit year of `now`.
- `{%m}` → month of `now` (01-12).
- `{%d}` → day of `now` (01-31).
- `{%H}` → hour of `now` (00-23).
- `{%M}` → minute of `now` (00-59).
- `{%S}` → second of `now` (00-59).
Unknown tokens are passed through unchanged.
Parameters
----------
s : str
Template string.
format : str, optional
strftime pattern used for `{today}`
and `{yesterday}`. Defaults to ISO 8601 date `%Y-%m-%d`.
Returns
-------
str
`s` with all recognised macros replaced.
Examples
--------
>>> # Assuming today is 2026-04-22:
>>> macro2timestr('/var/log/laravel/laravel-{today}.log')
'/var/log/laravel/laravel-2026-04-22.log'
>>> macro2timestr('C:\\\\logs\\\\{%Y}{%m}{%d}.log')
'C:\\\\logs\\\\20260422.log'
>>> macro2timestr('{today}', format='%Y%m%d')
'20260422'
>>> macro2timestr('{yesterday}', format='%d.%m.%Y')
'21.04.2026'
"""
default_format = format or '%Y-%m-%d'
base = now(as_type='datetime')
result = s.replace('{today}', base.strftime(default_format))
result = result.replace(
'{yesterday}',
(base - datetime.timedelta(days=1)).strftime(default_format),
)
return _MACRO_COMPONENT.sub(
lambda match: base.strftime('%' + match.group(1)),
result,
)
def now(as_type=''):
"""
Returns the current date and time in various formats.
Depending on `as_type`, this returns:
- Integer UNIX epoch (default)
- Floating-point UNIX epoch ('float')
- datetime.datetime object in local time ('datetime')
- datetime.datetime object in UTC, naive ('utc')
- ISO string 'YYYY-MM-DD HH:MM:SS' in local time ('iso')
Use 'utc' for fields that are defined as UTC by spec (x509
`notBefore` / `notAfter`, HTTP `Date`, RFC 3339 timestamps, ...).
Returned as naive (no tzinfo) so it drops in wherever the
callee expects a naive datetime.
Parameters
----------
as_type : str, optional
'', 'epoch', 'float', 'datetime', 'utc' or 'iso'. Defaults to ''.
Returns
-------
int, float, datetime.datetime, or str
Current time in the requested format.
Examples
--------
>>> now()
1586422786
>>> now(as_type='float')
1586422786.1521912
>>> now(as_type='datetime')
datetime.datetime(2020, 4, 9, 11, 1, 41, 228752)
>>> now(as_type='utc')
datetime.datetime(2020, 4, 9, 9, 1, 41, 228752)
>>> now(as_type='iso')
'2020-04-09 11:31:24'
"""
if as_type == 'datetime':
return datetime.datetime.now()
if as_type == 'utc':
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
if as_type == 'iso':
return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if as_type == 'float':
return time.time()
return int(time.time())
# Matches the fractional seconds of an ISO 8601 / RFC 3339 timestamp. A date or
# time never carries another dot, so the first match is the fraction.
_ISO8601_FRACTION_REGEX = re.compile(r'\.(\d+)')
def _normalize_iso8601_fraction(timestr):
"""
Rewrites the fractional seconds of an ISO 8601 string to exactly six digits.
`datetime.fromisoformat()` accepts only three or six fractional digits before Python 3.11,
while tools written in Go emit RFC 3339 with nanosecond precision and drop trailing zeros
(`time.RFC3339Nano`). Both ends of that range therefore fail on the interpreters shipped with
RHEL 9 and RHEL 8: `.076976146` has too many digits, `.07` too few. Padding and trimming to
six digits keeps the value within a microsecond and makes the string parse everywhere.
Parameters
----------
timestr : str
An ISO 8601 / RFC 3339 timestamp, with or without fractional seconds.
Returns
-------
str
The timestamp with its fractional seconds normalized, unchanged if it carries none.
Notes
-----
- Verified against Python 3.9, 3.10, 3.11 and 3.14: only 3.11 and newer accept the raw
nanosecond form.
"""
return _ISO8601_FRACTION_REGEX.sub(
lambda match: '.' + match.group(1)[:6].ljust(6, '0'),
timestr,
count=1,
)
# Matches a UTC offset written without the colon `fromisoformat()` wants before
# Python 3.11, at the very end of the string. ISO 8601 allows both `+0200` and
# `+02:00`, and `journalctl --output=short-iso` writes the former.
_ISO8601_OFFSET_REGEX = re.compile(r'([+-])(\d{2})(\d{2})$')
def _normalize_iso8601_offset(timestr):
"""Insert the colon into a `+hhmm` offset, which `fromisoformat()` needs before 3.11.
Leaves a value that already carries the colon, one that ends in `Z`, and one without an
offset at all untouched.
"""
return _ISO8601_OFFSET_REGEX.sub(r'\1\2:\3', timestr, count=1)
# The trailing offset in the form `fromisoformat()` wants, for taking a value
# apart by hand where that method does not exist yet.
_ISO8601_COLON_OFFSET_REGEX = re.compile(r'([+-])(\d{2}):(\d{2})$')
def _fromisoformat(iso):
"""`datetime.fromisoformat()`, hand-rolled where the interpreter has none.
Python 3.6 - which RHEL 8 ships as its system interpreter and which our own plugins run
on there - gained no `fromisoformat()`, and `%z` did not read an offset written with a
colon before 3.7 either. The value arrives normalized, so the layouts it can still have
are few enough to try in turn.
"""
if hasattr(datetime.datetime, 'fromisoformat'):
return datetime.datetime.fromisoformat(iso)
tzinfo = None
match = _ISO8601_COLON_OFFSET_REGEX.search(iso)
if match:
offset = datetime.timedelta(
hours=int(match.group(2)),
minutes=int(match.group(3)),
)
tzinfo = datetime.timezone(-offset if match.group(1) == '-' else offset)
iso = iso[: match.start()]
separator = 'T' if 'T' in iso else ' '
_, _, time_part = iso.partition(separator)
layout = '%Y-%m-%d'
if time_part:
layout += separator + '%H:%M'
if time_part.count(':') > 1:
layout += ':%S'
if '.' in time_part:
layout += '.%f'
dt = datetime.datetime.strptime(iso, layout)
return dt.replace(tzinfo=tzinfo) if tzinfo is not None else dt
def _parse(timestr, pattern, tzinfo=None):
"""Turn a time string into a datetime, either by `strptime` layout or as ISO 8601.
Shared by `timestr2datetime()` and `timestr2epoch()` so both accept the same values; see
`timestr2epoch()` for what `pattern='iso8601'` covers.
"""
if not isinstance(timestr, str):
# the documented error of a value that is not a time, whatever the pattern
raise ValueError(f'Not a time string: {timestr!r}')
if pattern == 'rfc2822':
# The date of e-mail headers, RSS and HTTP (`Wed, 1 Apr 2026 10:00:00 +0000`).
# RFC 2822 allows a one-digit day and leaves the weekday and the seconds out,
# which no single strptime() layout covers.
try:
dt = email.utils.parsedate_to_datetime(timestr.strip())
except (IndexError, TypeError) as e:
# Python 3.9 raises TypeError on a value it cannot read, later versions the
# ValueError every other pattern raises
raise ValueError(f'Not an RFC 2822 date: {timestr}') from e
if dt.tzinfo is None and tzinfo is not None:
dt = dt.replace(tzinfo=tzinfo)
return dt
if pattern == 'iso8601':
# fromisoformat() accepts a trailing 'Z' only from Python 3.11, so
# normalize it first. Same for fractional seconds that are not exactly
# three or six digits long, and for an offset written without a colon.
iso = timestr.strip()
if iso.endswith('Z'):
iso = iso[:-1] + '+00:00'
iso = _normalize_iso8601_offset(_normalize_iso8601_fraction(iso))
dt = _fromisoformat(iso)
# A value that already carries an offset keeps it; a naive value is
# tagged with `tzinfo` when one is given.
if dt.tzinfo is None and tzinfo is not None:
dt = dt.replace(tzinfo=tzinfo)
return dt
dt = datetime.datetime.strptime(timestr, pattern)
# If a timezone is provided, make the datetime timezone-aware.
if tzinfo is not None:
dt = dt.replace(tzinfo=tzinfo)
return dt
def timestr2datetime(timestr, pattern='%Y-%m-%d %H:%M:%S', tzinfo=None):
"""
Converts a time string into a datetime object using the specified format.
This function parses a string representing a date and time into a `datetime.datetime`
object based on the provided format pattern. The default format is ISO (YYYY-MM-DD HH:MM:SS).
Parameters
----------
timestr : str
A string representing the date and time.
pattern : str, optional
The format string corresponding to the structure of `timestr`.
Defaults to '%Y-%m-%d %H:%M:%S'. For more details on format codes, see:
https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes
Pass the special value `'iso8601'` to parse without knowing the exact layout in advance;
`timestr2epoch()` describes what that covers. Pass `'rfc2822'` for the date of e-mail
headers, RSS feeds and HTTP (`Wed, 1 Apr 2026 10:00:00 +0000`).
tzinfo : datetime.tzinfo, optional
Timezone to tag a value that carries none.
A value that brings its own offset keeps it. Defaults to None, which leaves the result
naive.
Returns
-------
datetime.datetime
A datetime object corresponding to the parsed date and time.
Examples
--------
>>> timestr2datetime('2021-05-08 09:32:09')
datetime.datetime(2021, 5, 8, 9, 32, 9)
>>> timestr2datetime('2026-08-28T17:16:18+0200', pattern='iso8601')
datetime.datetime(2026, 8, 28, 17, 16, 18, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200)))
"""
return _parse(timestr, pattern, tzinfo)
def timestr2epoch(timestr, pattern='%Y-%m-%d %H:%M:%S', tzinfo=None):
"""
Converts a time string to a UNIX epoch timestamp.
Parameters
----------
timestr : str
The time string to convert.
pattern : str
The format of the time string (default is '%Y-%m-%d %H:%M:%S').
Pass the special value `'iso8601'` to parse without knowing the exact `strptime` layout in
advance. Despite the name, this mode is backed by `datetime.fromisoformat()` (with a trailing
`Z` normalized to `+00:00` first), not a full ISO 8601 parser: it reliably handles RFC 3339
timestamps (date, `T`, time, and a `Z` or `+hh:mm` offset) and date-only values, but rejects
other valid ISO 8601 forms such as ordinal dates (`2024-015`). Which further layouts are
accepted depends on the Python version, because `fromisoformat()` was narrow before 3.11 and
broad from 3.11 on; RFC 3339 works consistently on 3.7+, including the nanosecond precision
of Go-based tools, whose fractional seconds are normalized to microseconds first. A value
that carries an offset (or `Z`) keeps it; a value without one is treated per `tzinfo`
(local time if `tzinfo` is None). An offset written without the colon (`+0200`, which
`journalctl --output=short-iso` produces) is accepted on every supported Python, even
though `fromisoformat()` itself only takes it from 3.11 on.
Pass `'rfc2822'` for the date of e-mail headers, RSS feeds and HTTP, read with
`email.utils.parsedate_to_datetime()`: the weekday, the seconds and a leading zero of the
day are optional, and the offset may be numeric (`+0200`) or a zone name (`GMT`). A
value with `-0000`, which RFC 2822 uses for an unknown zone, is treated per `tzinfo`.
tzinfo : datetime.tzinfo, optional
Timezone information.
If provided, the parsed datetime is set to this timezone.
If None, the time is assumed to be local time.
A value that already carries its own offset (e.g. a `Z` or `+hh:mm` in an iso8601 string)
keeps it.
Returns
-------
float
The UNIX epoch timestamp (seconds since January 1, 1970, 00:00:00 UTC).
Raises
------
ValueError
If the time string does not match the provided format (or is not accepted by
`datetime.fromisoformat()` after `Z` normalization when `pattern='iso8601'`).
Examples
--------
# Convert a time string in local time:
epoch_local = timestr2epoch("2025-03-01 12:00:00")
# Convert a time string assuming it's in UTC:
epoch_utc = timestr2epoch("2025-03-01 12:00:00", tzinfo=datetime.timezone.utc)
# Convert an ISO 8601 string without specifying its exact layout:
epoch_iso = timestr2epoch("2025-03-01T12:00:00Z", pattern='iso8601')
"""
return _parse(timestr, pattern, tzinfo).timestamp()
def timestrdiff(
timestr1, timestr2, pattern1='%Y-%m-%d %H:%M:%S', pattern2='%Y-%m-%d %H:%M:%S'
):
"""
Computes the absolute difference in seconds between two datetime strings.
This function converts two datetime strings into `datetime.datetime` objects using
their respective format patterns, then calculates the absolute time difference between them.
By default, both strings are expected to be in ISO format (YYYY-MM-DD HH:MM:SS).
Parameters
----------
timestr1 : str
The first datetime string.
timestr2 : str
The second datetime string.
pattern1 : str, optional
The format pattern for `timestr1`. Defaults to '%Y-%m-%d %H:%M:%S'.
pattern2 : str, optional
The format pattern for `timestr2`. Defaults to '%Y-%m-%d %H:%M:%S'.
For more information on format codes, refer to:
https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes
Returns
-------
float
The absolute difference between the two timestamps in seconds. Where one of them
carries an offset and the other does not, the one without is read as local time.
Raises
------
ValueError
If one of the strings does not match its pattern, or is not a string.
Examples
--------
>>> timestrdiff('2021-05-08 09:32:09', '2021-05-08 09:30:00')
129.0
"""
dt1 = timestr2datetime(timestr1, pattern1)
dt2 = timestr2datetime(timestr2, pattern2)
if (dt1.tzinfo is None) != (dt2.tzinfo is None):
# a time with an offset and one without cannot be subtracted: the one without is
# local time, as everywhere else in this module
dt1, dt2 = (dt.astimezone() if dt.tzinfo is None else dt for dt in (dt1, dt2))
return abs((dt1 - dt2).total_seconds())
def utc_offset():
"""
Retrieves the current local UTC offset as a string.
This function returns the local timezone's UTC offset formatted as a string
in the format ±HHMM (e.g., '+0200' or '-0500'), where HH represents hours and MM represents
minutes.
Returns
-------
str
The current local UTC offset.
Examples
--------
>>> utc_offset()
'+0200'
"""
return time.strftime('%z')