Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-django-celery-beat for
openSUSE:Factory checked in at 2026-01-05 16:04:13
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-django-celery-beat (Old)
and /work/SRC/openSUSE:Factory/.python-django-celery-beat.new.1928 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-django-celery-beat"
Mon Jan 5 16:04:13 2026 rev:6 rq:1325391 version:2.8.1
Changes:
--------
---
/work/SRC/openSUSE:Factory/python-django-celery-beat/python-django-celery-beat.changes
2025-04-30 19:03:10.619311322 +0200
+++
/work/SRC/openSUSE:Factory/.python-django-celery-beat.new.1928/python-django-celery-beat.changes
2026-01-05 16:04:16.039222391 +0100
@@ -1,0 +2,10 @@
+Mon Jan 5 11:44:49 UTC 2026 - Markéta Machová <[email protected]>
+
+- Update to 2.8.1
+ * Refactor / all_as_schedule crontab query optimization
+ * Consider server timezone on _get_timezone_offset instead of django's
+ settings
+ * Periodic task querying is a separate method
+- Add pytest9.patch to allow pytest 9
+
+-------------------------------------------------------------------
Old:
----
django_celery_beat-2.8.0.tar.gz
New:
----
django_celery_beat-2.8.1.tar.gz
pytest9.patch
----------(New B)----------
New: * Periodic task querying is a separate method
- Add pytest9.patch to allow pytest 9
----------(New E)----------
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-django-celery-beat.spec ++++++
--- /var/tmp/diff_new_pack.r7OMZc/_old 2026-01-05 16:04:27.155683183 +0100
+++ /var/tmp/diff_new_pack.r7OMZc/_new 2026-01-05 16:04:27.155683183 +0100
@@ -1,7 +1,7 @@
#
# spec file for package python-django-celery-beat
#
-# Copyright (c) 2025 SUSE LLC
+# Copyright (c) 2026 SUSE LLC and contributors
#
# All modifications and additions to the file contributed by third parties
# remain the property of their copyright owners, unless otherwise agreed
@@ -17,7 +17,7 @@
Name: python-django-celery-beat
-Version: 2.8.0
+Version: 2.8.1
Release: 0
Summary: Database-backed Periodic Tasks
License: BSD-3-Clause
@@ -28,6 +28,8 @@
BuildRequires: %{python_module wheel}
BuildRequires: fdupes
BuildRequires: python-rpm-macros
+# PATCH-FIX-UPSTREAM https://github.com/celery/django-celery-beat/pull/986
Allow pytest 9
+Patch0: pytest9.patch
Requires: python-Django >= 3.2
Requires: python-celery >= 5.2.3
Requires: python-cron-descriptor >= 1.2.32
++++++ django_celery_beat-2.8.0.tar.gz -> django_celery_beat-2.8.1.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_celery_beat-2.8.0/Changelog
new/django_celery_beat-2.8.1/Changelog
--- old/django_celery_beat-2.8.0/Changelog 2025-04-16 09:15:02.000000000
+0200
+++ new/django_celery_beat-2.8.1/Changelog 2025-05-13 08:57:51.000000000
+0200
@@ -7,6 +7,16 @@
Next
====
+.. _version-2.8.1:
+
+2.8.1
+=====
+:release-date: 2025-05-13
+:release-by: Asif Saif Uddin (@auvipy)
+
+- Fixed regression by big code refactoring.
+
+
.. _version-2.8.0:
2.8.0
@@ -14,9 +24,6 @@
:release-date: 2025-04-16
:release-by: Asif Saif Uddin (@auvipy)
-Added
-~~~~~
-
- Add official support for Django 5.2.
- Issue 796: remove days of the week from human readable description when the
whole week is specified.
- fix 'exipres', 'expire_seconds' not working normal as expected.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_celery_beat-2.8.0/PKG-INFO
new/django_celery_beat-2.8.1/PKG-INFO
--- old/django_celery_beat-2.8.0/PKG-INFO 2025-04-16 09:15:04.987131600
+0200
+++ new/django_celery_beat-2.8.1/PKG-INFO 2025-05-13 08:57:54.326757700
+0200
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: django-celery-beat
-Version: 2.8.0
+Version: 2.8.1
Summary: Database-backed Periodic Tasks.
Home-page: https://github.com/celery/django-celery-beat
Author: Asif Saif Uddin, Ask Solem
@@ -63,7 +63,7 @@
|build-status| |coverage| |license| |wheel| |pyversion| |pyimp|
-:Version: 2.8.0
+:Version: 2.8.1
:Web: http://django-celery-beat.readthedocs.io/
:Download: http://pypi.python.org/pypi/django-celery-beat
:Source: http://github.com/celery/django-celery-beat
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_celery_beat-2.8.0/README.rst
new/django_celery_beat-2.8.1/README.rst
--- old/django_celery_beat-2.8.0/README.rst 2025-04-16 09:15:02.000000000
+0200
+++ new/django_celery_beat-2.8.1/README.rst 2025-05-13 08:57:51.000000000
+0200
@@ -4,7 +4,7 @@
|build-status| |coverage| |license| |wheel| |pyversion| |pyimp|
-:Version: 2.8.0
+:Version: 2.8.1
:Web: http://django-celery-beat.readthedocs.io/
:Download: http://pypi.python.org/pypi/django-celery-beat
:Source: http://github.com/celery/django-celery-beat
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/django_celery_beat-2.8.0/django_celery_beat/__init__.py
new/django_celery_beat-2.8.1/django_celery_beat/__init__.py
--- old/django_celery_beat-2.8.0/django_celery_beat/__init__.py 2025-04-16
09:15:02.000000000 +0200
+++ new/django_celery_beat-2.8.1/django_celery_beat/__init__.py 2025-05-13
08:57:51.000000000 +0200
@@ -5,7 +5,7 @@
import re
from collections import namedtuple
-__version__ = '2.8.0'
+__version__ = '2.8.1'
__author__ = 'Asif Saif Uddin, Ask Solem'
__contact__ = '[email protected], [email protected]'
__homepage__ = 'https://github.com/celery/django-celery-beat'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/django_celery_beat-2.8.0/django_celery_beat/models.py
new/django_celery_beat-2.8.1/django_celery_beat/models.py
--- old/django_celery_beat-2.8.0/django_celery_beat/models.py 2025-04-16
09:15:02.000000000 +0200
+++ new/django_celery_beat-2.8.1/django_celery_beat/models.py 2025-05-13
08:57:51.000000000 +0200
@@ -65,7 +65,7 @@
"""Return timezone string from Django settings ``CELERY_TIMEZONE``
variable.
If is not defined or is not a valid timezone, return ``"UTC"`` instead.
- """ # noqa: E501
+ """
try:
CELERY_TIMEZONE = getattr(
settings, '%s_TIMEZONE' % current_app.namespace)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/django_celery_beat-2.8.0/django_celery_beat/schedulers.py
new/django_celery_beat-2.8.1/django_celery_beat/schedulers.py
--- old/django_celery_beat-2.8.0/django_celery_beat/schedulers.py
2025-04-16 09:15:02.000000000 +0200
+++ new/django_celery_beat-2.8.1/django_celery_beat/schedulers.py
2025-05-13 08:57:51.000000000 +0200
@@ -4,6 +4,11 @@
import math
from multiprocessing.util import Finalize
+try:
+ from zoneinfo import ZoneInfo # Python 3.9+
+except ImportError:
+ from backports.zoneinfo import ZoneInfo # Python 3.8
+
from celery import current_app, schedules
from celery.beat import ScheduleEntry, Scheduler
from celery.utils.log import get_logger
@@ -11,21 +16,22 @@
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db import close_old_connections, transaction
-from django.db.models import Q
+from django.db.models import Case, F, IntegerField, Q, When
+from django.db.models.functions import Cast
from django.db.utils import DatabaseError, InterfaceError
-from django.utils import timezone
from kombu.utils.encoding import safe_repr, safe_str
from kombu.utils.json import dumps, loads
from .clockedschedule import clocked
from .models import (ClockedSchedule, CrontabSchedule, IntervalSchedule,
PeriodicTask, PeriodicTasks, SolarSchedule)
-from .utils import NEVER_CHECK_TIMEOUT, now
+from .utils import NEVER_CHECK_TIMEOUT, aware_now, now
# This scheduler must wake up more frequently than the
# regular of 5 minutes because it needs to take external
# changes to the schedule into account.
DEFAULT_MAX_INTERVAL = 5 # seconds
+SCHEDULE_SYNC_MAX_INTERVAL = 300 # 5 minutes
ADD_ENTRY_ERROR = """\
Cannot add entry %r to database schedule: %r. Contents: %r
@@ -238,6 +244,7 @@
_last_timestamp = None
_initial_read = True
_heap_invalidated = False
+ _last_full_sync = None
def __init__(self, *args, **kwargs):
"""Initialize the database scheduler."""
@@ -256,23 +263,144 @@
def all_as_schedule(self):
debug('DatabaseScheduler: Fetching database schedule')
s = {}
- next_five_minutes = now() + datetime.timedelta(minutes=5)
- exclude_clock_tasks_query = Q(
- clocked__isnull=False, clocked__clocked_time__gt=next_five_minutes
- )
- exclude_hours = self.get_excluded_hours_for_crontab_tasks()
- exclude_cron_tasks_query = Q(
- crontab__isnull=False, crontab__hour__in=exclude_hours
- )
- for model in self.Model.objects.enabled().exclude(
- exclude_clock_tasks_query | exclude_cron_tasks_query
- ):
+ for model in self.enabled_models():
try:
s[model.name] = self.Entry(model, app=self.app)
except ValueError:
pass
return s
+ def enabled_models(self):
+ """Return list of enabled periodic tasks.
+
+ Allows overriding how the list of periodic tasks is fetched without
+ duplicating the filtering/querying logic.
+ """
+ return list(self.enabled_models_qs())
+
+ def enabled_models_qs(self):
+ next_schedule_sync = now() + datetime.timedelta(
+ seconds=SCHEDULE_SYNC_MAX_INTERVAL
+ )
+ exclude_clock_tasks_query = Q(
+ clocked__isnull=False,
+ clocked__clocked_time__gt=next_schedule_sync
+ )
+
+ exclude_cron_tasks_query = self._get_crontab_exclude_query()
+
+ # Combine the queries for optimal database filtering
+ exclude_query = exclude_clock_tasks_query | exclude_cron_tasks_query
+
+ # Fetch only the tasks we need to consider
+ return self.Model.objects.enabled().exclude(exclude_query)
+
+ def _get_crontab_exclude_query(self):
+ """
+ Build a query to exclude crontab tasks based on their hour value,
+ adjusted for timezone differences relative to the server.
+
+ This creates an annotation for each crontab task that represents the
+ server-equivalent hour, then filters on that annotation.
+ """
+ # Get server time based on Django settings
+
+ server_time = aware_now()
+ server_hour = server_time.hour
+
+ # Window of +/- 2 hours around the current hour in server tz.
+ hours_to_include = [
+ (server_hour + offset) % 24 for offset in range(-2, 3)
+ ]
+ hours_to_include += [4] # celery's default cleanup task
+
+ # Regex pattern to match only numbers
+ # This ensures we only process numeric hour values
+ numeric_hour_pattern = r'^\d+$'
+
+ # Get all tasks with a simple numeric hour value
+ numeric_hour_tasks = CrontabSchedule.objects.filter(
+ hour__regex=numeric_hour_pattern
+ )
+
+ # Annotate these tasks with their server-hour equivalent
+ annotated_tasks = numeric_hour_tasks.annotate(
+ # Cast hour string to integer
+ hour_int=Cast('hour', IntegerField()),
+
+ # Calculate server-hour based on timezone offset
+ server_hour=Case(
+ # Handle each timezone specifically
+ *[
+ When(
+ timezone=timezone_name,
+ then=(
+ F('hour_int')
+ + self._get_timezone_offset(timezone_name)
+ + 24
+ ) % 24
+ )
+ for timezone_name in self._get_unique_timezone_names()
+ ],
+ # Default case - use hour as is
+ default=F('hour_int')
+ )
+ )
+
+ excluded_hour_task_ids = annotated_tasks.exclude(
+ server_hour__in=hours_to_include
+ ).values_list('id', flat=True)
+
+ # Build the final exclude query:
+ # Exclude crontab tasks that are not in our include list
+ exclude_query = Q(crontab__isnull=False) & Q(
+ crontab__id__in=excluded_hour_task_ids
+ )
+
+ return exclude_query
+
+ def _get_unique_timezone_names(self):
+ """Get a list of all unique timezone names used in CrontabSchedule"""
+ return CrontabSchedule.objects.values_list(
+ 'timezone', flat=True
+ ).distinct()
+
+ def _get_timezone_offset(self, timezone_name):
+ """
+ Args:
+ timezone_name: The name of the timezone or a ZoneInfo object
+
+ Returns:
+ int: The hour offset
+ """
+ # Get server timezone
+ server_time = aware_now()
+ # Use server_time.tzinfo directly if it is already a ZoneInfo instance
+ if isinstance(server_time.tzinfo, ZoneInfo):
+ server_tz = server_time.tzinfo
+ else:
+ server_tz = ZoneInfo(str(server_time.tzinfo))
+
+ if isinstance(timezone_name, ZoneInfo):
+ timezone_name = timezone_name.key
+
+ target_tz = ZoneInfo(timezone_name)
+
+ # Use a fixed point in time for the calculation to avoid DST issues
+ fixed_dt = datetime.datetime(2023, 1, 1, 12, 0, 0)
+
+ # Calculate the offset
+ dt1 = fixed_dt.replace(tzinfo=server_tz)
+ dt2 = fixed_dt.replace(tzinfo=target_tz)
+
+ # Calculate hour difference
+ offset_seconds = (
+ dt1.utcoffset().total_seconds() - dt2.utcoffset().total_seconds()
+ )
+ offset_hours = int(offset_seconds / 3600)
+
+ return offset_hours
+
def schedule_changed(self):
try:
close_old_connections()
@@ -372,13 +500,31 @@
@property
def schedule(self):
initial = update = False
+ current_time = datetime.datetime.now()
+
if self._initial_read:
debug('DatabaseScheduler: initial read')
initial = update = True
self._initial_read = False
+ self._last_full_sync = current_time
elif self.schedule_changed():
info('DatabaseScheduler: Schedule changed.')
update = True
+ self._last_full_sync = current_time
+
+ # Force update the schedule if it's been more than 5 minutes
+ if not update:
+ time_since_last_sync = (
+ current_time - self._last_full_sync
+ ).total_seconds()
+ if (
+ time_since_last_sync >= SCHEDULE_SYNC_MAX_INTERVAL
+ ):
+ debug(
+ 'DatabaseScheduler: Forcing full sync after 5 minutes'
+ )
+ update = True
+ self._last_full_sync = current_time
if update:
self.sync()
@@ -392,32 +538,3 @@
repr(entry) for entry in self._schedule.values()),
)
return self._schedule
-
- @staticmethod
- def get_excluded_hours_for_crontab_tasks():
- # Generate the full list of allowed hours for crontabs
- allowed_crontab_hours = [
- f"{hour:02}" for hour in range(24)
- ] + [
- str(hour) for hour in range(10)
- ]
-
- # Get current, next, and previous hours
- current_time = timezone.localtime(now())
- current_hour = current_time.hour
- next_hour = (current_hour + 1) % 24
- previous_hour = (current_hour - 1) % 24
-
- # Create a set of hours to remove (both padded and non-padded versions)
- hours_to_remove = {
- f"{current_hour:02}", str(current_hour),
- f"{next_hour:02}", str(next_hour),
- f"{previous_hour:02}", str(previous_hour),
- str(4), "04", # celery's default cleanup task
- }
-
- # Filter out 'should be considered' hours
- return [
- hour for hour in allowed_crontab_hours
- if hour not in hours_to_remove
- ]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_celery_beat-2.8.0/django_celery_beat/utils.py
new/django_celery_beat-2.8.1/django_celery_beat/utils.py
--- old/django_celery_beat-2.8.0/django_celery_beat/utils.py 2025-04-16
09:15:02.000000000 +0200
+++ new/django_celery_beat-2.8.1/django_celery_beat/utils.py 2025-05-13
08:57:51.000000000 +0200
@@ -1,8 +1,14 @@
"""Utilities."""
+import datetime
# -- XXX This module must not use translation as that causes
# -- a recursive loader import!
from datetime import timezone as datetime_timezone
+try:
+ from zoneinfo import ZoneInfo # Python 3.9+
+except ImportError:
+ from backports.zoneinfo import ZoneInfo # Python 3.8
+
from django.conf import settings
from django.utils import timezone
@@ -37,6 +43,16 @@
return timezone.now()
+def aware_now():
+ if getattr(settings, 'USE_TZ', True):
+ # When USE_TZ is True, return timezone.now()
+ return timezone.now()
+ else:
+ # When USE_TZ is False, use the project's timezone
+ project_tz = ZoneInfo(getattr(settings, 'TIME_ZONE', 'UTC'))
+ return datetime.datetime.now(project_tz)
+
+
def is_database_scheduler(scheduler):
"""Return true if Celery is configured to use the db scheduler."""
if not scheduler:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/django_celery_beat-2.8.0/django_celery_beat.egg-info/PKG-INFO
new/django_celery_beat-2.8.1/django_celery_beat.egg-info/PKG-INFO
--- old/django_celery_beat-2.8.0/django_celery_beat.egg-info/PKG-INFO
2025-04-16 09:15:04.000000000 +0200
+++ new/django_celery_beat-2.8.1/django_celery_beat.egg-info/PKG-INFO
2025-05-13 08:57:54.000000000 +0200
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: django-celery-beat
-Version: 2.8.0
+Version: 2.8.1
Summary: Database-backed Periodic Tasks.
Home-page: https://github.com/celery/django-celery-beat
Author: Asif Saif Uddin, Ask Solem
@@ -63,7 +63,7 @@
|build-status| |coverage| |license| |wheel| |pyversion| |pyimp|
-:Version: 2.8.0
+:Version: 2.8.1
:Web: http://django-celery-beat.readthedocs.io/
:Download: http://pypi.python.org/pypi/django-celery-beat
:Source: http://github.com/celery/django-celery-beat
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/django_celery_beat-2.8.0/django_celery_beat.egg-info/SOURCES.txt
new/django_celery_beat-2.8.1/django_celery_beat.egg-info/SOURCES.txt
--- old/django_celery_beat-2.8.0/django_celery_beat.egg-info/SOURCES.txt
2025-04-16 09:15:04.000000000 +0200
+++ new/django_celery_beat-2.8.1/django_celery_beat.egg-info/SOURCES.txt
2025-05-13 08:57:54.000000000 +0200
@@ -109,4 +109,5 @@
t/unit/test_admin.py
t/unit/test_crontabs.py
t/unit/test_models.py
-t/unit/test_schedulers.py
\ No newline at end of file
+t/unit/test_schedulers.py
+t/unit/test_utils.py
\ No newline at end of file
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_celery_beat-2.8.0/docs/copyright.rst
new/django_celery_beat-2.8.1/docs/copyright.rst
--- old/django_celery_beat-2.8.0/docs/copyright.rst 2025-04-16
09:15:02.000000000 +0200
+++ new/django_celery_beat-2.8.1/docs/copyright.rst 2025-05-13
08:57:51.000000000 +0200
@@ -3,11 +3,11 @@
*django-celery-beat User Manual*
-by Ask Solem
+by Ask Solem & Asif Saif Uddin
.. |copy| unicode:: U+000A9 .. COPYRIGHT SIGN
-Copyright |copy| 2016, Ask Solem
+Copyright |copy| 2016, Ask Solem, 2017-2036, Asif Saif Uddin,
All rights reserved. This material may be copied or distributed only
subject to the terms and conditions set forth in the `Creative Commons
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/django_celery_beat-2.8.0/docs/includes/introduction.txt
new/django_celery_beat-2.8.1/docs/includes/introduction.txt
--- old/django_celery_beat-2.8.0/docs/includes/introduction.txt 2025-04-16
09:15:02.000000000 +0200
+++ new/django_celery_beat-2.8.1/docs/includes/introduction.txt 2025-05-13
08:57:51.000000000 +0200
@@ -1,4 +1,4 @@
-:Version: 2.8.0
+:Version: 2.8.1
:Web: http://django-celery-beat.readthedocs.io/
:Download: http://pypi.python.org/pypi/django-celery-beat
:Source: http://github.com/celery/django-celery-beat
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_celery_beat-2.8.0/setup.cfg
new/django_celery_beat-2.8.1/setup.cfg
--- old/django_celery_beat-2.8.0/setup.cfg 2025-04-16 09:15:04.987131600
+0200
+++ new/django_celery_beat-2.8.1/setup.cfg 2025-05-13 08:57:54.326757700
+0200
@@ -7,6 +7,7 @@
ignore = N806, N802, N801, N803, W503, W504
exclude =
**/migrations/*.py
+max-line-length = 88
[pep257]
ignore = D102,D104,D203,D105,D213
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_celery_beat-2.8.0/t/unit/test_schedulers.py
new/django_celery_beat-2.8.1/t/unit/test_schedulers.py
--- old/django_celery_beat-2.8.0/t/unit/test_schedulers.py 2025-04-16
09:15:02.000000000 +0200
+++ new/django_celery_beat-2.8.1/t/unit/test_schedulers.py 2025-05-13
08:57:51.000000000 +0200
@@ -4,6 +4,7 @@
from datetime import datetime, timedelta
from itertools import count
from time import monotonic
+from unittest.mock import patch
try:
from zoneinfo import ZoneInfo # Python 3.9+
@@ -306,40 +307,50 @@
interval = 10
right_now = self.app.now()
one_interval_ago = right_now - timedelta(seconds=interval)
- m = self.create_model_interval(schedule(timedelta(seconds=interval)),
- start_time=right_now,
- last_run_at=one_interval_ago)
+ m = self.create_model_interval(
+ schedule(timedelta(seconds=interval)),
+ start_time=right_now,
+ last_run_at=one_interval_ago
+ )
e = self.Entry(m, app=self.app)
isdue, delay = e.is_due()
assert isdue
assert delay == interval
tomorrow = right_now + timedelta(days=1)
- m2 = self.create_model_interval(schedule(timedelta(seconds=interval)),
- start_time=tomorrow,
- last_run_at=one_interval_ago)
+ m2 = self.create_model_interval(
+ schedule(timedelta(seconds=interval)),
+ start_time=tomorrow,
+ last_run_at=one_interval_ago
+ )
e2 = self.Entry(m2, app=self.app)
isdue, delay = e2.is_due()
assert not isdue
- assert delay == math.ceil((tomorrow - right_now).total_seconds())
+ assert delay == math.ceil(
+ (tomorrow - right_now).total_seconds()
+ )
def test_one_off_task(self):
interval = 10
right_now = self.app.now()
one_interval_ago = right_now - timedelta(seconds=interval)
- m = self.create_model_interval(schedule(timedelta(seconds=interval)),
- one_off=True,
- last_run_at=one_interval_ago,
- total_run_count=0)
+ m = self.create_model_interval(
+ schedule(timedelta(seconds=interval)),
+ one_off=True,
+ last_run_at=one_interval_ago,
+ total_run_count=0
+ )
e = self.Entry(m, app=self.app)
isdue, delay = e.is_due()
assert isdue
assert delay == interval
- m2 = self.create_model_interval(schedule(timedelta(seconds=interval)),
- one_off=True,
- last_run_at=one_interval_ago,
- total_run_count=1)
+ m2 = self.create_model_interval(
+ schedule(timedelta(seconds=interval)),
+ one_off=True,
+ last_run_at=one_interval_ago,
+ total_run_count=1
+ )
e2 = self.Entry(m2, app=self.app)
isdue, delay = e2.is_due()
assert not isdue
@@ -349,26 +360,32 @@
interval = 10
right_now = self.app.now()
one_second_later = right_now + timedelta(seconds=1)
- m = self.create_model_interval(schedule(timedelta(seconds=interval)),
- start_time=right_now,
- expires=one_second_later)
+ m = self.create_model_interval(
+ schedule(timedelta(seconds=interval)),
+ start_time=right_now,
+ expires=one_second_later
+ )
e = self.Entry(m, app=self.app)
isdue, delay = e.is_due()
assert isdue
assert delay == interval
- m2 = self.create_model_interval(schedule(timedelta(seconds=interval)),
- start_time=right_now,
- expires=right_now)
+ m2 = self.create_model_interval(
+ schedule(timedelta(seconds=interval)),
+ start_time=right_now,
+ expires=right_now
+ )
e2 = self.Entry(m2, app=self.app)
isdue, delay = e2.is_due()
assert not isdue
assert delay == NEVER_CHECK_TIMEOUT
one_second_ago = right_now - timedelta(seconds=1)
- m2 = self.create_model_interval(schedule(timedelta(seconds=interval)),
- start_time=right_now,
- expires=one_second_ago)
+ m2 = self.create_model_interval(
+ schedule(timedelta(seconds=interval)),
+ start_time=right_now,
+ expires=one_second_ago
+ )
e2 = self.Entry(m2, app=self.app)
isdue, delay = e2.is_due()
assert not isdue
@@ -519,7 +536,7 @@
# distant future time (should not be in schedule)
self.m11 = self.create_model_crontab(
- crontab(hour=str((now_hour + 2) % 24)))
+ crontab(hour=str((now_hour + 3) % 24)))
self.m11.save()
self.m11.refresh_from_db()
@@ -533,19 +550,46 @@
def test_all_as_schedule(self):
sched = self.s.schedule
assert sched
- assert len(sched) == 9
- assert 'celery.backend_cleanup' in sched
- for n, e in sched.items():
- assert isinstance(e, self.s.Entry)
- def test_get_excluded_hours_for_crontab_tasks(self):
- now_hour = timezone.localtime(timezone.now()).hour
- excluded_hours = self.s.get_excluded_hours_for_crontab_tasks()
+ # Check for presence of standard tasks
+ expected_task_names = {
+ self.m1.name, # interval task
+ self.m2.name, # interval task
+ self.m3.name, # crontab task
+ self.m4.name, # solar task
+ self.m6.name, # clocked task (near future)
+ self.m8.name, # crontab task (current hour)
+ self.m9.name, # crontab task (current hour + 1)
+ self.m10.name, # crontab task (current hour - 1)
+ 'celery.backend_cleanup' # auto-added by system
+ }
+
+ # The distant future crontab task (hour + 3) should be excluded
+ distant_task_name = self.m11.name
+
+ # But if it's hour is 4 (or converts to 4 after timezone adjustment),
+ # it would be included because of the special handling for hour=4
+ current_hour = timezone.localtime(timezone.now()).hour
+ is_hour_four_task = False
+
+ # Check if the task would have hour 4.
+ if self.m11.crontab.hour == '4' or (current_hour + 3) % 24 == 4:
+ is_hour_four_task = True
+
+ # Add to expected tasks if it's an hour=4 task
+ if is_hour_four_task:
+ expected_task_names.add(distant_task_name)
+
+ # Verify all expected tasks are present in the schedule
+ schedule_task_names = set(sched.keys())
+ assert schedule_task_names == expected_task_names, (
+ f"Task mismatch. Expected: {expected_task_names}, "
+ f"Got: {schedule_task_names}"
+ )
- assert str(now_hour) not in excluded_hours
- assert str((now_hour + 1) % 24) not in excluded_hours
- assert str((now_hour - 1) % 24) not in excluded_hours
- assert str(4) not in excluded_hours
+ # Verify all entries are the right type
+ for n, e in sched.items():
+ assert isinstance(e, self.s.Entry)
def test_schedule_changed(self):
self.m2.args = '[16, 16]'
@@ -722,7 +766,9 @@
assert self.s.schedules_equal(self.s.schedule, self.s.schedule)
monkeypatch.setattr(self.s, 'schedule_changed', lambda: True)
- assert not self.s.schedules_equal(self.s.schedule, self.s.schedule)
+ assert not self.s.schedules_equal(
+ self.s.schedule, self.s.schedule
+ )
def test_heap_always_return_the_first_item(self):
interval = 10
@@ -898,6 +944,274 @@
assert s._heap[0][2].name != m2.name
is_due, _ = e2.is_due()
+ @pytest.mark.django_db
+ def test_crontab_exclusion_logic_basic(self):
+ current_hour = datetime.now().hour
+ cron_current_hour = CrontabSchedule.objects.create(
+ hour=str(current_hour)
+ )
+ cron_plus_one = CrontabSchedule.objects.create(
+ hour=str((current_hour + 1) % 24)
+ )
+ cron_plus_two = CrontabSchedule.objects.create(
+ hour=str((current_hour + 2) % 24)
+ )
+ cron_minus_one = CrontabSchedule.objects.create(
+ hour=str((current_hour - 1) % 24)
+ )
+ cron_minus_two = CrontabSchedule.objects.create(
+ hour=str((current_hour - 2) % 24)
+ )
+ cron_outside = CrontabSchedule.objects.create(
+ hour=str((current_hour + 5) % 24)
+ )
+
+ # Create a special hour 4 schedule that should always be included
+ cron_hour_four = CrontabSchedule.objects.create(hour='4')
+
+ # Create periodic tasks using these schedules
+ task_current = self.create_model(
+ name='task-current',
+ crontab=cron_current_hour
+ )
+ task_current.save()
+
+ task_plus_one = self.create_model(
+ name='task-plus-one',
+ crontab=cron_plus_one
+ )
+ task_plus_one.save()
+
+ task_plus_two = self.create_model(
+ name='task-plus-two',
+ crontab=cron_plus_two
+ )
+ task_plus_two.save()
+
+ task_minus_one = self.create_model(
+ name='task-minus-one',
+ crontab=cron_minus_one
+ )
+ task_minus_one.save()
+
+ task_minus_two = self.create_model(
+ name='task-minus-two',
+ crontab=cron_minus_two
+ )
+ task_minus_two.save()
+
+ task_outside = self.create_model(
+ name='task-outside',
+ crontab=cron_outside
+ )
+ task_outside.save()
+
+ task_hour_four = self.create_model(
+ name='task-hour-four',
+ crontab=cron_hour_four
+ )
+ task_hour_four.save()
+
+ # Run the scheduler's exclusion logic
+ exclude_query = self.s._get_crontab_exclude_query()
+
+ # Get excluded task IDs
+ excluded_tasks = set(
+ PeriodicTask.objects.filter(exclude_query).values_list(
+ 'id', flat=True
+ )
+ )
+
+ # The test that matters is that hour 4 is always included
+ assert task_hour_four.id not in excluded_tasks
+
+ # Assert that tasks within the time window are not excluded
+ for task_id in [task_current.id, task_plus_one.id, task_plus_two.id,
+ task_minus_one.id, task_minus_two.id]:
+ assert task_id not in excluded_tasks
+
+ if task_outside.crontab.hour != '4':
+ assert task_outside.id in excluded_tasks
+
+ @pytest.mark.django_db
+ def test_crontab_special_hour_four(self):
+ """
+ Test that schedules with hour=4 are always included, regardless of
+ the current time.
+ """
+ # Create a task with hour=4
+ cron_hour_four = CrontabSchedule.objects.create(hour='4')
+ task_hour_four = self.create_model(
+ name='special-cleanup-task',
+ crontab=cron_hour_four
+ )
+
+ # Run the scheduler's exclusion logic
+ exclude_query = self.s._get_crontab_exclude_query()
+
+ # Get excluded task IDs
+ excluded_tasks = set(
+ PeriodicTask.objects.filter(exclude_query).values_list(
+ 'id', flat=True
+ )
+ )
+
+ # The hour=4 task should never be excluded
+ assert task_hour_four.id not in excluded_tasks
+
+ @pytest.mark.django_db
+ @patch('django_celery_beat.schedulers.aware_now')
+ @patch('django.utils.timezone.get_current_timezone')
+ def test_crontab_timezone_conversion(self, mock_get_tz, mock_aware_now):
+ # Set up mocks for server timezone and current time
+ from datetime import datetime
+ server_tz = ZoneInfo("Asia/Tokyo")
+
+ mock_get_tz.return_value = server_tz
+
+ # Server time is 17:00 Tokyo time
+ mock_now_dt = datetime(2023, 1, 1, 17, 0, 0, tzinfo=server_tz)
+ mock_aware_now.return_value = mock_now_dt
+
+ # Create tasks with the following crontab schedules:
+ # 1. UTC task at hour 8 - equivalent to 17:00 Tokyo time (current hour)
+ # - should be included
+ # 2. New York task at hour 3 - equivalent to 17:00 Tokyo time
+ # (current hour) - should be included
+ # 3. UTC task at hour 3 - equivalent to 12:00 Tokyo time
+ # - should be excluded (outside window)
+
+ # Create crontab schedules in different timezones
+ utc_current_hour = CrontabSchedule.objects.create(
+ hour='8', timezone='UTC'
+ )
+ ny_current_hour = CrontabSchedule.objects.create(
+ hour='3', timezone='America/New_York'
+ )
+ utc_outside_window = CrontabSchedule.objects.create(
+ hour='3', timezone='UTC'
+ )
+
+ # Create periodic tasks using these schedules
+ task_utc_current = self.create_model(
+ name='utc-current-hour',
+ crontab=utc_current_hour
+ )
+ task_utc_current.save()
+
+ task_ny_current = self.create_model(
+ name='ny-current-hour',
+ crontab=ny_current_hour
+ )
+ task_ny_current.save()
+
+ task_utc_outside = self.create_model(
+ name='utc-outside-window',
+ crontab=utc_outside_window
+ )
+ task_utc_outside.save()
+
+ # Run the scheduler's exclusion logic
+ exclude_query = self.s._get_crontab_exclude_query()
+
+ # Get excluded task IDs
+ excluded_tasks = set(
+ PeriodicTask.objects.filter(exclude_query).values_list(
+ 'id', flat=True
+ )
+ )
+
+ # Current hour tasks in different timezones should not be excluded
+ assert task_utc_current.id not in excluded_tasks, (
+ "UTC current hour task should be included"
+ )
+ assert task_ny_current.id not in excluded_tasks, (
+ "New York current hour task should be included"
+ )
+
+ # Task outside window should be excluded
+ assert task_utc_outside.id in excluded_tasks, (
+ "UTC outside window task should be excluded"
+ )
+
+ @pytest.mark.django_db
+ @patch('django.utils.timezone.get_current_timezone')
+ @patch('django_celery_beat.schedulers.aware_now')
+ def test_crontab_timezone_conversion_with_negative_offset_and_dst(
+ self, mock_aware_now, mock_get_tz
+ ):
+ # Set up mocks for server timezone and current time
+ from datetime import datetime
+
+ server_tz = ZoneInfo("UTC")
+
+ mock_get_tz.return_value = server_tz
+
+ # Server time is 17:00 UTC in June
+ mock_now_dt = datetime(2023, 6, 1, 17, 0, 0, tzinfo=server_tz)
+ mock_aware_now.return_value = mock_now_dt
+
+ # Create tasks with the following crontab schedules:
+ # 1. Asia/Tokyo task at hour 2 - equivalent to 17:00 UTC (current hour)
+ # - should be included
+ # 2. Europe/Paris task at hour 19 - equivalent to 17:00 UTC
+ # (current hour) - should be included
+ # 3. Europe/Paris task at hour 15 - equivalent to 13:00 UTC
+ # - should be excluded (outside window)
+
+ # Create crontab schedules in different timezones
+ tokyo_current_hour = CrontabSchedule.objects.create(
+ hour='2', timezone='Asia/Tokyo'
+ )
+ paris_current_hour = CrontabSchedule.objects.create(
+ hour='19', timezone='Europe/Paris'
+ )
+ paris_outside_window = CrontabSchedule.objects.create(
+ hour='14', timezone='Europe/Paris'
+ )
+
+ # Create periodic tasks using these schedules
+ task_utc_current = self.create_model(
+ name='tokyo-current-hour',
+ crontab=tokyo_current_hour
+ )
+ task_utc_current.save()
+
+ task_ny_current = self.create_model(
+ name='paros-current-hour',
+ crontab=paris_current_hour
+ )
+ task_ny_current.save()
+
+ task_utc_outside = self.create_model(
+ name='paris-outside-window',
+ crontab=paris_outside_window
+ )
+ task_utc_outside.save()
+
+ # Run the scheduler's exclusion logic
+ exclude_query = self.s._get_crontab_exclude_query()
+
+ # Get excluded task IDs
+ excluded_tasks = set(
+ PeriodicTask.objects.filter(exclude_query).values_list(
+ 'id', flat=True
+ )
+ )
+
+ # Current hour tasks in different timezones should not be excluded
+ assert task_utc_current.id not in excluded_tasks, (
+ "Tokyo current hour task should be included"
+ )
+ assert task_ny_current.id not in excluded_tasks, (
+ "Paris current hour task should be included"
+ )
+
+ # Task outside window should be excluded
+ assert task_utc_outside.id in excluded_tasks, (
+ "Paris outside window task should be excluded"
+ )
+
@pytest.mark.django_db
class test_models(SchedulerCase):
@@ -1018,7 +1332,7 @@
isdue, nextcheck = s.schedule.is_due(dt_lastrun)
assert isdue is False # False means task isn't due, but keep checking.
assert (nextcheck > 0) and (isdue is False) or \
- (nextcheck == s.max_interval) and (isdue is True)
+ (nextcheck == s.max_interval) and (isdue is True)
s2 = SolarSchedule(event='solar_noon', latitude=48.06, longitude=12.86)
dt2 = datetime(day=26, month=7, year=2000, hour=1, minute=0)
@@ -1028,23 +1342,26 @@
isdue2, nextcheck2 = s2.schedule.is_due(dt2_lastrun)
assert isdue2 is True # True means task is due and should run.
assert (nextcheck2 > 0) and (isdue2 is True) or \
- (nextcheck2 == s2.max_interval) and (isdue2 is False)
+ (nextcheck2 == s2.max_interval) and (isdue2 is False)
def test_ClockedSchedule_schedule(self):
- due_datetime = make_aware(datetime(day=26,
- month=7,
- year=3000,
- hour=1,
- minute=0)) # future time
+ due_datetime = make_aware(datetime(
+ day=26,
+ month=7,
+ year=3000,
+ hour=1,
+ minute=0
+ )) # future time
s = ClockedSchedule(clocked_time=due_datetime)
dt = datetime(day=25, month=7, year=2050, hour=1, minute=0)
dt_lastrun = make_aware(dt)
assert s.schedule is not None
isdue, nextcheck = s.schedule.is_due(dt_lastrun)
- assert isdue is False # False means task isn't due, but keep checking.
+ # False means task isn't due, but keep checking.
+ assert isdue is False
assert (nextcheck > 0) and (isdue is False) or \
- (nextcheck == s.max_interval) and (isdue is True)
+ (nextcheck == s.max_interval) and (isdue is True)
due_datetime = make_aware(datetime.now())
s = ClockedSchedule(clocked_time=due_datetime)
@@ -1136,3 +1453,54 @@
ma.run_tasks(self.request, PeriodicTask.objects.filter(id=self.m1.id))
assert 'periodic_task_name' in self.captured_headers
assert self.captured_headers['periodic_task_name'] == self.m1.name
+
+
[email protected]_db
+class test_timezone_offset_handling:
+ def setup_method(self):
+ self.app = patch("django_celery_beat.schedulers.current_app").start()
+
+ def teardown_method(self):
+ patch.stopall()
+
+ @patch("django_celery_beat.schedulers.aware_now")
+ def test_server_timezone_handling_with_zoneinfo(self, mock_aware_now):
+ """Test handling when server timezone is already a ZoneInfo
instance."""
+
+ # Create a mock scheduler with only the methods we need to test
+ class MockScheduler:
+ _get_timezone_offset =
schedulers.DatabaseScheduler._get_timezone_offset
+
+ s = MockScheduler()
+
+ tokyo_tz = ZoneInfo("Asia/Tokyo")
+ mock_now = datetime(2023, 1, 1, 12, 0, 0, tzinfo=tokyo_tz)
+ mock_aware_now.return_value = mock_now
+
+ # Test with a different timezone
+ new_york_tz = "America/New_York"
+ offset = s._get_timezone_offset(new_york_tz) # Pass self explicitly
+
+ # Tokyo is UTC+9, New York is UTC-5, so difference should be 14 hours
+ assert offset == 14
+ assert mock_aware_now.called
+
+ @patch("django_celery_beat.schedulers.aware_now")
+ def test_timezone_offset_with_zoneinfo_object_param(self, mock_aware_now):
+ """Test handling when timezone_name parameter is a ZoneInfo object."""
+
+ class MockScheduler:
+ _get_timezone_offset =
schedulers.DatabaseScheduler._get_timezone_offset
+
+ s = MockScheduler()
+
+ tokyo_tz = ZoneInfo("Asia/Tokyo")
+ mock_now = datetime(2023, 1, 1, 12, 0, 0, tzinfo=tokyo_tz)
+ mock_aware_now.return_value = mock_now
+
+ # Test with a ZoneInfo object as parameter
+ new_york_tz = ZoneInfo("America/New_York")
+ offset = s._get_timezone_offset(new_york_tz) # Pass self explicitly
+
+ # Tokyo is UTC+9, New York is UTC-5, so difference should be 14 hours
+ assert offset == 14
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_celery_beat-2.8.0/t/unit/test_utils.py
new/django_celery_beat-2.8.1/t/unit/test_utils.py
--- old/django_celery_beat-2.8.0/t/unit/test_utils.py 1970-01-01
01:00:00.000000000 +0100
+++ new/django_celery_beat-2.8.1/t/unit/test_utils.py 2025-05-13
08:57:51.000000000 +0200
@@ -0,0 +1,30 @@
+import pytest
+from django.test import TestCase, override_settings
+from django.utils import timezone
+
+from django_celery_beat.utils import aware_now
+
+
[email protected]_db
+class TestUtils(TestCase):
+ def test_aware_now_with_use_tz_true(self):
+ """Test aware_now when USE_TZ is True"""
+ with override_settings(USE_TZ=True):
+ result = aware_now()
+ assert timezone.is_aware(result)
+ # Convert both timezones to string for comparison
+ assert str(result.tzinfo) == str(timezone.get_current_timezone())
+
+ def test_aware_now_with_use_tz_false(self):
+ """Test aware_now when USE_TZ is False"""
+ with override_settings(USE_TZ=False, TIME_ZONE="Asia/Tokyo"):
+ result = aware_now()
+ assert timezone.is_aware(result)
+ assert result.tzinfo.key == "Asia/Tokyo"
+
+ def test_aware_now_with_use_tz_false_default_timezone(self):
+ """Test aware_now when USE_TZ is False and default TIME_ZONE"""
+ with override_settings(USE_TZ=False): # Let Django use its default UTC
+ result = aware_now()
+ assert timezone.is_aware(result)
+ assert str(result.tzinfo) == "UTC"
++++++ pytest9.patch ++++++
>From 78d62f41108a2c4a52f41f300ab91ea34962810e Mon Sep 17 00:00:00 2001
From: Colin Watson <[email protected]>
Date: Thu, 1 Jan 2026 16:30:50 +0000
Subject: [PATCH] Allow pytest 9
https://docs.pytest.org/en/stable/deprecations.html#applying-a-mark-to-a-fixture-function
notes that applying a mark to a fixture function never had any effect.
It raises an exception during test collection with pytest 9.
---
requirements/test.txt | 2 +-
t/unit/test_schedulers.py | 3 ---
2 files changed, 1 insertion(+), 4 deletions(-)
diff --git a/requirements/test.txt b/requirements/test.txt
index bd798673..8c3fd3f0 100644
--- a/requirements/test.txt
+++ b/requirements/test.txt
@@ -4,7 +4,7 @@ pytest-timeout
# Conditional dependencies
pytest>=6.2.5,<8.0; python_version < '3.9' # Python 3.8 only
-pytest>=6.2.5,<9.0; python_version >= '3.9' # Python 3.9+ only
+pytest>=6.2.5,<10.0; python_version >= '3.9' # Python 3.9+ only
pytest-django>=4.5.2,<4.6.0; python_version < '3.9' # Python 3.8 only
pytest-django>=4.5.2,<5.0; python_version >= '3.9' # Python 3.9+ only
backports.zoneinfo; python_version < '3.9' # Python 3.8 only
diff --git a/t/unit/test_schedulers.py b/t/unit/test_schedulers.py
index 1640f6b4..85c6694f 100644
--- a/t/unit/test_schedulers.py
+++ b/t/unit/test_schedulers.py
@@ -561,7 +561,6 @@ def test_task_with_expires(self):
class test_DatabaseSchedulerFromAppConf(SchedulerCase):
Scheduler = TrackingScheduler
- @pytest.mark.django_db
@pytest.fixture(autouse=True)
def setup_scheduler(self, app):
self.app = app
@@ -617,7 +616,6 @@ def test_periodic_task_model_schedule_type_change(self):
class test_DatabaseScheduler(SchedulerCase):
Scheduler = TrackingScheduler
- @pytest.mark.django_db
@pytest.fixture(autouse=True)
def setup_scheduler(self, app):
self.app = app
@@ -1588,7 +1586,6 @@ def test_track_changes(self):
@pytest.mark.django_db
class test_modeladmin_PeriodicTaskAdmin(SchedulerCase):
- @pytest.mark.django_db
@pytest.fixture(autouse=True)
def setup_scheduler(self, app):
self.app = app