Defaults.tzinfo (#2042)

This commit is contained in:
Bibo-Joshi 2020-09-27 12:59:48 +02:00
parent b07e42ef33
commit 103b115486
12 changed files with 194 additions and 55 deletions

View file

@ -1766,6 +1766,8 @@ class Bot(TelegramObject):
until_date (:obj:`int` | :obj:`datetime.datetime`, optional): Date when the user will
be unbanned, unix time. If user is banned for more than 366 days or less than 30
seconds from the current time they are considered to be banned forever.
For timezone naive :obj:`datetime.datetime` objects, the default timezone of the
bot will be used.
api_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to be passed to the
Telegram API.
@ -1780,7 +1782,8 @@ class Bot(TelegramObject):
if until_date is not None:
if isinstance(until_date, datetime):
until_date = to_timestamp(until_date)
until_date = to_timestamp(until_date,
tzinfo=self.defaults.tzinfo if self.defaults else None)
data['until_date'] = until_date
result = self._post('kickChatMember', data, timeout=timeout, api_kwargs=api_kwargs)
@ -2866,6 +2869,8 @@ class Bot(TelegramObject):
will be lifted for the user, unix time. If user is restricted for more than 366
days or less than 30 seconds from the current time, they are considered to be
restricted forever.
For timezone naive :obj:`datetime.datetime` objects, the default timezone of the
bot will be used.
permissions (:class:`telegram.ChatPermissions`): A JSON-serialized object for new user
permissions.
timeout (:obj:`int` | :obj:`float`, optional): If this value is specified, use it as
@ -2884,7 +2889,8 @@ class Bot(TelegramObject):
if until_date is not None:
if isinstance(until_date, datetime):
until_date = to_timestamp(until_date)
until_date = to_timestamp(until_date,
tzinfo=self.defaults.tzinfo if self.defaults else None)
data['until_date'] = until_date
result = self._post('restrictChatMember', data, timeout=timeout, api_kwargs=api_kwargs)
@ -3630,6 +3636,8 @@ class Bot(TelegramObject):
timestamp) when the poll will be automatically closed. Must be at least 5 and no
more than 600 seconds in the future. Can't be used together with
:attr:`open_period`.
For timezone naive :obj:`datetime.datetime` objects, the default timezone of the
bot will be used.
is_closed (:obj:`bool`, optional): Pass :obj:`True`, if the poll needs to be
immediately closed. This can be useful for poll preview.
disable_notification (:obj:`bool`, optional): Sends the message silently. Users will
@ -3682,7 +3690,8 @@ class Bot(TelegramObject):
data['open_period'] = open_period
if close_date:
if isinstance(close_date, datetime):
close_date = to_timestamp(close_date)
close_date = to_timestamp(close_date,
tzinfo=self.defaults.tzinfo if self.defaults else None)
data['close_date'] = close_date
return self._message('sendPoll', data, timeout=timeout,

View file

@ -17,6 +17,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/].
"""This module contains the class Defaults, which allows to pass default values to Updater."""
import pytz
from telegram.utils.helpers import DEFAULT_NONE
@ -37,6 +38,8 @@ class Defaults:
quote (:obj:`bool`): Optional. If set to :obj:`True`, the reply is sent as an actual reply
to the message. If ``reply_to_message_id`` is passed in ``kwargs``, this parameter will
be ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats.
tzinfo (:obj:`tzinfo`): A timezone to be used for all date(time) objects appearing
throughout PTB.
Parameters:
parse_mode (:obj:`str`, optional): Send Markdown or HTML, if you want Telegram apps to show
@ -51,6 +54,10 @@ class Defaults:
quote (:obj:`bool`, optional): If set to :obj:`True`, the reply is sent as an actual reply
to the message. If ``reply_to_message_id`` is passed in ``kwargs``, this parameter will
be ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats.
tzinfo (:obj:`tzinfo`, optional): A timezone to be used for all date(time) inputs
appearing throughout PTB, i.e. if a timezone naive date(time) object is passed
somewhere, it will be assumed to be in ``tzinfo``. Must be a timezone provided by the
``pytz`` module. Defaults to UTC.
"""
def __init__(self,
parse_mode=None,
@ -59,12 +66,14 @@ class Defaults:
# Timeout needs special treatment, since the bot methods have two different
# default values for timeout (None and 20s)
timeout=DEFAULT_NONE,
quote=None):
quote=None,
tzinfo=pytz.utc):
self._parse_mode = parse_mode
self._disable_notification = disable_notification
self._disable_web_page_preview = disable_web_page_preview
self._timeout = timeout
self._quote = quote
self._tzinfo = tzinfo
@property
def parse_mode(self):
@ -111,12 +120,22 @@ class Defaults:
raise AttributeError("You can not assign a new value to defaults after because it would "
"not have any effect.")
@property
def tzinfo(self):
return self._tzinfo
@tzinfo.setter
def tzinfo(self, value):
raise AttributeError("You can not assign a new value to defaults after because it would "
"not have any effect.")
def __hash__(self):
return hash((self._parse_mode,
self._disable_notification,
self._disable_web_page_preview,
self._timeout,
self._quote))
self._quote,
self._tzinfo))
def __eq__(self, other):
if isinstance(other, Defaults):

View file

@ -89,8 +89,9 @@ class JobQueue:
return self._tz_now() + time
if isinstance(time, datetime.time):
dt = datetime.datetime.combine(
datetime.datetime.now(tz=time.tzinfo or self.scheduler.timezone).date(), time,
tzinfo=time.tzinfo or self.scheduler.timezone)
datetime.datetime.now(tz=time.tzinfo or self.scheduler.timezone).date(), time)
if dt.tzinfo is None:
dt = self.scheduler.timezone.localize(dt)
if shift_day and dt <= datetime.datetime.now(pytz.utc):
dt += datetime.timedelta(days=1)
return dt
@ -106,6 +107,9 @@ class JobQueue:
"""
self._dispatcher = dispatcher
if dispatcher.bot.defaults:
if dispatcher.bot.defaults:
self.scheduler.configure(timezone=dispatcher.bot.defaults.tzinfo or pytz.utc)
def run_once(self, callback, when, context=None, name=None, job_kwargs=None):
"""Creates a new ``Job`` that runs once and adds it to the queue.
@ -129,13 +133,11 @@ class JobQueue:
job should run.
* :obj:`datetime.datetime` will be interpreted as a specific date and time at
which the job should run. If the timezone (``datetime.tzinfo``) is :obj:`None`,
UTC will be assumed.
the default timezone of the bot will be used.
* :obj:`datetime.time` will be interpreted as a specific time of day at which the
job should run. This could be either today or, if the time has already passed,
tomorrow. If the timezone (``time.tzinfo``) is :obj:`None`, UTC will be assumed.
If ``when`` is :obj:`datetime.datetime` or :obj:`datetime.time` type
then ``when.tzinfo`` will define ``Job.tzinfo``. Otherwise UTC will be assumed.
tomorrow. If the timezone (``time.tzinfo``) is :obj:`None`, the
default timezone of the bot will be used.
context (:obj:`object`, optional): Additional data needed for the callback function.
Can be accessed through ``job.context`` in the callback. Defaults to :obj:`None`.
@ -193,13 +195,11 @@ class JobQueue:
job should run.
* :obj:`datetime.datetime` will be interpreted as a specific date and time at
which the job should run. If the timezone (``datetime.tzinfo``) is :obj:`None`,
UTC will be assumed.
the default timezone of the bot will be used.
* :obj:`datetime.time` will be interpreted as a specific time of day at which the
job should run. This could be either today or, if the time has already passed,
tomorrow. If the timezone (``time.tzinfo``) is :obj:`None`, UTC will be assumed.
If ``first`` is :obj:`datetime.datetime` or :obj:`datetime.time` type
then ``first.tzinfo`` will define ``Job.tzinfo``. Otherwise UTC will be assumed.
tomorrow. If the timezone (``time.tzinfo``) is :obj:`None`, the
default timezone of the bot will be used.
Defaults to ``interval``
last (:obj:`int` | :obj:`float` | :obj:`datetime.timedelta` | \
@ -208,7 +208,8 @@ class JobQueue:
depending on its type. See ``first`` for details.
If ``last`` is :obj:`datetime.datetime` or :obj:`datetime.time` type
and ``last.tzinfo`` is :obj:`None`, UTC will be assumed.
and ``last.tzinfo`` is :obj:`None`, the default timezone of the bot will be
assumed.
Defaults to :obj:`None`.
context (:obj:`object`, optional): Additional data needed for the callback function.
@ -268,8 +269,7 @@ class JobQueue:
``context.job`` is the :class:`telegram.ext.Job` instance. It can be used to access
its ``job.context`` or change it to a repeating job.
when (:obj:`datetime.time`): Time of day at which the job should run. If the timezone
(``when.tzinfo``) is :obj:`None`, UTC will be assumed. This will also implicitly
define ``Job.tzinfo``.
(``when.tzinfo``) is :obj:`None`, the default timezone of the bot will be used.
day (:obj:`int`): Defines the day of the month whereby the job would run. It should
be within the range of 1 and 31, inclusive.
context (:obj:`object`, optional): Additional data needed for the callback function.
@ -338,8 +338,7 @@ class JobQueue:
``context.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. If the timezone
(``time.tzinfo``) is :obj:`None`, UTC will be assumed.
``time.tzinfo`` will implicitly define ``Job.tzinfo``.
(``time.tzinfo``) is :obj:`None`, the default timezone of the bot will be used.
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.

View file

@ -26,6 +26,8 @@ from collections import defaultdict
from html import escape
from numbers import Number
import pytz
try:
import ujson as json
except ImportError:
@ -72,8 +74,6 @@ def escape_markdown(text, version=1, entity_type=None):
# -------- date/time related helpers --------
# TODO: add generic specification of UTC for naive datetimes to docs
def _datetime_to_float_timestamp(dt_obj):
"""
Converts a datetime object to a float timestamp (with sub-second precision).
@ -85,13 +85,13 @@ def _datetime_to_float_timestamp(dt_obj):
return dt_obj.timestamp()
def to_float_timestamp(t, reference_timestamp=None):
def to_float_timestamp(t, reference_timestamp=None, tzinfo=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 :class:`datetime` module that are timezone-naive will be assumed
to be in UTC.
to be in UTC, if ``bot`` is not passed or ``bot.defaults`` is :obj:`None`.
:obj:`None` s are left alone (i.e. ``to_float_timestamp(None)`` is :obj:`None`).
@ -113,6 +113,9 @@ def to_float_timestamp(t, reference_timestamp=None):
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 :obj:`None`. If this is not the case, a ``ValueError`` will be raised.
tzinfo (:obj:`datetime.tzinfo`, optional): If ``t`` is a naive object from the
:class:`datetime` module, it will be interpreted as this timezone. Defaults to
``pytz.utc``.
Returns:
(float | None) The return value depends on the type of argument ``t``. If ``t`` is
@ -138,33 +141,43 @@ def to_float_timestamp(t, reference_timestamp=None):
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
if tzinfo is None:
tzinfo = pytz.utc
if isinstance(t, dtm.time):
reference_dt = dtm.datetime.fromtimestamp(reference_timestamp, tz=t.tzinfo or tzinfo)
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))
aware_datetime = dtm.datetime.combine(reference_date, t)
if aware_datetime.tzinfo is None:
aware_datetime = tzinfo.localize(aware_datetime)
# if the time of day has passed today, use tomorrow
if reference_time > aware_datetime.timetz():
aware_datetime += dtm.timedelta(days=1)
return _datetime_to_float_timestamp(aware_datetime)
elif isinstance(t, dtm.datetime):
if t.tzinfo is None:
t = tzinfo.localize(t)
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):
def to_timestamp(dt_obj, reference_timestamp=None, tzinfo=pytz.utc):
"""
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
return (int(to_float_timestamp(dt_obj, reference_timestamp, tzinfo))
if dt_obj is not None else None)
def from_timestamp(unixtime, tzinfo=dtm.timezone.utc):
def from_timestamp(unixtime, tzinfo=pytz.utc):
"""
Converts an (integer) unix timestamp to a timezone aware datetime object.
:obj:`None`s are left alone (i.e. ``from_timestamp(None)`` is :obj:`None`).

View file

@ -69,6 +69,18 @@ def default_bot(request, bot_info):
return default_bot
@pytest.fixture(scope='function')
def tz_bot(timezone, bot_info):
defaults = Defaults(tzinfo=timezone)
default_bot = DEFAULT_BOTS.get(defaults)
if default_bot:
return default_bot
else:
default_bot = make_bot(bot_info, **{'defaults': defaults})
DEFAULT_BOTS[defaults] = default_bot
return default_bot
@pytest.fixture(scope='session')
def chat_id(bot_info):
return bot_info['chat_id']

View file

@ -29,7 +29,7 @@ from telegram import (Bot, Update, ChatAction, TelegramError, User, InlineKeyboa
InlineQueryResultDocument, Dice, MessageEntity, ParseMode)
from telegram.constants import MAX_INLINE_QUERY_RESULTS
from telegram.error import BadRequest, InvalidToken, NetworkError, RetryAfter
from telegram.utils.helpers import from_timestamp, escape_markdown
from telegram.utils.helpers import from_timestamp, escape_markdown, to_timestamp
from tests.conftest import expect_bad_request
BASE_TIME = time.time()
@ -272,6 +272,29 @@ class TestBot:
assert new_message.poll.id == message.poll.id
assert new_message.poll.is_closed
@flaky(5, 1)
@pytest.mark.timeout(10)
def test_send_close_date_default_tz(self, tz_bot, super_group_id):
question = 'Is this a test?'
answers = ['Yes', 'No', 'Maybe']
reply_markup = InlineKeyboardMarkup.from_button(
InlineKeyboardButton(text='text', callback_data='data'))
aware_close_date = dtm.datetime.now(tz=tz_bot.defaults.tzinfo) + dtm.timedelta(seconds=5)
close_date = aware_close_date.replace(tzinfo=None)
message = tz_bot.send_poll(chat_id=super_group_id, question=question, options=answers,
close_date=close_date, timeout=60)
assert message.poll.close_date == aware_close_date.replace(microsecond=0)
time.sleep(5.1)
new_message = tz_bot.edit_message_reply_markup(chat_id=super_group_id,
message_id=message.message_id,
reply_markup=reply_markup, timeout=60)
assert new_message.poll.id == message.poll.id
assert new_message.poll.is_closed
@flaky(3, 1)
@pytest.mark.timeout(10)
@pytest.mark.parametrize('default_bot', [{'parse_mode': 'Markdown'}], indirect=True)
@ -518,6 +541,22 @@ class TestBot:
assert bot.kick_chat_member(2, 32, until_date=until)
assert bot.kick_chat_member(2, 32, until_date=1577887200)
def test_kick_chat_member_default_tz(self, monkeypatch, tz_bot):
until = dtm.datetime(2020, 1, 11, 16, 13)
until_timestamp = to_timestamp(until, tzinfo=tz_bot.defaults.tzinfo)
def test(url, data, *args, **kwargs):
chat_id = data['chat_id'] == 2
user_id = data['user_id'] == 32
until_date = data.get('until_date', until_timestamp) == until_timestamp
return chat_id and user_id and until_date
monkeypatch.setattr(tz_bot.request, 'post', test)
assert tz_bot.kick_chat_member(2, 32)
assert tz_bot.kick_chat_member(2, 32, until_date=until)
assert tz_bot.kick_chat_member(2, 32, until_date=until_timestamp)
# TODO: Needs improvement.
def test_unban_chat_member(self, monkeypatch, bot):
def test(url, data, *args, **kwargs):
@ -951,6 +990,28 @@ class TestBot:
chat_permissions,
until_date=dtm.datetime.utcnow())
def test_restrict_chat_member_default_tz(self, monkeypatch, tz_bot, channel_id,
chat_permissions):
until = dtm.datetime(2020, 1, 11, 16, 13)
until_timestamp = to_timestamp(until, tzinfo=tz_bot.defaults.tzinfo)
def test(url, data, *args, **kwargs):
return data.get('until_date', until_timestamp) == until_timestamp
monkeypatch.setattr(tz_bot.request, 'post', test)
assert tz_bot.restrict_chat_member(channel_id,
95205500,
chat_permissions)
assert tz_bot.restrict_chat_member(channel_id,
95205500,
chat_permissions,
until_date=until)
assert tz_bot.restrict_chat_member(channel_id,
95205500,
chat_permissions,
until_date=until_timestamp)
@flaky(3, 1)
@pytest.mark.timeout(10)
def test_promote_chat_member(self, bot, channel_id):

View file

@ -37,6 +37,8 @@ class TestDefault:
defaults.timeout = True
with pytest.raises(AttributeError):
defaults.quote = True
with pytest.raises(AttributeError):
defaults.tzinfo = True
def test_equality(self):
a = Defaults(parse_mode='HTML', quote=True)

View file

@ -190,7 +190,7 @@ class TestFilters:
matches = result['matches']
assert isinstance(matches, list)
assert all([type(res) == SRE_TYPE for res in matches])
update.message.forward_date = False
update.message.forward_date = None
result = filter(update)
assert not result
update.message.text = 'test it out'
@ -926,7 +926,7 @@ class TestFilters:
update.message.text = 'test'
update.message.forward_date = datetime.datetime.utcnow()
assert (Filters.text & (Filters.status_update | Filters.forwarded))(update)
update.message.forward_date = False
update.message.forward_date = None
assert not (Filters.text & (Filters.forwarded | Filters.status_update))(update)
update.message.pinned_message = True
assert (Filters.text & (Filters.forwarded | Filters.status_update)(update))

View file

@ -25,6 +25,7 @@ from telegram import Sticker
from telegram import Update
from telegram import User
from telegram import MessageEntity
from telegram.ext import Defaults
from telegram.message import Message
from telegram.utils import helpers
from telegram.utils.helpers import _datetime_to_float_timestamp
@ -135,6 +136,10 @@ class TestHelpers:
assert (helpers.to_float_timestamp(time_spec)
== pytest.approx(helpers.to_float_timestamp(time_spec, reference_timestamp=now)))
def test_to_float_timestamp_error(self):
with pytest.raises(TypeError, match='Defaults'):
helpers.to_float_timestamp(Defaults())
@pytest.mark.parametrize('time_spec', TIME_SPECS, ids=str)
def test_to_timestamp(self, time_spec):
# delegate tests to `to_float_timestamp`
@ -144,6 +149,9 @@ class TestHelpers:
# this 'convenience' behaviour has been left left for backwards compatibility
assert helpers.to_timestamp(None) is None
def test_from_timestamp_none(self):
assert helpers.from_timestamp(None) is None
def test_from_timestamp_naive(self):
datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, tzinfo=None)
assert helpers.from_timestamp(1573431976, tzinfo=None) == datetime

View file

@ -121,7 +121,7 @@ class TestJobQueue:
sleep(0.07)
assert self.result == 1
def test_run_repeating_first_timezone(self, job_queue, timezone):
def test_run_repeating_last_timezone(self, job_queue, timezone):
"""Test correct scheduling of job when passing a timezone-aware datetime as ``first``"""
job_queue.run_repeating(self.job_run_once, 0.1,
first=dtm.datetime.now(timezone) + dtm.timedelta(seconds=0.05))
@ -135,15 +135,6 @@ class TestJobQueue:
sleep(0.1)
assert self.result == 1
def test_run_repeating_last_timezone(self, job_queue, timezone):
"""Test correct scheduling of job when passing a timezone-aware datetime as ``first``"""
job_queue.run_repeating(self.job_run_once, 0.05,
last=dtm.datetime.now(timezone) + dtm.timedelta(seconds=0.06))
sleep(0.1)
assert self.result == 1
sleep(0.1)
assert self.result == 1
def test_run_repeating_last_before_first(self, job_queue):
with pytest.raises(ValueError, match="'last' must not be before 'first'!"):
job_queue.run_repeating(self.job_run_once, 0.05, first=1, last=0.5)
@ -300,7 +291,11 @@ class TestJobQueue:
time_of_day = expected_reschedule_time.time().replace(tzinfo=timezone)
day = now.day
expected_reschedule_time += dtm.timedelta(calendar.monthrange(now.year, now.month)[1])
expected_reschedule_time = timezone.normalize(
expected_reschedule_time + dtm.timedelta(calendar.monthrange(now.year, now.month)[1]))
# Adjust the hour for the special case that between now and next month a DST switch happens
expected_reschedule_time += dtm.timedelta(
hours=time_of_day.hour - expected_reschedule_time.hour)
expected_reschedule_time = expected_reschedule_time.timestamp()
job_queue.run_monthly(self.job_run_once, time_of_day, day)
@ -326,6 +321,25 @@ class TestJobQueue:
scheduled_time = job_queue.jobs()[0].next_t.timestamp()
assert scheduled_time == pytest.approx(expected_reschedule_time)
def test_default_tzinfo(self, _dp, tz_bot):
# 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
jq = JobQueue()
original_bot = _dp.bot
_dp.bot = tz_bot
jq.set_dispatcher(_dp)
try:
jq.start()
when = dtm.datetime.now(tz_bot.defaults.tzinfo) + dtm.timedelta(seconds=0.0005)
jq.run_once(self.job_run_once, when.time())
sleep(0.001)
assert self.result == 1
jq.stop()
finally:
_dp.bot = original_bot
@pytest.mark.parametrize('use_context', [True, False])
def test_get_jobs(self, job_queue, use_context):
job_queue._dispatcher.use_context = use_context

View file

@ -169,13 +169,13 @@ class TestMessage:
MessageEntity(**e) for e in test_entities_v2
])
def test_all_posibilities_de_json_and_to_dict(self, bot, message_params):
def test_all_possibilities_de_json_and_to_dict(self, bot, message_params):
new = Message.de_json(message_params.to_dict(), bot)
assert new.to_dict() == message_params.to_dict()
def test_dict_approach(self, message):
assert message['date'] == message.date
assert message['text'] == message.text
assert message['chat_id'] == message.chat_id
assert message['no_key'] is None

View file

@ -20,6 +20,8 @@
import pytest
from datetime import datetime
from telegram import Poll, PollOption, PollAnswer, User, MessageEntity
from telegram.utils.helpers import to_timestamp
@ -154,7 +156,7 @@ class TestPoll:
open_period = 42
close_date = datetime.utcnow()
def test_de_json(self):
def test_de_json(self, bot):
json_dict = {
'id': self.id_,
'question': self.question,
@ -169,7 +171,7 @@ class TestPoll:
'open_period': self.open_period,
'close_date': to_timestamp(self.close_date)
}
poll = Poll.de_json(json_dict, None)
poll = Poll.de_json(json_dict, bot)
assert poll.id == self.id_
assert poll.question == self.question