1# Copyright 2011 OpenStack Foundation.
2# All Rights Reserved.
3#
4#    Licensed under the Apache License, Version 2.0 (the "License"); you may
5#    not use this file except in compliance with the License. You may obtain
6#    a copy of the License at
7#
8#         http://www.apache.org/licenses/LICENSE-2.0
9#
10#    Unless required by applicable law or agreed to in writing, software
11#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13#    License for the specific language governing permissions and limitations
14#    under the License.
15
16"""
17Time related utilities and helper functions.
18"""
19
20import calendar
21import datetime
22import functools
23import logging
24import time
25
26from debtcollector import removals
27import iso8601
28import pytz
29
30from oslo_utils import reflection
31
32# ISO 8601 extended time format with microseconds
33_ISO8601_TIME_FORMAT_SUBSECOND = '%Y-%m-%dT%H:%M:%S.%f'
34_ISO8601_TIME_FORMAT = '%Y-%m-%dT%H:%M:%S'
35PERFECT_TIME_FORMAT = _ISO8601_TIME_FORMAT_SUBSECOND
36
37_MAX_DATETIME_SEC = 59
38
39now = time.monotonic
40
41
42@removals.remove(
43    message="use datetime.datetime.isoformat()",
44    version="1.6",
45    removal_version="?",
46    )
47def isotime(at=None, subsecond=False):
48    """Stringify time in ISO 8601 format.
49
50    .. deprecated:: 1.5.0
51       Use :func:`utcnow` and :func:`datetime.datetime.isoformat` instead.
52    """
53    if not at:
54        at = utcnow()
55    st = at.strftime(_ISO8601_TIME_FORMAT
56                     if not subsecond
57                     else _ISO8601_TIME_FORMAT_SUBSECOND)
58    tz = at.tzinfo.tzname(None) if at.tzinfo else 'UTC'
59    # Need to handle either iso8601 or python UTC format
60    st += ('Z' if tz in ('UTC', 'UTC+00:00') else tz)
61    return st
62
63
64def parse_isotime(timestr):
65    """Parse time from ISO 8601 format."""
66    try:
67        return iso8601.parse_date(timestr)
68    except iso8601.ParseError as e:
69        raise ValueError(str(e))
70    except TypeError as e:
71        raise ValueError(str(e))
72
73
74@removals.remove(
75    message="use either datetime.datetime.isoformat() "
76    "or datetime.datetime.strftime() instead",
77    version="1.6",
78    removal_version="?",
79    )
80def strtime(at=None, fmt=PERFECT_TIME_FORMAT):
81    """Returns formatted utcnow.
82
83    .. deprecated:: 1.5.0
84       Use :func:`utcnow()`, :func:`datetime.datetime.isoformat`
85       or :func:`datetime.strftime` instead:
86
87       * ``strtime()`` => ``utcnow().isoformat()``
88       * ``strtime(fmt=...)`` => ``utcnow().strftime(fmt)``
89       * ``strtime(at)`` => ``at.isoformat()``
90       * ``strtime(at, fmt)`` => ``at.strftime(fmt)``
91    """
92    if not at:
93        at = utcnow()
94    return at.strftime(fmt)
95
96
97def parse_strtime(timestr, fmt=PERFECT_TIME_FORMAT):
98    """Turn a formatted time back into a datetime."""
99    return datetime.datetime.strptime(timestr, fmt)
100
101
102def normalize_time(timestamp):
103    """Normalize time in arbitrary timezone to UTC naive object."""
104    offset = timestamp.utcoffset()
105    if offset is None:
106        return timestamp
107    return timestamp.replace(tzinfo=None) - offset
108
109
110def is_older_than(before, seconds):
111    """Return True if before is older than seconds.
112
113    .. versionchanged:: 1.7
114       Accept datetime string with timezone information.
115       Fix comparison with timezone aware datetime.
116    """
117    if isinstance(before, str):
118        before = parse_isotime(before)
119
120    before = normalize_time(before)
121
122    return utcnow() - before > datetime.timedelta(seconds=seconds)
123
124
125def is_newer_than(after, seconds):
126    """Return True if after is newer than seconds.
127
128    .. versionchanged:: 1.7
129       Accept datetime string with timezone information.
130       Fix comparison with timezone aware datetime.
131    """
132    if isinstance(after, str):
133        after = parse_isotime(after)
134
135    after = normalize_time(after)
136
137    return after - utcnow() > datetime.timedelta(seconds=seconds)
138
139
140def utcnow_ts(microsecond=False):
141    """Timestamp version of our utcnow function.
142
143    See :py:class:`oslo_utils.fixture.TimeFixture`.
144
145    .. versionchanged:: 1.3
146       Added optional *microsecond* parameter.
147    """
148    if utcnow.override_time is None:
149        # NOTE(kgriffs): This is several times faster
150        # than going through calendar.timegm(...)
151        timestamp = time.time()
152        if not microsecond:
153            timestamp = int(timestamp)
154        return timestamp
155
156    now = utcnow()
157    timestamp = calendar.timegm(now.timetuple())
158
159    if microsecond:
160        timestamp += float(now.microsecond) / 1000000
161
162    return timestamp
163
164
165def utcnow(with_timezone=False):
166    """Overridable version of utils.utcnow that can return a TZ-aware datetime.
167
168    See :py:class:`oslo_utils.fixture.TimeFixture`.
169
170    .. versionchanged:: 1.6
171       Added *with_timezone* parameter.
172    """
173    if utcnow.override_time:
174        try:
175            return utcnow.override_time.pop(0)
176        except AttributeError:
177            return utcnow.override_time
178    if with_timezone:
179        return datetime.datetime.now(tz=iso8601.iso8601.UTC)
180    return datetime.datetime.utcnow()
181
182
183@removals.remove(
184    message="use datetime.datetime.utcfromtimestamp().isoformat()",
185    version="1.6",
186    removal_version="?",
187    )
188def iso8601_from_timestamp(timestamp, microsecond=False):
189    """Returns an iso8601 formatted date from timestamp.
190
191    .. versionchanged:: 1.3
192       Added optional *microsecond* parameter.
193
194    .. deprecated:: 1.5.0
195       Use :func:`datetime.datetime.utcfromtimestamp` and
196       :func:`datetime.datetime.isoformat` instead.
197    """
198    return isotime(datetime.datetime.utcfromtimestamp(timestamp), microsecond)
199
200
201utcnow.override_time = None
202
203
204def set_time_override(override_time=None):
205    """Overrides utils.utcnow.
206
207    Make it return a constant time or a list thereof, one at a time.
208
209    See :py:class:`oslo_utils.fixture.TimeFixture`.
210
211    :param override_time: datetime instance or list thereof. If not
212                          given, defaults to the current UTC time.
213    """
214    utcnow.override_time = override_time or datetime.datetime.utcnow()
215
216
217def advance_time_delta(timedelta):
218    """Advance overridden time using a datetime.timedelta.
219
220    See :py:class:`oslo_utils.fixture.TimeFixture`.
221
222    """
223    assert utcnow.override_time is not None  # nosec
224    try:
225        for dt in utcnow.override_time:
226            dt += timedelta
227    except TypeError:
228        utcnow.override_time += timedelta
229
230
231def advance_time_seconds(seconds):
232    """Advance overridden time by seconds.
233
234    See :py:class:`oslo_utils.fixture.TimeFixture`.
235
236    """
237    advance_time_delta(datetime.timedelta(0, seconds))
238
239
240def clear_time_override():
241    """Remove the overridden time.
242
243    See :py:class:`oslo_utils.fixture.TimeFixture`.
244
245    """
246    utcnow.override_time = None
247
248
249def marshall_now(now=None):
250    """Make an rpc-safe datetime with microseconds.
251
252    .. versionchanged:: 1.6
253       Timezone information is now serialized instead of being stripped.
254    """
255    if not now:
256        now = utcnow()
257    d = dict(day=now.day, month=now.month, year=now.year, hour=now.hour,
258             minute=now.minute, second=now.second,
259             microsecond=now.microsecond)
260    if now.tzinfo:
261        # Need to handle either iso8601 or python UTC format
262        tzname = now.tzinfo.tzname(None)
263        d['tzname'] = 'UTC' if tzname == 'UTC+00:00' else tzname
264    return d
265
266
267def unmarshall_time(tyme):
268    """Unmarshall a datetime dict.
269
270    .. versionchanged:: 1.5
271       Drop leap second.
272
273    .. versionchanged:: 1.6
274       Added support for timezone information.
275    """
276
277    # NOTE(ihrachys): datetime does not support leap seconds,
278    # so the best thing we can do for now is dropping them
279    # http://bugs.python.org/issue23574
280    second = min(tyme['second'], _MAX_DATETIME_SEC)
281    dt = datetime.datetime(day=tyme['day'],
282                           month=tyme['month'],
283                           year=tyme['year'],
284                           hour=tyme['hour'],
285                           minute=tyme['minute'],
286                           second=second,
287                           microsecond=tyme['microsecond'])
288    tzname = tyme.get('tzname')
289    if tzname:
290        # Need to handle either iso8601 or python UTC format
291        tzname = 'UTC' if tzname == 'UTC+00:00' else tzname
292        tzinfo = pytz.timezone(tzname)
293        dt = tzinfo.localize(dt)
294    return dt
295
296
297def delta_seconds(before, after):
298    """Return the difference between two timing objects.
299
300    Compute the difference in seconds between two date, time, or
301    datetime objects (as a float, to microsecond resolution).
302    """
303    delta = after - before
304    return delta.total_seconds()
305
306
307def is_soon(dt, window):
308    """Determines if time is going to happen in the next window seconds.
309
310    :param dt: the time
311    :param window: minimum seconds to remain to consider the time not soon
312
313    :return: True if expiration is within the given duration
314    """
315    soon = (utcnow() + datetime.timedelta(seconds=window))
316    return normalize_time(dt) <= soon
317
318
319class Split(object):
320    """A *immutable* stopwatch split.
321
322    See: http://en.wikipedia.org/wiki/Stopwatch for what this is/represents.
323
324    .. versionadded:: 1.4
325    """
326
327    __slots__ = ['_elapsed', '_length']
328
329    def __init__(self, elapsed, length):
330        self._elapsed = elapsed
331        self._length = length
332
333    @property
334    def elapsed(self):
335        """Duration from stopwatch start."""
336        return self._elapsed
337
338    @property
339    def length(self):
340        """Seconds from last split (or the elapsed time if no prior split)."""
341        return self._length
342
343    def __repr__(self):
344        r = reflection.get_class_name(self, fully_qualified=False)
345        r += "(elapsed=%s, length=%s)" % (self._elapsed, self._length)
346        return r
347
348
349def time_it(logger, log_level=logging.DEBUG,
350            message="It took %(seconds).02f seconds to"
351                    " run function '%(func_name)s'",
352            enabled=True, min_duration=0.01):
353    """Decorator that will log how long its decorated function takes to run.
354
355    This does **not** output a log if the decorated function fails
356    with an exception.
357
358    :param logger: logger instance to use when logging elapsed time
359    :param log_level: logger logging level to use when logging elapsed time
360    :param message: customized message to use when logging elapsed time,
361                    the message may use automatically provide values
362                    ``%(seconds)`` and ``%(func_name)`` if it finds those
363                    values useful to record
364    :param enabled: whether to enable or disable this decorator (useful to
365                    decorate a function with this decorator, and then easily
366                    be able to switch that decoration off by some config or
367                    other value)
368    :param min_duration: argument that determines if logging is triggered
369                         or not, it is by default set to 0.01 seconds to avoid
370                         logging when durations and/or elapsed function call
371                         times are less than 0.01 seconds, to disable
372                         any ``min_duration`` checks this value should be set
373                         to less than or equal to zero or set to none
374    """
375
376    def decorator(func):
377        if not enabled:
378            return func
379
380        @functools.wraps(func)
381        def wrapper(*args, **kwargs):
382            with StopWatch() as w:
383                result = func(*args, **kwargs)
384            time_taken = w.elapsed()
385            if min_duration is None or time_taken >= min_duration:
386                logger.log(log_level, message,
387                           {'seconds': time_taken,
388                            'func_name': reflection.get_callable_name(func)})
389            return result
390
391        return wrapper
392
393    return decorator
394
395
396class StopWatch(object):
397    """A simple timer/stopwatch helper class.
398
399    Inspired by: apache-commons-lang java stopwatch.
400
401    Not thread-safe (when a single watch is mutated by multiple threads at
402    the same time). Thread-safe when used by a single thread (not shared) or
403    when operations are performed in a thread-safe manner on these objects by
404    wrapping those operations with locks.
405
406    It will use the `monotonic`_ pypi library to find an appropriate
407    monotonically increasing time providing function (which typically varies
408    depending on operating system and python version).
409
410    .. _monotonic: https://pypi.org/project/monotonic/
411
412    .. versionadded:: 1.4
413    """
414    _STARTED = 'STARTED'
415    _STOPPED = 'STOPPED'
416
417    def __init__(self, duration=None):
418        if duration is not None and duration < 0:
419            raise ValueError("Duration must be greater or equal to"
420                             " zero and not %s" % duration)
421        self._duration = duration
422        self._started_at = None
423        self._stopped_at = None
424        self._state = None
425        self._splits = ()
426
427    def start(self):
428        """Starts the watch (if not already started).
429
430        NOTE(harlowja): resets any splits previously captured (if any).
431        """
432        if self._state == self._STARTED:
433            return self
434        self._started_at = now()
435        self._stopped_at = None
436        self._state = self._STARTED
437        self._splits = ()
438        return self
439
440    @property
441    def splits(self):
442        """Accessor to all/any splits that have been captured."""
443        return self._splits
444
445    def split(self):
446        """Captures a split/elapsed since start time (and doesn't stop)."""
447        if self._state == self._STARTED:
448            elapsed = self.elapsed()
449            if self._splits:
450                length = self._delta_seconds(self._splits[-1].elapsed, elapsed)
451            else:
452                length = elapsed
453            self._splits = self._splits + (Split(elapsed, length),)
454            return self._splits[-1]
455        else:
456            raise RuntimeError("Can not create a split time of a stopwatch"
457                               " if it has not been started or if it has been"
458                               " stopped")
459
460    def restart(self):
461        """Restarts the watch from a started/stopped state."""
462        if self._state == self._STARTED:
463            self.stop()
464        self.start()
465        return self
466
467    @staticmethod
468    def _delta_seconds(earlier, later):
469        # Uses max to avoid the delta/time going backwards (and thus negative).
470        return max(0.0, later - earlier)
471
472    def elapsed(self, maximum=None):
473        """Returns how many seconds have elapsed."""
474        if self._state not in (self._STARTED, self._STOPPED):
475            raise RuntimeError("Can not get the elapsed time of a stopwatch"
476                               " if it has not been started/stopped")
477        if self._state == self._STOPPED:
478            elapsed = self._delta_seconds(self._started_at, self._stopped_at)
479        else:
480            elapsed = self._delta_seconds(self._started_at, now())
481        if maximum is not None and elapsed > maximum:
482            elapsed = max(0.0, maximum)
483        return elapsed
484
485    def __enter__(self):
486        """Starts the watch."""
487        self.start()
488        return self
489
490    def __exit__(self, type, value, traceback):
491        """Stops the watch (ignoring errors if stop fails)."""
492        try:
493            self.stop()
494        except RuntimeError:  # nosec: errors are meant to be ignored
495            pass
496
497    def leftover(self, return_none=False):
498        """Returns how many seconds are left until the watch expires.
499
500        :param return_none: when ``True`` instead of raising a ``RuntimeError``
501                            when no duration has been set this call will
502                            return ``None`` instead.
503        :type return_none: boolean
504        """
505        if self._state != self._STARTED:
506            raise RuntimeError("Can not get the leftover time of a stopwatch"
507                               " that has not been started")
508        if self._duration is None:
509            if not return_none:
510                raise RuntimeError("Can not get the leftover time of a watch"
511                                   " that has no duration")
512            return None
513        return max(0.0, self._duration - self.elapsed())
514
515    def expired(self):
516        """Returns if the watch has expired (ie, duration provided elapsed)."""
517        if self._state not in (self._STARTED, self._STOPPED):
518            raise RuntimeError("Can not check if a stopwatch has expired"
519                               " if it has not been started/stopped")
520        if self._duration is None:
521            return False
522        return self.elapsed() > self._duration
523
524    def has_started(self):
525        """Returns True if the watch is in a started state."""
526        return self._state == self._STARTED
527
528    def has_stopped(self):
529        """Returns True if the watch is in a stopped state."""
530        return self._state == self._STOPPED
531
532    def resume(self):
533        """Resumes the watch from a stopped state."""
534        if self._state == self._STOPPED:
535            self._state = self._STARTED
536            return self
537        else:
538            raise RuntimeError("Can not resume a stopwatch that has not been"
539                               " stopped")
540
541    def stop(self):
542        """Stops the watch."""
543        if self._state == self._STOPPED:
544            return self
545        if self._state != self._STARTED:
546            raise RuntimeError("Can not stop a stopwatch that has not been"
547                               " started")
548        self._stopped_at = now()
549        self._state = self._STOPPED
550        return self
551