"""
This is util_time, it contains functions for handling time related code.
The :func:`timestamp` function returns an iso8601 timestamp without much fuss.
The :func:`timeparse` is the inverse of `timestamp`, and makes use of
:mod:`dateutil` if it is available.
The :class:`Timer` class is a context manager that times a block of indented
code. It includes `tic` and `toc` methods a more matlab like feel.
Timerit is gone! Use the standalone and separate module :py:mod:`timerit`.
See Also:
:mod:`tempora` - https://github.com/jaraco/tempora - time related utility functions from Jaraco
:mod:`pendulum` - https://github.com/sdispater/pendulum - drop in replacement for datetime
:mod:`arrow` - https://github.com/arrow-py/arrow
"""
import time
import sys
from functools import lru_cache
__all__ = ['timestamp', 'timeparse', 'Timer']
@lru_cache(maxsize=None)
def _needs_workaround39103():
"""
Depending on the system C library, either %04Y or %Y wont work.
This is an actual Python bug:
https://bugs.python.org/issue13305
singer-python also had a similar issue:
https://github.com/singer-io/singer-python/issues/86
See Also:
https://github.com/jaraco/tempora/blob/main/tempora/__init__.py#L59
"""
from datetime import datetime as datetime_cls
return len(datetime_cls(1, 1, 1).strftime('%Y')) != 4
[docs]
def timestamp(datetime=None, precision=0, default_timezone='local',
allow_dateutil=True):
"""
Make a concise iso8601 timestamp suitable for use in filenames.
Args:
datetime (datetime.datetime | datetime.date | None):
A datetime to format into a timestamp. If unspecified, the current
local time is used. If given as a date, the time 00:00 is used.
precision (int):
if non-zero, adds up to 6 digits of sub-second precision.
default_timezone (str | datetime.timezone):
if the input does not specify a timezone, assume this one.
Can be "local" or "utc", or a standardized code if dateutil is
installed.
allow_dateutil (bool):
if True, will use dateutil to lookup the default timezone if needed
Returns:
str:
The timestamp, which will always contain a date, time, and
timezone.
Note:
For more info see [WikiISO8601]_, [PyStrptime]_, [PyTime]_.
References:
.. [WikiISO8601] https://en.wikipedia.org/wiki/ISO_8601
.. [PyStrptime] https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior
.. [PyTime] https://docs.python.org/3/library/time.html
Example:
>>> import ubelt as ub
>>> stamp = ub.timestamp()
>>> print('stamp = {!r}'.format(stamp))
stamp = ...-...-...T...
Example:
>>> import ubelt as ub
>>> import datetime as datetime_mod
>>> from datetime import datetime as datetime_cls
>>> # Create a datetime object with timezone information
>>> ast_tzinfo = datetime_mod.timezone(datetime_mod.timedelta(hours=-4), 'AST')
>>> datetime = datetime_cls.utcfromtimestamp(123456789.123456789).replace(tzinfo=ast_tzinfo)
>>> stamp = ub.timestamp(datetime, precision=2)
>>> print('stamp = {!r}'.format(stamp))
stamp = '1973-11-29T213309.12-4'
>>> # Demo with a fractional hour timezone
>>> act_tzinfo = datetime_mod.timezone(datetime_mod.timedelta(hours=+9.5), 'ACT')
>>> datetime = datetime_cls.utcfromtimestamp(123456789.123456789).replace(tzinfo=act_tzinfo)
>>> stamp = ub.timestamp(datetime, precision=2)
>>> print('stamp = {!r}'.format(stamp))
stamp = '1973-11-29T213309.12+0930'
>>> # Can accept datetime or date objects with local, utc, or custom default timezones
>>> act_tzinfo = datetime_mod.timezone(datetime_mod.timedelta(hours=+9.5), 'ACT')
>>> datetime_utc = ub.timeparse('2020-03-05T112233', default_timezone='utc')
>>> datetime_act = ub.timeparse('2020-03-05T112233', default_timezone=act_tzinfo)
>>> datetime_notz = datetime_utc.replace(tzinfo=None)
>>> date = datetime_utc.date()
>>> stamp_utc = ub.timestamp(datetime_utc)
>>> stamp_act = ub.timestamp(datetime_act)
>>> stamp_date_utc = ub.timestamp(date, default_timezone='utc')
>>> print(f'stamp_utc = {stamp_utc}')
>>> print(f'stamp_act = {stamp_act}')
>>> print(f'stamp_date_utc = {stamp_date_utc}')
stamp_utc = 2020-03-05T112233+0
stamp_act = 2020-03-05T112233+0930
stamp_date_utc = 2020-03-05T000000+0
Example:
>>> # xdoctest: +REQUIRES(module:dateutil)
>>> # Make sure we are compatible with dateutil
>>> import ubelt as ub
>>> from dateutil.tz import tzlocal
>>> import datetime as datetime_mod
>>> from datetime import datetime as datetime_cls
>>> tz_act = datetime_mod.timezone(datetime_mod.timedelta(hours=+9.5), 'ACT')
>>> tzinfo_list = [
>>> tz_act,
>>> datetime_mod.timezone(datetime_mod.timedelta(hours=-4), 'AST'),
>>> datetime_mod.timezone(datetime_mod.timedelta(hours=0), 'UTC'),
>>> datetime_mod.timezone.utc,
>>> None,
>>> tzlocal()
>>> ]
>>> # Note: there is a win32 bug here
>>> # https://bugs.python.org/issue37 that means we cant use
>>> # dates close to the epoch
>>> datetime_list = [
>>> datetime_cls.utcfromtimestamp(123456789.123456789 + 315360000),
>>> datetime_cls.utcfromtimestamp(0 + 315360000),
>>> ]
>>> basis = {
>>> 'precision': [0, 3, 9],
>>> 'tzinfo': tzinfo_list,
>>> 'datetime': datetime_list,
>>> 'default_timezone': ['local', 'utc', tz_act],
>>> }
>>> for params in ub.named_product(basis):
>>> dtime = params['datetime'].replace(tzinfo=params['tzinfo'])
>>> precision = params.get('precision', 0)
>>> stamp = ub.timestamp(datetime=dtime, precision=precision)
>>> recon = ub.timeparse(stamp)
>>> alt = recon.strftime('%Y-%m-%dT%H%M%S.%f%z')
>>> print('---')
>>> print('params = {}'.format(ub.repr2(params, nl=1)))
>>> print(f'dtime={dtime}')
>>> print(f'stamp={stamp}')
>>> print(f'recon={recon}')
>>> print(f'alt ={alt}')
>>> shift = 10 ** precision
>>> a = int(dtime.timestamp() * shift)
>>> b = int(recon.timestamp() * shift)
>>> assert a == b, f'{a} != {b}'
"""
import datetime as datetime_mod
from datetime import datetime as datetime_cls
datetime_obj = datetime
offset_seconds = None
# datetime inherits from date. Strange, so we cant use this. See:
# https://github.com/python/typeshed/issues/4802
if isinstance(datetime_obj, datetime_mod.date) and not isinstance(datetime_obj, datetime_cls):
# Coerce a date to datetime.datetime
datetime_obj = datetime_cls.combine(datetime_obj, datetime_cls.min.time())
if datetime_obj is None or datetime_obj.tzinfo is None:
# In either case, we need to construct a timezone object
tzinfo = _timezone_coerce(default_timezone,
allow_dateutil=allow_dateutil)
# If datetime_obj is unspecified, create a timezone aware now object
if datetime_obj is None:
datetime_obj = datetime_cls.now(tzinfo)
else:
tzinfo = datetime_obj.tzinfo
# the arg to utcoffset is confusing
offset_seconds = tzinfo.utcoffset(datetime_obj).total_seconds()
# offset_seconds = tzinfo.utcoffset(None).total_seconds()
seconds_per_hour = 3600
tz_hour, tz_remain = divmod(offset_seconds, seconds_per_hour)
tz_hour = int(tz_hour)
if tz_remain:
seconds_per_minute = 60
tz_min = int(tz_remain // seconds_per_minute)
utc_offset = '{:+03d}{:02d}'.format(tz_hour, tz_min)
else:
utc_offset = str(tz_hour) if tz_hour < 0 else '+' + str(tz_hour)
if precision > 0:
fprecision = 6 # microseconds are padded to 6 decimals
# NOTE: The time.strftime and datetime.datetime.strftime methods
# seem to work differently. The former does not support %f
if _needs_workaround39103(): # nocover
local_stamp = datetime_obj.strftime('%04Y-%m-%dT%H%M%S.%f')
else: # nocover
local_stamp = datetime_obj.strftime('%Y-%m-%dT%H%M%S.%f')
ms_offset = len(local_stamp) - max(0, fprecision - precision)
local_stamp = local_stamp[:ms_offset]
else:
if _needs_workaround39103(): # nocover
local_stamp = datetime_obj.strftime('%04Y-%m-%dT%H%M%S')
else: # nocover
local_stamp = datetime_obj.strftime('%Y-%m-%dT%H%M%S')
stamp = local_stamp + utc_offset
return stamp
[docs]
def timeparse(stamp, default_timezone='local', allow_dateutil=True):
"""
Create a :class:`datetime.datetime` object from a string timestamp.
Without any extra dependencies this will parse the output of
:func:`ubelt.util_time.timestamp` into a datetime object. In the case
where the format differs, :func:`dateutil.parser.parse` will be used
if the :py:mod:`python-dateutil` package is installed.
Args:
stamp (str):
a string encoded timestamp
default_timezone (str):
if the input does not specify a timezone, assume this one.
Can be "local" or "utc".
allow_dateutil (bool):
if False we only use the minimal parsing and do not allow a
fallback to dateutil.
Returns:
datetime.datetime: the parsed datetime
Raises:
ValueError: if if parsing fails.
TODO:
- [ ] Allow defaulting to local or utm timezone (currently default is local)
Example:
>>> import ubelt as ub
>>> # Demonstrate a round trip of timestamp and timeparse
>>> stamp = ub.timestamp()
>>> datetime = ub.timeparse(stamp)
>>> assert ub.timestamp(datetime) == stamp
>>> # Round trip with precision
>>> stamp = ub.timestamp(precision=4)
>>> datetime = ub.timeparse(stamp)
>>> assert ub.timestamp(datetime, precision=4) == stamp
Example:
>>> import ubelt as ub
>>> # We should always be able to parse these
>>> good_stamps = [
>>> '2000-11-22',
>>> '2000-11-22T111111.44444Z',
>>> '2000-11-22T111111.44444+5',
>>> '2000-11-22T111111.44444-05',
>>> '2000-11-22T111111.44444-0500',
>>> '2000-11-22T111111.44444+0530',
>>> '2000-11-22T111111Z',
>>> '2000-11-22T111111+5',
>>> '2000-11-22T111111+0530',
>>> ]
>>> for stamp in good_stamps:
>>> print(f'----')
>>> print(f'stamp={stamp}')
>>> result = ub.timeparse(stamp, allow_dateutil=0)
>>> print(f'result={result!r}')
>>> recon = ub.timestamp(result)
>>> print(f'recon={recon}')
Example:
>>> import ubelt as ub
>>> # We require dateutil to handle these types of stamps
>>> import pytest
>>> conditional_stamps = [
>>> '2000-01-02T11:23:58.12345+5:30',
>>> '09/25/2003',
>>> 'Thu Sep 25 10:36:28 2003',
>>> ]
>>> for stamp in conditional_stamps:
>>> with pytest.raises(ValueError):
>>> result = ub.timeparse(stamp, allow_dateutil=False)
>>> have_dateutil = bool(ub.modname_to_modpath('dateutil'))
>>> if have_dateutil:
>>> for stamp in conditional_stamps:
>>> result = ub.timeparse(stamp)
Ignore:
import timerit
ti = timerit.Timerit(1000, 10)
ti.reset('non-standard dateutil.parse').call(lambda: dateutil.parser.parse('2000-01-02T112358.12345+5'))
ti.reset('non-standard ubelt.timeparse').call(lambda: ub.timeparse('2000-01-02T112358.12345+5'))
ti.reset('standard dateutil.parse').call(lambda: dateutil.parser.parse('2000-01-02T112358.12345+0500'))
ti.reset('standard dateutil.isoparse').call(lambda: dateutil.parser.isoparse('2000-01-02T112358.12345+0500'))
ti.reset('standard ubelt.timeparse').call(lambda: ub.timeparse('2000-01-02T112358.12345+0500'))
ti.reset('standard datetime_cls.strptime').call(lambda: datetime_cls.strptime('2000-01-02T112358.12345+0500', '%Y-%m-%dT%H%M%S.%f%z'))
"""
from datetime import datetime as datetime_cls
datetime_obj = None
# Check if we might have a minimal format
maybe_minimal = (
len(stamp) >= 17 and 'T' in stamp[10:]
)
fixed_stamp = stamp
if maybe_minimal:
# Note by default %z only handles the format `[+-]HHMM(SS(.ffffff))`
# this means we have to handle the case where `[+-]HH` is given.
# We do this by checking the offset and padding it to at least the
# `[+-]HHMM` format
date_part, timetz_part = stamp.split('T', 1)
if '-' in timetz_part[6:]:
time_part, sign, tz_part = timetz_part.partition('-')
elif '+' in timetz_part[6:]:
time_part, sign, tz_part = timetz_part.partition('+')
else:
# In 3.7 a Z suffix is handled correctly
# For 3.6 compatability, replace Z with +0000
if timetz_part.endswith('Z'):
time_part = timetz_part[:-1]
sign = '+'
tz_part = '0000'
else:
tz_part = None
if tz_part is not None:
if len(tz_part) == 1:
tz_part = '0{}00'.format(tz_part)
elif len(tz_part) == 2:
tz_part = '{}00'.format(tz_part)
fixed_stamp = ''.join([date_part, 'T', time_part, sign, tz_part])
if len(stamp) == 10:
try:
fmt = '%Y-%m-%d'
datetime_obj = datetime_cls.strptime(fixed_stamp, fmt)
except ValueError:
pass
if maybe_minimal and datetime_obj is None:
minimal_formats = [
'%Y-%m-%dT%H%M%S%z',
'%Y-%m-%dT%H%M%S',
'%Y-%m-%dT%H%M%S.%f%z',
'%Y-%m-%dT%H%M%S.%f',
]
for fmt in minimal_formats:
try:
datetime_obj = datetime_cls.strptime(fixed_stamp, fmt)
except ValueError:
pass
else:
break
if datetime_obj is None:
# Our minimal logic did not work, can we use dateutil?
if not allow_dateutil:
raise ValueError((
'Cannot parse timestamp. '
'Unknown string format: {!r}, and '
'dateutil is not allowed').format(stamp))
else:
try:
from dateutil.parser import parse as du_parse
except (ModuleNotFoundError, ImportError): # nocover
raise ValueError((
'Cannot parse timestamp. '
'Unknown string format: {!r}, and '
'dateutil is not installed').format(stamp)) from None
else: # nocover
datetime_obj = du_parse(stamp)
if datetime_obj.tzinfo is None:
# Timezone is unspecified, need to construct the default one.
tzinfo = _timezone_coerce(default_timezone,
allow_dateutil=allow_dateutil)
datetime_obj = datetime_obj.replace(tzinfo=tzinfo)
return datetime_obj
def _timezone_coerce(tzinfo, allow_dateutil=True):
"""
Ensure output it a timezone instance.
Example:
>>> import pytest
>>> from ubelt.util_time import * # NOQA
>>> from ubelt.util_time import _timezone_coerce
>>> results = []
>>> write = results.append
>>> tzinfo = _timezone_coerce('utc', allow_dateutil=0)
>>> print(f'tzinfo={tzinfo}')
tzinfo=UTC
Example:
>>> # xdoctest: +REQUIRES(module:dateutil)
>>> import pytest
>>> results = []
>>> write = results.append
>>> write(_timezone_coerce('utc'))
>>> write(_timezone_coerce('GMT'))
>>> write(_timezone_coerce('EST'))
>>> write(_timezone_coerce('HST'))
>>> import datetime as datetime_mod
>>> dt = datetime_mod.datetime.now()
>>> for tzinfo in results:
>>> print(f'tzoffset={tzinfo.utcoffset(dt).total_seconds()}')
tzoffset=0.0
tzoffset=0.0
tzoffset=-18000.0
tzoffset=-36000.0
Example:
>>> import pytest
>>> from ubelt.util_time import * # NOQA
>>> from ubelt.util_time import _timezone_coerce
>>> with pytest.raises(ValueError):
... _timezone_coerce('GMT', allow_dateutil=0)
>>> with pytest.raises(TypeError):
... _timezone_coerce(object(), allow_dateutil=0)
>>> # xdoctest: +REQUIRES(module:dateutil)
>>> with pytest.raises(KeyError):
... _timezone_coerce('NotATimezone', allow_dateutil=1)
Example:
>>> import pytest
>>> from ubelt.util_time import * # NOQA
>>> from ubelt.util_time import _timezone_coerce
>>> import time
>>> tz1 = _timezone_coerce('local', allow_dateutil=0)
>>> tz2 = _timezone_coerce('local', allow_dateutil=1)
>>> sec1 = tz1.utcoffset(None).total_seconds()
>>> sec2 = tz2.utcoffset(None).total_seconds()
>>> assert sec1 == sec2 == -time.timezone
"""
import datetime as datetime_mod
if isinstance(tzinfo, str):
if tzinfo == 'local':
# Note: the local timezone time.timezone is negated
_delta = datetime_mod.timedelta(seconds=-time.timezone)
out_tzinfo = datetime_mod.timezone(_delta)
elif tzinfo == 'utc':
out_tzinfo = datetime_mod.timezone.utc
else:
if allow_dateutil:
from dateutil import tz as tz_mod
out_tzinfo = tz_mod.gettz(tzinfo)
if out_tzinfo is None:
raise KeyError(tzinfo)
else:
raise ValueError((
'Unrecognized timezone: {!r}, and '
'dateutil is not allowed').format(tzinfo))
elif isinstance(tzinfo, datetime_mod.timezone):
out_tzinfo = tzinfo
else:
raise TypeError(
'Unknown type: {!r} for tzinfo'.format(
type(tzinfo))
)
return out_tzinfo
[docs]
class Timer(object):
"""
Measures time elapsed between a start and end point. Can be used as a
with-statement context manager, or using the tic/toc api.
Attributes:
elapsed (float): number of seconds measured by the context manager
tstart (float): time of last `tic` reported by `self._time()`
write (Callable): function used to write
flush (Callable): function used to flush
Example:
>>> # Create and start the timer using the context manager
>>> import math
>>> import ubelt as ub
>>> timer = ub.Timer('Timer test!', verbose=1)
>>> with timer:
>>> math.factorial(10)
>>> assert timer.elapsed > 0
tic('Timer test!')
...toc('Timer test!')=...
Example:
>>> # Create and start the timer using the tic/toc interface
>>> import ubelt as ub
>>> timer = ub.Timer().tic()
>>> elapsed1 = timer.toc()
>>> elapsed2 = timer.toc()
>>> elapsed3 = timer.toc()
>>> assert elapsed1 <= elapsed2
>>> assert elapsed2 <= elapsed3
Example:
>>> # In Python 3.7+ nanosecond resolution can be enabled
>>> import ubelt as ub
>>> import sys
>>> if sys.version_info[0:2] <= (3, 6):
>>> import pytest
>>> pytest.skip()
>>> # xdoctest +REQUIRES(Python>=3.7) # fixme directive doesnt exist yet
>>> timer = ub.Timer(label='perf_counter_ns', ns=True).tic()
>>> elapsed1 = timer.toc()
>>> elapsed2 = timer.toc()
>>> assert elapsed1 <= elapsed2
>>> assert isinstance(elapsed1, int)
"""
_default_time = time.perf_counter
def __init__(self, label='', verbose=None, newline=True, ns=False):
"""
Args:
label (str):
identifier for printing. Default to ''.
verbose (int | None):
verbosity flag, defaults to True if label is given, otherwise 0.
newline (bool):
if False and verbose, print tic and toc on the same line.
Defaults to True.
ns (bool):
if True, a nano-second resolution timer to avoid precision loss
caused by the float type. Defaults to False.
"""
if verbose is None:
verbose = bool(label)
self.label = label
self.verbose = verbose
self.newline = newline
self.tstart = -1
self.elapsed = -1
self.write = sys.stdout.write
self.flush = sys.stdout.flush
self.ns = ns
if self.ns:
self._time = time.perf_counter_ns
else:
self._time = self._default_time
[docs]
def tic(self):
"""
starts the timer
Returns:
Timer: self
"""
if self.verbose:
self.flush()
self.write('\ntic(%r)' % self.label)
if self.newline:
self.write('\n')
self.flush()
self.tstart = self._time()
return self
[docs]
def toc(self):
"""
stops the timer
Returns:
float | int: number of second or nanoseconds
"""
elapsed = self._time() - self.tstart
if self.verbose:
if self.ns:
self.write('...toc(%r)=%.4fs\n' % (self.label, elapsed / 1e9))
else:
self.write('...toc(%r)=%.4fs\n' % (self.label, elapsed))
self.flush()
return elapsed
def __enter__(self):
"""
Returns:
Timer: self
"""
self.tic()
return self
def __exit__(self, ex_type, ex_value, ex_traceback):
"""
Args:
ex_type (Type[BaseException] | None):
ex_value (BaseException | None):
ex_traceback (TracebackType | None):
Returns:
bool | None
"""
self.elapsed = self.toc()
if ex_traceback is not None:
return False
# class Time:
# """
# Stub for potential future time object
# """
# def __init__(cls, datetime):
# ...
# @classmethod
# def coerce(cls, data):
# ...
# @classmethod
# def parse(cls, stamp):
# ...
#
# class TimeDelta:
# ...