Repository: python-telegram-bot/python-telegram-bot

Issue:
Inconsistent use of UTC/local naïve datetimes
PR #1485 made `telegram.utils.helpers.from_timestamp` return a naïve `datetime.datetime` object in UTC, but this created an inconsistency with the rest of the repository in the handling of naïve `datetime.datetime`s, where it is assumed that these objects represent *local* time. In fact, there is a direct inconsistency between `from_timestamp` and `to_timestamp` helpers, since the latter still assumes that the `datetime` object is in local time.

See [#1497 (comment)](https://github.com/python-telegram-bot/python-telegram-bot/pull/1497#issuecomment-527256274) (point `3.`) for extended discussion.

I've already fixed the issue in #1497 (and added tests), but should I make a separate PR to isolate the change?


### Steps to reproduce

0. Go to a place with nonzero UTC offset. I live in CEST (GMT +2).

1. Construct a `telegram.Message` from a JSON data dictionary. This is a message I sent to a test bot of mine (stripped of the optional fields), as received from an HTTP request to Telegram:

    ```python
    import telegram as tg
    
    message_json = {
        'message_id': 462,
        'from': {
            'id': 41214001,
            'is_bot': False,
            'first_name': 'Paolo',
        },
        'chat': {
            'id': 41214001,
            'type': 'private'
        },
        'date': 1567596574,
    }
    
    original_timestamp = message_json['date']

    bot = tg.Bot('000:not_a_token')
    message = tg.Message.de_json(message_json, bot=bot)
    ```

2.  Re-form the `Message`'s JSON `dict` and extract the timestamp:
    ```python
    restored_timestamp = message.to_dict()['date']
    ```

3. Check that the timestamps are the same:
    ```python
    assert restored_timestamp == original_timestamp, \
        f"Timestamps differ by {restored_timestamp - original_timestamp} seconds"
    ```

### Expected behaviour
The timestamps should be the same.

### Actual behaviour
The timestamps differ by the local UTC offset (reversed):

```text
AssertionError: Timestamps differ by -7200 seconds
```

When the `Message` is constructed, the `datetime` object it gets represents UTC time, but when it gets re-converted to a timestamp, that same object is assumed to be in local time, which in my case is `7200` seconds ahead of UTC, so the resulting timestamp is `7200` seconds behind the original.

### Configuration
<details>

**Operating System:**  Windows


**Version of Python, python-telegram-bot & dependencies:**

``$ python -m telegram``

```
python-telegram-bot 12.0.0 (v12.0.0-0-gc84e21d)
certifi 2019.03.09
future 0.17.1
Python 3.7.3 (v3.7.3:ef4ec6ed12, Mar 25 2019, 22:22:05) [MSC v.1916 64 bit (AMD64)]
```

</details>



Discussion:

Resolving patch:
diff --git a/telegram/ext/jobqueue.py b/telegram/ext/jobqueue.py
--- a/telegram/ext/jobqueue.py
+++ b/telegram/ext/jobqueue.py
@@ -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):
@@ -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
@@ -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):
@@ -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):
@@ -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.
@@ -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):
@@ -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)
@@ -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)
 
@@ -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
@@ -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,
@@ -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
 
diff --git a/telegram/utils/helpers.py b/telegram/utils/helpers.py
--- a/telegram/utils/helpers.py
+++ b/telegram/utils/helpers.py
@@ -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
@@ -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
@@ -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):


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -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)
@@ -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)
diff --git a/tests/test_bot.py b/tests/test_bot.py
--- a/tests/test_bot.py
+++ b/tests/test_bot.py
@@ -19,7 +19,7 @@
 import os
 import sys
 import time
-from datetime import datetime
+import datetime as dtm
 from platform import python_implementation
 
 import pytest
@@ -108,7 +108,7 @@ def test_forward_message(self, bot, chat_id, message):
 
         assert message.text == message.text
         assert message.forward_from.username == message.from_user.username
-        assert isinstance(message.forward_date, datetime)
+        assert isinstance(message.forward_date, dtm.datetime)
 
     @flaky(3, 1)
     @pytest.mark.timeout(10)
@@ -615,7 +615,7 @@ def test_restrict_chat_member(self, bot, channel_id, chat_permissions):
             assert bot.restrict_chat_member(channel_id,
                                             95205500,
                                             chat_permissions,
-                                            until_date=datetime.now())
+                                            until_date=dtm.datetime.utcnow())
 
     @flaky(3, 1)
     @pytest.mark.timeout(10)
diff --git a/tests/test_chatmember.py b/tests/test_chatmember.py
--- a/tests/test_chatmember.py
+++ b/tests/test_chatmember.py
@@ -46,7 +46,7 @@ def test_de_json_required_args(self, bot, user):
         assert chat_member.status == self.status
 
     def test_de_json_all_args(self, bot, user):
-        time = datetime.datetime.now()
+        time = datetime.datetime.utcnow()
         json_dict = {'user': user.to_dict(),
                      'status': self.status,
                      'until_date': to_timestamp(time),
diff --git a/tests/test_filters.py b/tests/test_filters.py
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -27,7 +27,7 @@
 
 @pytest.fixture(scope='function')
 def update():
-    return Update(0, Message(0, User(0, 'Testuser', False), datetime.datetime.now(),
+    return Update(0, Message(0, User(0, 'Testuser', False), datetime.datetime.utcnow(),
                              Chat(0, 'private')))
 
 
@@ -138,7 +138,7 @@ def test_regex_complex_merges(self, update):
         assert isinstance(matches, list)
         assert len(matches) == 2
         assert all([type(res) == SRE_TYPE for res in matches])
-        update.message.forward_date = datetime.datetime.now()
+        update.message.forward_date = datetime.datetime.utcnow()
         result = filter(update)
         assert result
         assert isinstance(result, dict)
@@ -248,7 +248,7 @@ def test_regex_inverted(self, update):
         assert result
 
     def test_filters_reply(self, update):
-        another_message = Message(1, User(1, 'TestOther', False), datetime.datetime.now(),
+        another_message = Message(1, User(1, 'TestOther', False), datetime.datetime.utcnow(),
                                   Chat(0, 'private'))
         update.message.text = 'test'
         assert not Filters.reply(update)
@@ -475,7 +475,7 @@ def test_filters_status_update(self, update):
 
     def test_filters_forwarded(self, update):
         assert not Filters.forwarded(update)
-        update.message.forward_date = datetime.datetime.now()
+        update.message.forward_date = datetime.datetime.utcnow()
         assert Filters.forwarded(update)
 
     def test_filters_game(self, update):
@@ -616,7 +616,7 @@ def test_msg_in_filter(self, update):
 
     def test_and_filters(self, update):
         update.message.text = 'test'
-        update.message.forward_date = datetime.datetime.now()
+        update.message.forward_date = datetime.datetime.utcnow()
         assert (Filters.text & Filters.forwarded)(update)
         update.message.text = '/test'
         assert not (Filters.text & Filters.forwarded)(update)
@@ -625,7 +625,7 @@ def test_and_filters(self, update):
         assert not (Filters.text & Filters.forwarded)(update)
 
         update.message.text = 'test'
-        update.message.forward_date = datetime.datetime.now()
+        update.message.forward_date = datetime.datetime.utcnow()
         assert (Filters.text & Filters.forwarded & Filters.private)(update)
 
     def test_or_filters(self, update):
@@ -640,7 +640,7 @@ def test_or_filters(self, update):
 
     def test_and_or_filters(self, update):
         update.message.text = 'test'
-        update.message.forward_date = datetime.datetime.now()
+        update.message.forward_date = datetime.datetime.utcnow()
         assert (Filters.text & (Filters.status_update | Filters.forwarded))(update)
         update.message.forward_date = False
         assert not (Filters.text & (Filters.forwarded | Filters.status_update))(update)
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -16,6 +16,9 @@
 #
 # You should have received a copy of the GNU Lesser Public License
 # along with this program.  If not, see [http://www.gnu.org/licenses/].
+import time
+import datetime as dtm
+
 import pytest
 
 from telegram import Sticker
@@ -23,6 +26,17 @@
 from telegram import User
 from telegram.message import Message
 from telegram.utils import helpers
+from telegram.utils.helpers import _UtcOffsetTimezone, _datetime_to_float_timestamp
+
+
+# sample time specification values categorised into absolute / delta / time-of-day
+ABSOLUTE_TIME_SPECS = [dtm.datetime.now(tz=_UtcOffsetTimezone(dtm.timedelta(hours=-7))),
+                       dtm.datetime.utcnow()]
+DELTA_TIME_SPECS = [dtm.timedelta(hours=3, seconds=42, milliseconds=2), 30, 7.5]
+TIME_OF_DAY_TIME_SPECS = [dtm.time(12, 42, tzinfo=_UtcOffsetTimezone(dtm.timedelta(hours=-7))),
+                          dtm.time(12, 42)]
+RELATIVE_TIME_SPECS = DELTA_TIME_SPECS + TIME_OF_DAY_TIME_SPECS
+TIME_SPECS = ABSOLUTE_TIME_SPECS + RELATIVE_TIME_SPECS
 
 
 class TestHelpers(object):
@@ -32,6 +46,76 @@ def test_escape_markdown(self):
 
         assert expected_str == helpers.escape_markdown(test_str)
 
+    def test_to_float_timestamp_absolute_naive(self):
+        """Conversion from timezone-naive datetime to timestamp.
+        Naive datetimes should be assumed to be in UTC.
+        """
+        datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5)
+        assert helpers.to_float_timestamp(datetime) == 1573431976.1
+
+    def test_to_float_timestamp_absolute_aware(self, timezone):
+        """Conversion from timezone-aware datetime to timestamp"""
+        # we're parametrizing this with two different UTC offsets to exclude the possibility
+        # of an xpass when the test is run in a timezone with the same UTC offset
+        datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5, tzinfo=timezone)
+        assert (helpers.to_float_timestamp(datetime) ==
+                1573431976.1 - timezone.utcoffset(None).total_seconds())
+
+    def test_to_float_timestamp_absolute_no_reference(self):
+        """A reference timestamp is only relevant for relative time specifications"""
+        with pytest.raises(ValueError):
+            helpers.to_float_timestamp(dtm.datetime(2019, 11, 11), reference_timestamp=123)
+
+    @pytest.mark.parametrize('time_spec', DELTA_TIME_SPECS, ids=str)
+    def test_to_float_timestamp_delta(self, time_spec):
+        """Conversion from a 'delta' time specification to timestamp"""
+        reference_t = 0
+        delta = time_spec.total_seconds() if hasattr(time_spec, 'total_seconds') else time_spec
+        assert helpers.to_float_timestamp(time_spec, reference_t) == reference_t + delta
+
+    def test_to_float_timestamp_time_of_day(self):
+        """Conversion from time-of-day specification to timestamp"""
+        hour, hour_delta = 12, 1
+        ref_t = _datetime_to_float_timestamp(dtm.datetime(1970, 1, 1, hour=hour))
+
+        # test for a time of day that is still to come, and one in the past
+        time_future, time_past = dtm.time(hour + hour_delta), dtm.time(hour - hour_delta)
+        assert helpers.to_float_timestamp(time_future, ref_t) == ref_t + 60 * 60 * hour_delta
+        assert helpers.to_float_timestamp(time_past, ref_t) == ref_t + 60 * 60 * (24 - hour_delta)
+
+    def test_to_float_timestamp_time_of_day_timezone(self, timezone):
+        """Conversion from timezone-aware time-of-day specification to timestamp"""
+        # we're parametrizing this with two different UTC offsets to exclude the possibility
+        # of an xpass when the test is run in a timezone with the same UTC offset
+        utc_offset = timezone.utcoffset(None)
+        ref_datetime = dtm.datetime(1970, 1, 1, 12)
+        ref_t, time_of_day = _datetime_to_float_timestamp(ref_datetime), ref_datetime.time()
+
+        # first test that naive time is assumed to be utc:
+        assert helpers.to_float_timestamp(time_of_day, ref_t) == pytest.approx(ref_t)
+        # test that by setting the timezone the timestamp changes accordingly:
+        assert (helpers.to_float_timestamp(time_of_day.replace(tzinfo=timezone), ref_t) ==
+                pytest.approx(ref_t + (-utc_offset.total_seconds() % (24 * 60 * 60))))
+
+    @pytest.mark.parametrize('time_spec', RELATIVE_TIME_SPECS, ids=str)
+    def test_to_float_timestamp_default_reference(self, time_spec):
+        """The reference timestamp for relative time specifications should default to now"""
+        now = time.time()
+        assert (helpers.to_float_timestamp(time_spec)
+                == pytest.approx(helpers.to_float_timestamp(time_spec, reference_timestamp=now)))
+
+    @pytest.mark.parametrize('time_spec', TIME_SPECS, ids=str)
+    def test_to_timestamp(self, time_spec):
+        # delegate tests to `to_float_timestamp`
+        assert helpers.to_timestamp(time_spec) == int(helpers.to_float_timestamp(time_spec))
+
+    def test_to_timestamp_none(self):
+        # this 'convenience' behaviour has been left left for backwards compatibility
+        assert helpers.to_timestamp(None) is None
+
+    def test_from_timestamp(self):
+        assert helpers.from_timestamp(1573431976) == dtm.datetime(2019, 11, 11, 0, 26, 16)
+
     def test_create_deep_linked_url(self):
         username = 'JamesTheMock'
 
@@ -63,7 +147,6 @@ def test_create_deep_linked_url(self):
             helpers.create_deep_linked_url("abc", None)
 
     def test_effective_message_type(self):
-
         def build_test_message(**kwargs):
             config = dict(
                 message_id=1,
diff --git a/tests/test_jobqueue.py b/tests/test_jobqueue.py
--- a/tests/test_jobqueue.py
+++ b/tests/test_jobqueue.py
@@ -16,7 +16,7 @@
 #
 # You should have received a copy of the GNU Lesser Public License
 # along with this program.  If not, see [http://www.gnu.org/licenses/].
-import datetime
+import datetime as dtm
 import os
 import sys
 import time
@@ -28,6 +28,7 @@
 
 from telegram.ext import JobQueue, Updater, Job, CallbackContext
 from telegram.utils.deprecate import TelegramDeprecationWarning
+from telegram.utils.helpers import _UtcOffsetTimezone
 
 
 @pytest.fixture(scope='function')
@@ -83,6 +84,24 @@ def test_run_once(self, job_queue):
         sleep(0.02)
         assert self.result == 1
 
+    def test_run_once_timezone(self, job_queue, timezone):
+        """Test the correct handling of aware datetimes.
+        Set the target datetime to utcnow + x hours (naive) with the timezone set to utc + x hours,
+        which is equivalent to now.
+        """
+        # we're parametrizing this with two different UTC offsets to exclude the possibility
+        # of an xpass when the test is run in a timezone with the same UTC offset
+        when = (dtm.datetime.utcnow() + timezone.utcoffset(None)).replace(tzinfo=timezone)
+        job_queue.run_once(self.job_run_once, when)
+        sleep(0.001)
+        assert self.result == 1
+
+    def test_run_once_no_time_spec(self, job_queue):
+        # test that an appropiate exception is raised if a job is attempted to be scheduled
+        # without specifying a time
+        with pytest.raises(ValueError):
+            job_queue.run_once(self.job_run_once, when=None)
+
     def test_job_with_context(self, job_queue):
         job_queue.run_once(self.job_run_once_with_context, 0.01, context=5)
         sleep(0.02)
@@ -100,6 +119,13 @@ def test_run_repeating_first(self, job_queue):
         sleep(0.07)
         assert self.result == 1
 
+    def test_run_repeating_first_timezone(self, job_queue, timezone):
+        """Test correct scheduling of job when passing a timezone-aware datetime as ``first``"""
+        first = (dtm.datetime.utcnow() + timezone.utcoffset(None)).replace(tzinfo=timezone)
+        job_queue.run_repeating(self.job_run_once, 0.05, first=first)
+        sleep(0.001)
+        assert self.result == 1
+
     def test_multiple(self, job_queue):
         job_queue.run_once(self.job_run_once, 0.01)
         job_queue.run_once(self.job_run_once, 0.02)
@@ -183,7 +209,7 @@ def test_time_unit_int(self, job_queue):
     def test_time_unit_dt_timedelta(self, job_queue):
         # Testing seconds, minutes and hours as datetime.timedelta object
         # This is sufficient to test that it actually works.
-        interval = datetime.timedelta(seconds=0.05)
+        interval = dtm.timedelta(seconds=0.05)
         expected_time = time.time() + interval.total_seconds()
 
         job_queue.run_once(self.job_datetime_tests, interval)
@@ -192,43 +218,70 @@ def test_time_unit_dt_timedelta(self, job_queue):
 
     def test_time_unit_dt_datetime(self, job_queue):
         # Testing running at a specific datetime
-        delta = datetime.timedelta(seconds=0.05)
-        when = datetime.datetime.now() + delta
-        expected_time = time.time() + delta.total_seconds()
+        delta, now = dtm.timedelta(seconds=0.05), time.time()
+        when = dtm.datetime.utcfromtimestamp(now) + delta
+        expected_time = now + delta.total_seconds()
 
         job_queue.run_once(self.job_datetime_tests, when)
         sleep(0.06)
-        assert pytest.approx(self.job_time) == expected_time
+        assert self.job_time == pytest.approx(expected_time)
 
     def test_time_unit_dt_time_today(self, job_queue):
         # Testing running at a specific time today
-        delta = 0.05
-        when = (datetime.datetime.now() + datetime.timedelta(seconds=delta)).time()
-        expected_time = time.time() + delta
+        delta, now = 0.05, time.time()
+        when = (dtm.datetime.utcfromtimestamp(now) + dtm.timedelta(seconds=delta)).time()
+        expected_time = now + delta
 
         job_queue.run_once(self.job_datetime_tests, when)
         sleep(0.06)
-        assert pytest.approx(self.job_time) == expected_time
+        assert self.job_time == pytest.approx(expected_time)
 
     def test_time_unit_dt_time_tomorrow(self, job_queue):
         # Testing running at a specific time that has passed today. Since we can't wait a day, we
-        # test if the jobs next_t has been calculated correctly
-        delta = -2
-        when = (datetime.datetime.now() + datetime.timedelta(seconds=delta)).time()
-        expected_time = time.time() + delta + 60 * 60 * 24
+        # test if the job's next scheduled execution time has been calculated correctly
+        delta, now = -2, time.time()
+        when = (dtm.datetime.utcfromtimestamp(now) + dtm.timedelta(seconds=delta)).time()
+        expected_time = now + delta + 60 * 60 * 24
 
         job_queue.run_once(self.job_datetime_tests, when)
-        assert pytest.approx(job_queue._queue.get(False)[0]) == expected_time
+        assert job_queue._queue.get(False)[0] == pytest.approx(expected_time)
 
     def test_run_daily(self, job_queue):
-        delta = 0.5
-        time_of_day = (datetime.datetime.now() + datetime.timedelta(seconds=delta)).time()
-        expected_time = time.time() + 60 * 60 * 24 + delta
+        delta, now = 0.1, time.time()
+        time_of_day = (dtm.datetime.utcfromtimestamp(now) + dtm.timedelta(seconds=delta)).time()
+        expected_reschedule_time = now + delta + 24 * 60 * 60
 
         job_queue.run_daily(self.job_run_once, time_of_day)
-        sleep(0.6)
+        sleep(0.2)
+        assert self.result == 1
+        assert job_queue._queue.get(False)[0] == pytest.approx(expected_reschedule_time)
+
+    def test_run_daily_with_timezone(self, job_queue):
+        """test that the weekday is retrieved based on the job's timezone
+        We set a job to run at the current UTC time of day (plus a small delay buffer) with a
+        timezone that is---approximately (see below)---UTC +24, and set it to run on the weekday
+        after the current UTC weekday. The job should therefore be executed now (because in UTC+24,
+        the time of day is the same as the current weekday is the one after the current UTC
+        weekday).
+        """
+        now = time.time()
+        utcnow = dtm.datetime.utcfromtimestamp(now)
+        delta = 0.1
+
+        # must subtract one minute because the UTC offset has to be strictly less than 24h
+        # thus this test will xpass if run in the interval [00:00, 00:01) UTC time
+        # (because target time will be 23:59 UTC, so local and target weekday will be the same)
+        target_tzinfo = _UtcOffsetTimezone(dtm.timedelta(days=1, minutes=-1))
+        target_datetime = (utcnow + dtm.timedelta(days=1, minutes=-1, seconds=delta)).replace(
+            tzinfo=target_tzinfo)
+        target_time = target_datetime.timetz()
+        target_weekday = target_datetime.date().weekday()
+        expected_reschedule_time = now + delta + 24 * 60 * 60
+
+        job_queue.run_daily(self.job_run_once, time=target_time, days=(target_weekday,))
+        sleep(delta + 0.1)
         assert self.result == 1
-        assert pytest.approx(job_queue._queue.get(False)[0]) == expected_time
+        assert job_queue._queue.get(False)[0] == pytest.approx(expected_reschedule_time)
 
     def test_warnings(self, job_queue):
         j = Job(self.job_run_once, repeat=False)
diff --git a/tests/test_message.py b/tests/test_message.py
--- a/tests/test_message.py
+++ b/tests/test_message.py
@@ -35,12 +35,12 @@ def message(bot):
 @pytest.fixture(scope='function',
                 params=[
                     {'forward_from': User(99, 'forward_user', False),
-                     'forward_date': datetime.now()},
+                     'forward_date': datetime.utcnow()},
                     {'forward_from_chat': Chat(-23, 'channel'),
                      'forward_from_message_id': 101,
-                     'forward_date': datetime.now()},
+                     'forward_date': datetime.utcnow()},
                     {'reply_to_message': Message(50, None, None, None)},
-                    {'edit_date': datetime.now()},
+                    {'edit_date': datetime.utcnow()},
                     {'text': 'a text message',
                      'enitites': [MessageEntity('bold', 10, 4),
                                   MessageEntity('italic', 16, 7)]},
@@ -114,7 +114,7 @@ def message_params(bot, request):
 class TestMessage(object):
     id = 1
     from_user = User(2, 'testuser', False)
-    date = datetime.now()
+    date = datetime.utcnow()
     chat = Chat(3, 'private')
     test_entities = [{'length': 4, 'offset': 10, 'type': 'bold'},
                      {'length': 7, 'offset': 16, 'type': 'italic'},