#!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API # Copyright (C) 2015-2024 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser Public License for more details. # # 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 asyncio import calendar import datetime as dtm import logging import platform import time import pytest from telegram.ext import ApplicationBuilder, CallbackContext, ContextTypes, Defaults, Job, JobQueue from tests.auxil.envvars import GITHUB_ACTION, TEST_WITH_OPT_DEPS from tests.auxil.pytest_classes import make_bot from tests.auxil.slots import mro_slots if TEST_WITH_OPT_DEPS: import pytz UTC = pytz.utc else: import datetime UTC = datetime.timezone.utc class CustomContext(CallbackContext): pass @pytest.fixture async def job_queue(app): jq = JobQueue() jq.set_application(app) await jq.start() yield jq await jq.stop() @pytest.mark.skipif( TEST_WITH_OPT_DEPS, reason="Only relevant if the optional dependency is not installed" ) class TestNoJobQueue: def test_init_job_queue(self): with pytest.raises(RuntimeError, match=r"python-telegram-bot\[job-queue\]"): JobQueue() def test_init_job(self): with pytest.raises(RuntimeError, match=r"python-telegram-bot\[job-queue\]"): Job(None) @pytest.mark.skipif( not TEST_WITH_OPT_DEPS, reason="Only relevant if the optional dependency is installed" ) @pytest.mark.skipif( bool(GITHUB_ACTION and platform.system() in ["Windows", "Darwin"]), reason="On Windows & MacOS precise timings are not accurate.", ) @pytest.mark.flaky(10, 1) # Timings aren't quite perfect class TestJobQueue: result = 0 job_time = 0 received_error = None async def test_repr(self, app): jq = JobQueue() jq.set_application(app) assert repr(jq) == f"JobQueue[application={app!r}]" when = dtm.datetime.utcnow() + dtm.timedelta(days=1) callback = self.job_run_once job = jq.run_once(callback, when, name="name2") assert repr(job) == ( f"Job[id={job.job.id}, name={job.name}, callback=job_run_once, " f"trigger=date[" f"{when.strftime('%Y-%m-%d %H:%M:%S UTC')}" f"]]" ) @pytest.fixture(autouse=True) def _reset(self): self.result = 0 self.job_time = 0 self.received_error = None def test_scheduler_configuration(self, job_queue, timezone, bot): # Unfortunately, we can't really test the executor setting explicitly without relying # on protected attributes. However, this should be tested enough implicitly via all the # other tests in here assert job_queue.scheduler_configuration["timezone"] is UTC tz_app = ApplicationBuilder().defaults(Defaults(tzinfo=timezone)).token(bot.token).build() assert tz_app.job_queue.scheduler_configuration["timezone"] is timezone async def job_run_once(self, context): if ( isinstance(context, CallbackContext) and isinstance(context.job, Job) and isinstance(context.update_queue, asyncio.Queue) and context.job.data is None and context.chat_data is None and context.user_data is None and isinstance(context.bot_data, dict) ): self.result += 1 async def job_with_exception(self, context): raise Exception("Test Error") async def job_remove_self(self, context): self.result += 1 context.job.schedule_removal() async def job_run_once_with_data(self, context): self.result += context.job.data async def job_datetime_tests(self, context): self.job_time = time.time() async def error_handler_context(self, update, context): self.received_error = ( str(context.error), context.job, context.user_data, context.chat_data, ) async def error_handler_raise_error(self, *args): raise Exception("Failing bigly") def test_slot_behaviour(self, job_queue): for attr in job_queue.__slots__: assert getattr(job_queue, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(job_queue)) == len(set(mro_slots(job_queue))), "duplicate slot" def test_application_weakref(self, bot): jq = JobQueue() application = ApplicationBuilder().token(bot.token).job_queue(None).build() with pytest.raises(RuntimeError, match="No application was set"): jq.application jq.set_application(application) assert jq.application is application del application with pytest.raises(RuntimeError, match="no longer alive"): jq.application async def test_run_once(self, job_queue): job_queue.run_once(self.job_run_once, 0.1) await asyncio.sleep(0.2) assert self.result == 1 async def test_run_once_timezone(self, job_queue, timezone): """Test the correct handling of aware datetimes""" # 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.now(timezone) job_queue.run_once(self.job_run_once, when) await asyncio.sleep(0.1) assert self.result == 1 async def test_job_with_data(self, job_queue): job_queue.run_once(self.job_run_once_with_data, 0.1, data=5) await asyncio.sleep(0.2) assert self.result == 5 async def test_run_repeating(self, job_queue): job_queue.run_repeating(self.job_run_once, 0.1) await asyncio.sleep(0.25) assert self.result == 2 async def test_run_repeating_first(self, job_queue): job_queue.run_repeating(self.job_run_once, 0.5, first=0.2) await asyncio.sleep(0.15) assert self.result == 0 await asyncio.sleep(0.1) assert self.result == 1 async def test_run_repeating_first_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.5, first=dtm.datetime.now(timezone) + dtm.timedelta(seconds=0.2) ) await asyncio.sleep(0.05) assert self.result == 0 await asyncio.sleep(0.25) assert self.result == 1 async def test_run_repeating_last(self, job_queue): job_queue.run_repeating(self.job_run_once, 0.25, last=0.4) await asyncio.sleep(0.3) assert self.result == 1 await asyncio.sleep(0.4) assert self.result == 1 async def test_run_repeating_last_timezone(self, job_queue, timezone): """Test correct scheduling of job when passing a timezone-aware datetime as ``last``""" job_queue.run_repeating( self.job_run_once, 0.25, last=dtm.datetime.now(timezone) + dtm.timedelta(seconds=0.4) ) await asyncio.sleep(0.3) assert self.result == 1 await asyncio.sleep(0.4) assert self.result == 1 async 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.5, first=1, last=0.5) async def test_run_repeating_timedelta(self, job_queue): job_queue.run_repeating(self.job_run_once, dtm.timedelta(seconds=0.1)) await asyncio.sleep(0.25) assert self.result == 2 async def test_run_custom(self, job_queue): job_queue.run_custom(self.job_run_once, {"trigger": "interval", "seconds": 0.2}) await asyncio.sleep(0.5) assert self.result == 2 async def test_multiple(self, job_queue): job_queue.run_once(self.job_run_once, 0.1) job_queue.run_once(self.job_run_once, 0.2) job_queue.run_repeating(self.job_run_once, 0.2) await asyncio.sleep(0.55) assert self.result == 4 async def test_disabled(self, job_queue): j1 = job_queue.run_once(self.job_run_once, 0.1) j2 = job_queue.run_repeating(self.job_run_once, 0.5) j1.enabled = False j2.enabled = False await asyncio.sleep(0.6) assert self.result == 0 j1.enabled = True await asyncio.sleep(0.6) assert self.result == 1 async def test_schedule_removal(self, job_queue): j1 = job_queue.run_once(self.job_run_once, 0.3) j2 = job_queue.run_repeating(self.job_run_once, 0.2) await asyncio.sleep(0.25) assert self.result == 1 j1.schedule_removal() j2.schedule_removal() await asyncio.sleep(0.4) assert self.result == 1 async def test_schedule_removal_from_within(self, job_queue): job_queue.run_repeating(self.job_remove_self, 0.1) await asyncio.sleep(0.5) assert self.result == 1 async def test_longer_first(self, job_queue): job_queue.run_once(self.job_run_once, 0.2) job_queue.run_once(self.job_run_once, 0.1) await asyncio.sleep(0.15) assert self.result == 1 async def test_error(self, job_queue): job_queue.run_repeating(self.job_with_exception, 0.1) job_queue.run_repeating(self.job_run_once, 0.2) await asyncio.sleep(0.3) assert self.result == 1 async def test_in_application(self, bot_info): app = ApplicationBuilder().bot(make_bot(bot_info)).build() async with app: assert not app.job_queue.scheduler.running await app.start() assert app.job_queue.scheduler.running app.job_queue.run_repeating(self.job_run_once, 0.2) await asyncio.sleep(0.3) assert self.result == 1 await app.stop() assert not app.job_queue.scheduler.running await asyncio.sleep(1) assert self.result == 1 async def test_time_unit_int(self, job_queue): # Testing seconds in int delta = 0.5 expected_time = time.time() + delta job_queue.run_once(self.job_datetime_tests, delta) await asyncio.sleep(0.6) assert pytest.approx(self.job_time) == expected_time async 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 = dtm.timedelta(seconds=0.5) expected_time = time.time() + interval.total_seconds() job_queue.run_once(self.job_datetime_tests, interval) await asyncio.sleep(0.6) assert pytest.approx(self.job_time) == expected_time async def test_time_unit_dt_datetime(self, job_queue): # Testing running at a specific datetime delta, now = dtm.timedelta(seconds=0.5), dtm.datetime.now(UTC) when = now + delta expected_time = when.timestamp() job_queue.run_once(self.job_datetime_tests, when) await asyncio.sleep(0.6) assert self.job_time == pytest.approx(expected_time) async def test_time_unit_dt_time_today(self, job_queue): # Testing running at a specific time today delta, now = 0.5, dtm.datetime.now(UTC) expected_time = now + dtm.timedelta(seconds=delta) when = expected_time.time() expected_time = expected_time.timestamp() job_queue.run_once(self.job_datetime_tests, when) await asyncio.sleep(0.6) assert self.job_time == pytest.approx(expected_time) async 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 job's next scheduled execution time has been calculated correctly delta, now = -2, dtm.datetime.now(UTC) when = (now + dtm.timedelta(seconds=delta)).time() expected_time = (now + dtm.timedelta(seconds=delta, days=1)).timestamp() job_queue.run_once(self.job_datetime_tests, when) scheduled_time = job_queue.jobs()[0].next_t.timestamp() assert scheduled_time == pytest.approx(expected_time) async def test_run_daily(self, job_queue): delta, now = 1, dtm.datetime.now(UTC) time_of_day = (now + dtm.timedelta(seconds=delta)).time() expected_reschedule_time = (now + dtm.timedelta(seconds=delta, days=1)).timestamp() job_queue.run_daily(self.job_run_once, time_of_day) await asyncio.sleep(delta + 0.1) assert self.result == 1 scheduled_time = job_queue.jobs()[0].next_t.timestamp() assert scheduled_time == pytest.approx(expected_reschedule_time) @pytest.mark.parametrize("weekday", [0, 1, 2, 3, 4, 5, 6]) async def test_run_daily_days_of_week(self, job_queue, weekday): delta, now = 1, dtm.datetime.now(UTC) time_of_day = (now + dtm.timedelta(seconds=delta)).time() # offset in days until next weekday offset = (weekday + 6 - now.weekday()) % 7 offset = offset if offset > 0 else 7 expected_reschedule_time = (now + dtm.timedelta(seconds=delta, days=offset)).timestamp() job_queue.run_daily(self.job_run_once, time_of_day, days=[weekday]) await asyncio.sleep(delta + 0.1) scheduled_time = job_queue.jobs()[0].next_t.timestamp() assert scheduled_time == pytest.approx(expected_reschedule_time) async def test_run_monthly(self, job_queue, timezone): delta, now = 1, dtm.datetime.now(timezone) expected_reschedule_time = now + dtm.timedelta(seconds=delta) time_of_day = expected_reschedule_time.time().replace(tzinfo=timezone) day = now.day this_months_days = calendar.monthrange(now.year, now.month)[1] if now.month == 12: next_months_days = calendar.monthrange(now.year + 1, 1)[1] else: next_months_days = calendar.monthrange(now.year, now.month + 1)[1] expected_reschedule_time += dtm.timedelta(this_months_days) if day > next_months_days: expected_reschedule_time += dtm.timedelta(next_months_days) expected_reschedule_time = timezone.normalize(expected_reschedule_time) # 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) await asyncio.sleep(delta + 0.1) assert self.result == 1 scheduled_time = job_queue.jobs()[0].next_t.timestamp() assert scheduled_time == pytest.approx(expected_reschedule_time, rel=1e-3) async def test_run_monthly_non_strict_day(self, job_queue, timezone): delta, now = 1, dtm.datetime.now(timezone) expected_reschedule_time = now + dtm.timedelta(seconds=delta) time_of_day = expected_reschedule_time.time().replace(tzinfo=timezone) expected_reschedule_time += dtm.timedelta( calendar.monthrange(now.year, now.month)[1] ) - dtm.timedelta(days=now.day) # Adjust the hour for the special case that between now & end of month a DST switch happens expected_reschedule_time = timezone.normalize(expected_reschedule_time) 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, -1) scheduled_time = job_queue.jobs()[0].next_t.timestamp() assert scheduled_time == pytest.approx(expected_reschedule_time) async def test_default_tzinfo(self, 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 app = ApplicationBuilder().bot(tz_bot).build() jq = app.job_queue await jq.start() when = dtm.datetime.now(tz_bot.defaults.tzinfo) + dtm.timedelta(seconds=0.1) jq.run_once(self.job_run_once, when.time()) await asyncio.sleep(0.15) assert self.result == 1 await jq.stop() async def test_get_jobs(self, job_queue): callback = self.job_run_once job1 = job_queue.run_once(callback, 10, name="name1") await asyncio.sleep(0.03) # To stablize tests on windows job2 = job_queue.run_once(callback, 10, name="name1") await asyncio.sleep(0.03) job3 = job_queue.run_once(callback, 10, name="name2") await asyncio.sleep(0.03) assert job_queue.jobs() == (job1, job2, job3) assert job_queue.get_jobs_by_name("name1") == (job1, job2) assert job_queue.get_jobs_by_name("name2") == (job3,) async def test_job_run(self, app): job = app.job_queue.run_repeating(self.job_run_once, 0.02) await asyncio.sleep(0.05) # the job queue has not started yet assert self.result == 0 # so the job will not run await job.run(app) # but this will force it to run assert self.result == 1 async def test_enable_disable_job(self, job_queue): job = job_queue.run_repeating(self.job_run_once, 0.2) await asyncio.sleep(0.5) assert self.result == 2 job.enabled = False assert not job.enabled await asyncio.sleep(0.5) assert self.result == 2 job.enabled = True assert job.enabled await asyncio.sleep(0.5) assert self.result == 4 async def test_remove_job(self, job_queue): job = job_queue.run_repeating(self.job_run_once, 0.2) await asyncio.sleep(0.5) assert self.result == 2 assert not job.removed job.schedule_removal() assert job.removed await asyncio.sleep(0.5) assert self.result == 2 async def test_equality(self, job_queue): job = job_queue.run_repeating(self.job_run_once, 0.2) job_2 = job_queue.run_repeating(self.job_run_once, 0.2) job_3 = Job(self.job_run_once, 0.2) job_3._job = job.job assert job == job # noqa: PLR0124 assert job != job_queue assert job != job_2 assert job == job_3 assert hash(job) == hash(job) assert hash(job) != hash(job_queue) assert hash(job) != hash(job_2) assert hash(job) == hash(job_3) async def test_process_error_context(self, job_queue, app): app.add_error_handler(self.error_handler_context) job = job_queue.run_once(self.job_with_exception, 0.1, chat_id=42, user_id=43) await asyncio.sleep(0.15) assert self.received_error[0] == "Test Error" assert self.received_error[1] is job self.received_error = None await job.run(app) assert self.received_error[0] == "Test Error" assert self.received_error[1] is job assert self.received_error[2] is app.user_data[43] assert self.received_error[3] is app.chat_data[42] # Remove handler app.remove_error_handler(self.error_handler_context) self.received_error = None job = job_queue.run_once(self.job_with_exception, 0.1) await asyncio.sleep(0.15) assert self.received_error is None await job.run(app) assert self.received_error is None async def test_process_error_that_raises_errors(self, job_queue, app, caplog): app.add_error_handler(self.error_handler_raise_error) with caplog.at_level(logging.ERROR): job = job_queue.run_once(self.job_with_exception, 0.1) await asyncio.sleep(0.15) assert len(caplog.records) == 1 rec = caplog.records[-1] assert "An error was raised and an uncaught" in rec.getMessage() caplog.clear() with caplog.at_level(logging.ERROR): await job.run(app) assert len(caplog.records) == 1 rec = caplog.records[-1] assert "uncaught error was raised while handling" in rec.getMessage() caplog.clear() # Remove handler app.remove_error_handler(self.error_handler_raise_error) self.received_error = None with caplog.at_level(logging.ERROR): job = job_queue.run_once(self.job_with_exception, 0.1) await asyncio.sleep(0.15) assert len(caplog.records) == 1 rec = caplog.records[-1] assert "No error handlers are registered" in rec.getMessage() caplog.clear() with caplog.at_level(logging.ERROR): await job.run(app) assert len(caplog.records) == 1 rec = caplog.records[-1] assert rec.name == "telegram.ext.Application" assert "No error handlers are registered" in rec.getMessage() async def test_custom_context(self, bot): application = ( ApplicationBuilder() .token(bot.token) .context_types( ContextTypes( context=CustomContext, bot_data=int, user_data=float, chat_data=complex ) ) .build() ) job_queue = JobQueue() job_queue.set_application(application) async def callback(context): self.result = ( type(context), context.user_data, context.chat_data, type(context.bot_data), ) await job_queue.start() job_queue.run_once(callback, 0.1) await asyncio.sleep(0.15) assert self.result == (CustomContext, None, None, int) await job_queue.stop() async def test_attribute_error(self): job = Job(self.job_run_once) with pytest.raises( AttributeError, match="nor 'apscheduler.job.Job' has attribute 'error'" ): job.error @pytest.mark.parametrize("wait", [True, False]) async def test_wait_on_shut_down(self, job_queue, wait): ready_event = asyncio.Event() async def callback(_): await ready_event.wait() await job_queue.start() job_queue.run_once(callback, when=0.1) await asyncio.sleep(0.15) task = asyncio.create_task(job_queue.stop(wait=wait)) if wait: assert not task.done() ready_event.set() await asyncio.sleep(0.1) # no CancelledError (see source of JobQueue.stop for details) assert task.done() else: await asyncio.sleep(0.1) # unfortunately we will get a CancelledError here assert task.done() async def test_from_aps_job(self, job_queue): job = job_queue.run_once(self.job_run_once, 0.1, name="test_job") aps_job = job_queue.scheduler.get_job(job.id) tg_job = Job.from_aps_job(aps_job) assert tg_job is job assert tg_job.job is aps_job async def test_from_aps_job_missing_reference(self, job_queue): """We manually create a ext.Job and an aps job such that the former has no reference to the latter. Then we test that Job.from_aps_job() still sets the reference correctly. """ job = Job(self.job_run_once) aps_job = job_queue.scheduler.add_job( func=job_queue.job_callback, args=(job_queue, job), trigger="interval", seconds=2, id="test_id", ) assert job.job is None tg_job = Job.from_aps_job(aps_job) assert tg_job is job assert tg_job.job is aps_job async def test_run_job_exception_in_building_context( self, monkeypatch, job_queue, caplog, app ): # Makes sure that exceptions in building the context don't stop the application exception = ValueError("TestException") original_from_job = CallbackContext.from_job def raise_exception(job, application): if job.data == 1: raise exception return original_from_job(job, application) monkeypatch.setattr(CallbackContext, "from_job", raise_exception) received_jobs = set() async def job_callback(context): received_jobs.add(context.job.data) with caplog.at_level(logging.CRITICAL): job_queue.run_once(job_callback, 0.1, data=1) await asyncio.sleep(0.2) assert received_jobs == set() assert len(caplog.records) == 1 record = caplog.records[0] assert record.name == "telegram.ext.JobQueue" assert record.getMessage().startswith( "Error while building CallbackContext for job job_callback" ) assert record.levelno == logging.CRITICAL # Let's also check that no critical log is produced when the exception is not raised caplog.clear() with caplog.at_level(logging.CRITICAL): job_queue.run_once(job_callback, 0.1, data=2) await asyncio.sleep(0.2) assert received_jobs == {2} assert len(caplog.records) == 0