Skip to content

Fix UTC/local inconsistencies for naive datetimes #1506

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Nov 15, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 42 additions & 35 deletions telegram/ext/jobqueue.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from telegram.ext.callbackcontext import CallbackContext
from telegram.utils.deprecate import TelegramDeprecationWarning
from telegram.utils.helpers import to_float_timestamp, _UTC


class Days(object):
Expand Down Expand Up @@ -78,30 +79,29 @@ def set_dispatcher(self, dispatcher):
"""
self._dispatcher = dispatcher

def _put(self, job, next_t=None, last_t=None):
if next_t is None:
next_t = job.interval
if next_t is None:
raise ValueError('next_t is None')

if isinstance(next_t, datetime.datetime):
next_t = (next_t - datetime.datetime.now()).total_seconds()

elif isinstance(next_t, datetime.time):
next_datetime = datetime.datetime.combine(datetime.date.today(), next_t)

if datetime.datetime.now().time() > next_t:
next_datetime += datetime.timedelta(days=1)

next_t = (next_datetime - datetime.datetime.now()).total_seconds()

elif isinstance(next_t, datetime.timedelta):
next_t = next_t.total_seconds()

next_t += last_t or time.time()
def _put(self, job, time_spec=None, previous_t=None):
"""
Enqueues the job, scheduling its next run at the correct time.

self.logger.debug('Putting job %s with t=%f', job.name, next_t)
Args:
job (telegram.ext.Job): job to enqueue
time_spec (optional):
Specification of the time for which the job should be scheduled. The precise
semantics of this parameter depend on its type (see
:func:`telegram.ext.JobQueue.run_repeating` for details).
Defaults to now + ``job.interval``.
previous_t (optional):
Time at which the job last ran (``None`` if it hasn't run yet).

"""
# get time at which to run:
time_spec = time_spec or job.interval
if time_spec is None:
raise ValueError("no time specification given for scheduling non-repeating job")
next_t = to_float_timestamp(time_spec, reference_timestamp=previous_t)

# enqueue:
self.logger.debug('Putting job %s with t=%f', job.name, time_spec)
self._queue.put((next_t, job))

# Wake up the loop if this job should be executed next
Expand Down Expand Up @@ -141,7 +141,7 @@ def run_once(self, callback, when, context=None, name=None):

"""
job = Job(callback, repeat=False, context=context, name=name, job_queue=self)
self._put(job, next_t=when)
self._put(job, time_spec=when)
return job

def run_repeating(self, callback, interval, first=None, context=None, name=None):
Expand Down Expand Up @@ -192,7 +192,7 @@ def run_repeating(self, callback, interval, first=None, context=None, name=None)
context=context,
name=name,
job_queue=self)
self._put(job, next_t=first)
self._put(job, time_spec=first)
return job

def run_daily(self, callback, time, days=Days.EVERY_DAY, context=None, name=None):
Expand All @@ -203,7 +203,8 @@ def run_daily(self, callback, time, days=Days.EVERY_DAY, context=None, name=None
job. It should take ``bot, job`` as parameters, where ``job`` is the
:class:`telegram.ext.Job` instance. It can be used to access its ``Job.context``
or change it to a repeating job.
time (:obj:`datetime.time`): Time of day at which the job should run.
time (:obj:`datetime.time`): Time of day at which the job should run. If the timezone
(``time.tzinfo``) is ``None``, UTC will be assumed.
days (Tuple[:obj:`int`], optional): Defines on which days of the week the job should
run. Defaults to ``EVERY_DAY``
context (:obj:`object`, optional): Additional data needed for the callback function.
Expand All @@ -225,10 +226,11 @@ def run_daily(self, callback, time, days=Days.EVERY_DAY, context=None, name=None
interval=datetime.timedelta(days=1),
repeat=True,
days=days,
tzinfo=time.tzinfo,
context=context,
name=name,
job_queue=self)
self._put(job, next_t=time)
self._put(job, time_spec=time)
return job

def _set_next_peek(self, t):
Expand Down Expand Up @@ -272,7 +274,7 @@ def tick(self):

if job.enabled:
try:
current_week_day = datetime.datetime.now().weekday()
current_week_day = datetime.datetime.now(job.tzinfo).date().weekday()
if any(day == current_week_day for day in job.days):
self.logger.debug('Running job %s', job.name)
job.run(self._dispatcher)
Expand All @@ -284,7 +286,7 @@ def tick(self):
self.logger.debug('Skipping disabled job %s', job.name)

if job.repeat and not job.removed:
self._put(job, last_t=t)
self._put(job, previous_t=t)
else:
self.logger.debug('Dropping non-repeating or removed job %s', job.name)

Expand Down Expand Up @@ -358,10 +360,11 @@ class Job(object):
It should take ``bot, job`` as parameters, where ``job`` is the
:class:`telegram.ext.Job` instance. It can be used to access it's :attr:`context`
or change it to a repeating job.
interval (:obj:`int` | :obj:`float` | :obj:`datetime.timedelta`, optional): The interval in
which the job will run. If it is an :obj:`int` or a :obj:`float`, it will be
interpreted as seconds. If you don't set this value, you must set :attr:`repeat` to
``False`` and specify :attr:`next_t` when you put the job into the job queue.
interval (:obj:`int` | :obj:`float` | :obj:`datetime.timedelta`, optional): The time
interval between executions of the job. If it is an :obj:`int` or a :obj:`float`,
it will be interpreted as seconds. If you don't set this value, you must set
:attr:`repeat` to ``False`` and specify :attr:`time_spec` when you put the job into
the job queue.
repeat (:obj:`bool`, optional): If this job should be periodically execute its callback
function (``True``) or only once (``False``). Defaults to ``True``.
context (:obj:`object`, optional): Additional data needed for the callback function. Can be
Expand All @@ -371,7 +374,9 @@ class Job(object):
Defaults to ``Days.EVERY_DAY``
job_queue (:class:`telegram.ext.JobQueue`, optional): The ``JobQueue`` this job belongs to.
Only optional for backward compatibility with ``JobQueue.put()``.

tzinfo (:obj:`datetime.tzinfo`, optional): timezone associated to this job. Used when
checking the day of the week to determine whether a job should run (only relevant when
``days is not Days.EVERY_DAY``). Defaults to UTC.
"""

def __init__(self,
Expand All @@ -381,19 +386,21 @@ def __init__(self,
context=None,
days=Days.EVERY_DAY,
name=None,
job_queue=None):
job_queue=None,
tzinfo=_UTC):

self.callback = callback
self.context = context
self.name = name or callback.__name__

self._repeat = repeat
self._repeat = None
self._interval = None
self.interval = interval
self.repeat = repeat

self._days = None
self.days = days
self.tzinfo = tzinfo

self._job_queue = weakref.proxy(job_queue) if job_queue is not None else None

Expand Down
150 changes: 127 additions & 23 deletions telegram/utils/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains helper functions."""

import datetime as dtm # dtm = "DateTime Module"
import time

from collections import defaultdict
from numbers import Number

try:
import ujson as json
Expand All @@ -27,7 +32,6 @@

import re
import signal
from datetime import datetime

# From https://stackoverflow.com/questions/2549939/get-signal-names-from-numbers-in-python
_signames = {v: k
Expand All @@ -40,54 +44,154 @@ def get_signal_name(signum):
return _signames[signum]


# Not using future.backports.datetime here as datetime value might be an input from the user,
# making every isinstace() call more delicate. So we just use our own compat layer.
if hasattr(datetime, 'timestamp'):
def escape_markdown(text):
"""Helper function to escape telegram markup symbols."""
escape_chars = '\*_`\['
return re.sub(r'([%s])' % escape_chars, r'\\\1', text)


# -------- date/time related helpers --------
# TODO: add generic specification of UTC for naive datetimes to docs

if hasattr(dtm, 'timezone'):
# Python 3.3+
def _timestamp(dt_obj):
def _datetime_to_float_timestamp(dt_obj):
if dt_obj.tzinfo is None:
dt_obj = dt_obj.replace(tzinfo=_UTC)
return dt_obj.timestamp()

_UtcOffsetTimezone = dtm.timezone
_UTC = dtm.timezone.utc
else:
# Python < 3.3 (incl 2.7)
from time import mktime

def _timestamp(dt_obj):
return mktime(dt_obj.timetuple())
# hardcoded timezone class (`datetime.timezone` isn't available in py2)
class _UtcOffsetTimezone(dtm.tzinfo):
def __init__(self, offset):
self.offset = offset

def tzname(self, dt):
return 'UTC +{}'.format(self.offset)

def escape_markdown(text):
"""Helper function to escape telegram markup symbols."""
escape_chars = '\*_`\['
return re.sub(r'([%s])' % escape_chars, r'\\\1', text)
def utcoffset(self, dt):
return self.offset

def dst(self, dt):
return dtm.timedelta(0)

_UTC = _UtcOffsetTimezone(dtm.timedelta(0))
__EPOCH_DT = dtm.datetime.fromtimestamp(0, tz=_UTC)
__NAIVE_EPOCH_DT = __EPOCH_DT.replace(tzinfo=None)

# _datetime_to_float_timestamp
# Not using future.backports.datetime here as datetime value might be an input from the user,
# making every isinstace() call more delicate. So we just use our own compat layer.
def _datetime_to_float_timestamp(dt_obj):
epoch_dt = __EPOCH_DT if dt_obj.tzinfo is not None else __NAIVE_EPOCH_DT
return (dt_obj - epoch_dt).total_seconds()

def to_timestamp(dt_obj):
_datetime_to_float_timestamp.__doc__ = \
"""Converts a datetime object to a float timestamp (with sub-second precision).
If the datetime object is timezone-naive, it is assumed to be in UTC."""


def to_float_timestamp(t, reference_timestamp=None):
"""
Converts a given time object to a float POSIX timestamp.
Used to convert different time specifications to a common format. The time object
can be relative (i.e. indicate a time increment, or a time of day) or absolute.
Any objects from the :module:`datetime` module that are timezone-naive will be assumed
to be in UTC.

``None`` s are left alone (i.e. ``to_float_timestamp(None)`` is ``None``).

Args:
dt_obj (:class:`datetime.datetime`):
t (int | float | datetime.timedelta | datetime.datetime | datetime.time):
Time value to convert. The semantics of this parameter will depend on its type:

* :obj:`int` or :obj:`float` will be interpreted as "seconds from ``reference_t``"
* :obj:`datetime.timedelta` will be interpreted as
"time increment from ``reference_t``"
* :obj:`datetime.datetime` will be interpreted as an absolute date/time value
* :obj:`datetime.time` will be interpreted as a specific time of day

reference_timestamp (float, optional): POSIX timestamp that indicates the absolute time
from which relative calculations are to be performed (e.g. when ``t`` is given as an
:obj:`int`, indicating "seconds from ``reference_t``"). Defaults to now (the time at
which this function is called).

If ``t`` is given as an absolute representation of date & time (i.e. a
``datetime.datetime`` object), ``reference_timestamp`` is not relevant and so its
value should be ``None``. If this is not the case, a ``ValueError`` will be raised.

Returns:
int:
(float | None) The return value depends on the type of argument ``t``. If ``t`` is
given as a time increment (i.e. as a obj:`int`, :obj:`float` or
:obj:`datetime.timedelta`), then the return value will be ``reference_t`` + ``t``.

Else if it is given as an absolute date/time value (i.e. a :obj:`datetime.datetime`
object), the equivalent value as a POSIX timestamp will be returned.

Finally, if it is a time of the day without date (i.e. a :obj:`datetime.time`
object), the return value is the nearest future occurrence of that time of day.

Raises:
TypeError: if `t`'s type is not one of those described above
"""
if not dt_obj:
return None

return int(_timestamp(dt_obj))
if reference_timestamp is None:
reference_timestamp = time.time()
elif isinstance(t, dtm.datetime):
raise ValueError('t is an (absolute) datetime while reference_timestamp is not None')

if isinstance(t, dtm.timedelta):
return reference_timestamp + t.total_seconds()
elif isinstance(t, Number):
return reference_timestamp + t
elif isinstance(t, dtm.time):
if t.tzinfo is not None:
reference_dt = dtm.datetime.fromtimestamp(reference_timestamp, tz=t.tzinfo)
else:
reference_dt = dtm.datetime.utcfromtimestamp(reference_timestamp) # assume UTC
reference_date = reference_dt.date()
reference_time = reference_dt.timetz()
if reference_time > t: # if the time of day has passed today, use tomorrow
reference_date += dtm.timedelta(days=1)
return _datetime_to_float_timestamp(dtm.datetime.combine(reference_date, t))
elif isinstance(t, dtm.datetime):
return _datetime_to_float_timestamp(t)

raise TypeError('Unable to convert {} object to timestamp'.format(type(t).__name__))


def to_timestamp(dt_obj, reference_timestamp=None):
"""
Wrapper over :func:`to_float_timestamp` which returns an integer (the float value truncated
down to the nearest integer).

See the documentation for :func:`to_float_timestamp` for more details.
"""
return int(to_float_timestamp(dt_obj, reference_timestamp)) if dt_obj is not None else None


def from_timestamp(unixtime):
"""
Converts an (integer) unix timestamp to a naive datetime object in UTC.
``None`` s are left alone (i.e. ``from_timestamp(None)`` is ``None``).

Args:
unixtime (int):
unixtime (int): integer POSIX timestamp

Returns:
datetime.datetime:

equivalent :obj:`datetime.datetime` value in naive UTC if ``timestamp`` is not
``None``; else ``None``
"""
if not unixtime:
if unixtime is None:
return None

return datetime.utcfromtimestamp(unixtime)
return dtm.datetime.utcfromtimestamp(unixtime)

# -------- end --------


def mention_html(user_id, name):
Expand Down
16 changes: 14 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@

import pytest

from telegram import Bot, Message, User, Chat, MessageEntity, Update, \
InlineQuery, CallbackQuery, ShippingQuery, PreCheckoutQuery, ChosenInlineResult
from telegram import (Bot, Message, User, Chat, MessageEntity, Update,
InlineQuery, CallbackQuery, ShippingQuery, PreCheckoutQuery,
ChosenInlineResult)
from telegram.ext import Dispatcher, JobQueue, Updater, BaseFilter
from telegram.utils.helpers import _UtcOffsetTimezone
from tests.bots import get_bot

TRAVIS = os.getenv('TRAVIS', False)
Expand Down Expand Up @@ -258,3 +260,13 @@ def get_false_update_fixture_decorator_params():
@pytest.fixture(scope='function', **get_false_update_fixture_decorator_params())
def false_update(request):
return Update(update_id=1, **request.param)


@pytest.fixture(params=[1, 2], ids=lambda h: 'UTC +{hour:0>2}:00'.format(hour=h))
def utc_offset(request):
return datetime.timedelta(hours=request.param)


@pytest.fixture()
def timezone(utc_offset):
return _UtcOffsetTimezone(utc_offset)
Loading