Refactor calculate() to properly handle paused jobs and fix tests

This commit is contained in:
2025-02-21 21:11:50 +01:00
parent af6e88ba47
commit 2806e6044d
3 changed files with 42 additions and 27 deletions

View File

@ -6,12 +6,13 @@ from typing import TYPE_CHECKING
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from loguru import logger
if TYPE_CHECKING: if TYPE_CHECKING:
from apscheduler.job import Job from apscheduler.job import Job
def calculate(job: Job) -> str | None: def calculate(job: Job) -> str:
"""Calculate the time left for a job. """Calculate the time left for a job.
Args: Args:
@ -22,11 +23,19 @@ def calculate(job: Job) -> str | None:
""" """
trigger_time = None trigger_time = None
if isinstance(job.trigger, DateTrigger | IntervalTrigger): if isinstance(job.trigger, DateTrigger | IntervalTrigger):
trigger_time = job.next_run_time if hasattr(job, "next_run_time") else None trigger_time = job.next_run_time or None
elif isinstance(job.trigger, CronTrigger): elif isinstance(job.trigger, CronTrigger):
if not job.next_run_time:
logger.debug("No next run time found so probably paused?")
return "Paused"
trigger_time = job.trigger.get_next_fire_time(None, datetime.datetime.now(tz=job._scheduler.timezone)) # noqa: SLF001 trigger_time = job.trigger.get_next_fire_time(None, datetime.datetime.now(tz=job._scheduler.timezone)) # noqa: SLF001
logger.debug(f"{type(job.trigger)=}, {trigger_time=}")
if not trigger_time: if not trigger_time:
logger.debug("No trigger time found")
return "Paused" return "Paused"
return f"<t:{int(trigger_time.timestamp())}:R>" return f"<t:{int(trigger_time.timestamp())}:R>"
@ -70,4 +79,10 @@ def calc_time(time: datetime.datetime | None) -> str:
if not time: if not time:
return "None" return "None"
if time.tzinfo is None or time.tzinfo.utcoffset(time) is None:
logger.warning(f"Time is not timezone-aware: {time}")
if time < datetime.datetime.now(tz=time.tzinfo):
logger.warning(f"Time is in the past: {time}")
return f"<t:{int(time.timestamp())}:R>" return f"<t:{int(time.timestamp())}:R>"

View File

@ -6,29 +6,26 @@ readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
# The Discord bot library uses discord.py # The Discord bot library uses discord.py
# legacy-cgi and audioop-lts are because Python 3.13 removed cgi module and audioop module "discord-py[speed]>=2.5.0", # https://github.com/Rapptz/discord.py
"discord-py[speed]>=2.4.0,<3.0.0", # https://github.com/Rapptz/discord.py
"legacy-cgi>=2.6.2,<3.0.0; python_version >= '3.13'", # https://github.com/jackrosenthal/legacy-cgi
"audioop-lts>=0.2.1,<1.0.0; python_version >= '3.13'", # https://github.com/AbstractUmbra/audioop
# For parsing dates and times in /remind commands # For parsing dates and times in /remind commands
"dateparser>=1.0.0", # https://github.com/scrapinghub/dateparser "dateparser>=1.0.0", # https://github.com/scrapinghub/dateparser
# For sending webhook messages to Discord # For sending webhook messages to Discord
"discord-webhook>=1.3.1,<2.0.0", # https://github.com/lovvskillz/python-discord-webhook "discord-webhook>=1.3.1", # https://github.com/lovvskillz/python-discord-webhook
# For scheduling reminders, sqlalchemy is needed for storing reminders in a database # For scheduling reminders, sqlalchemy is needed for storing reminders in a database
"apscheduler>=3.11.0,<4.0.0", # https://github.com/agronholm/apscheduler "apscheduler>=3.11.0", # https://github.com/agronholm/apscheduler
"sqlalchemy>=2.0.37,<3.0.0", # https://github.com/sqlalchemy/sqlalchemy "sqlalchemy>=2.0.37", # https://github.com/sqlalchemy/sqlalchemy
# For loading environment variables from a .env file # For loading environment variables from a .env file
"python-dotenv>=1.0.1,<2.0.0", # https://github.com/theskumar/python-dotenv "python-dotenv>=1.0.1", # https://github.com/theskumar/python-dotenv
# For error tracking # For error tracking
"sentry-sdk>=2.20.0,<3.0.0", # https://github.com/getsentry/sentry-python "sentry-sdk>=2.20.0", # https://github.com/getsentry/sentry-python
# For logging # For logging
"loguru>=0.7.3,<1.0.0", # https://github.com/Delgan/loguru "loguru>=0.7.3", # https://github.com/Delgan/loguru
] ]
[dependency-groups] [dependency-groups]
@ -142,4 +139,7 @@ log_cli = true
log_cli_level = "INFO" log_cli_level = "INFO"
log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
log_cli_date_format = "%Y-%m-%d %H:%M:%S" log_cli_date_format = "%Y-%m-%d %H:%M:%S"
filterwarnings = ["ignore::DeprecationWarning:aiohttp.cookiejar"] filterwarnings = [
"ignore:Parsing dates involving a day of month without a year specified is ambiguious:DeprecationWarning:dateparser\\.utils\\.strptime",
"ignore::DeprecationWarning:aiohttp.cookiejar",
]

View File

@ -73,25 +73,26 @@ def dummy_job() -> None:
def test_calculate() -> None: def test_calculate() -> None:
"""Test the calculate function with various job inputs.""" """Test the calculate function with various job inputs."""
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
scheduler.timezone = timezone.utc
scheduler.start() scheduler.start()
# Create a job with a DateTrigger # Create a job with a DateTrigger
run_date = datetime(2270, 10, 1, 12, 0, 0, tzinfo=timezone.utc) run_date = datetime(2270, 10, 1, 12, 0, 0, tzinfo=scheduler.timezone)
job: Job = scheduler.add_job(dummy_job, trigger=DateTrigger(run_date=run_date), id="test_job", name="Test Job") job: Job = scheduler.add_job(dummy_job, trigger=DateTrigger(run_date=run_date), id="test_job", name="Test Job")
expected_output = "<t:9490737600:R>" expected_output = "<t:9490737600:R>"
assert_msg: str = f"Expected {expected_output}, got {calculate(job)}" assert_msg: str = f"Expected {expected_output}, got {calculate(job)}\nState:{job.__getstate__()}"
assert calculate(job) == expected_output, assert_msg assert calculate(job) == expected_output, assert_msg
# Modify the job to have a next_run_time # Modify the job to have a next_run_time
job.modify(next_run_time=run_date) job.modify(next_run_time=run_date)
assert_msg: str = f"Expected {expected_output}, got {calculate(job)}" assert_msg: str = f"Expected {expected_output}, got {calculate(job)}\nState:{job.__getstate__()}"
assert calculate(job) == expected_output, assert_msg assert calculate(job) == expected_output, assert_msg
# Paused job should still return the same output # Paused job should return "Paused"
job.pause() job.pause()
assert_msg: str = f"Expected None, got {calculate(job)}" assert_msg: str = f"Expected 'Paused', got {calculate(job)}\nState:{job.__getstate__()}"
assert not calculate(job), assert_msg assert calculate(job) == "Paused", assert_msg
scheduler.shutdown() scheduler.shutdown()
@ -101,7 +102,7 @@ def test_calculate_cronjob() -> None:
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
scheduler.start() scheduler.start()
run_date = datetime(2270, 10, 1, 12, 0, 0, tzinfo=timezone.utc) run_date = datetime(2270, 10, 1, 12, 0, 0, tzinfo=scheduler.timezone)
job: Job = scheduler.add_job( job: Job = scheduler.add_job(
dummy_job, dummy_job,
trigger=CronTrigger( trigger=CronTrigger(
@ -117,11 +118,10 @@ def test_calculate_cronjob() -> None:
job.modify(next_run_time=run_date) job.modify(next_run_time=run_date)
expected_output: str = f"<t:{int(run_date.timestamp())}:R>" expected_output: str = f"<t:{int(run_date.timestamp())}:R>"
assert calculate(job) == expected_output, f"Expected {expected_output}, got {calculate(job)}" assert calculate(job) == expected_output, f"Expected {expected_output}, got {calculate(job)}\nState:{job.__getstate__()}"
# You can't pause a CronTrigger job so this should return the same output
job.pause() job.pause()
assert calculate(job) == expected_output, f"Expected {expected_output}, got {calculate(job)}" assert calculate(job) == "Paused", f"Expected Paused, got {calculate(job)}\nState:{job.__getstate__()}"
scheduler.shutdown() scheduler.shutdown()
@ -130,15 +130,15 @@ def test_calculate_intervaljob() -> None:
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
scheduler.start() scheduler.start()
run_date = datetime(2270, 12, 31, 23, 59, 59, tzinfo=timezone.utc) run_date = datetime(2270, 12, 31, 23, 59, 59, tzinfo=scheduler.timezone)
job = scheduler.add_job(dummy_job, trigger=IntervalTrigger(seconds=3600), id="test_interval_job", name="Test Interval Job") job = scheduler.add_job(dummy_job, trigger=IntervalTrigger(seconds=3600), id="test_interval_job", name="Test Interval Job")
# Force next_run_time to expected value for testing # Force next_run_time to expected value for testing
job.modify(next_run_time=run_date) job.modify(next_run_time=run_date)
expected_output = f"<t:{int(run_date.timestamp())}:R>" expected_output = f"<t:{int(run_date.timestamp())}:R>"
assert calculate(job) == expected_output, f"Expected {expected_output}, got {calculate(job)}" assert calculate(job) == expected_output, f"Expected {expected_output}, got {calculate(job)}\nState:{job.__getstate__()}"
# Paused job should return False # Paused job should return "Paused"
job.pause() job.pause()
assert not calculate(job), f"Expected None, got {calculate(job)}" assert calculate(job) == "Paused", f"Expected Paused, got {calculate(job)}\nState:{job.__getstate__()}"
scheduler.shutdown() scheduler.shutdown()