Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-APScheduler for openSUSE:Factory checked in at 2026-01-08 15:28:42 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-APScheduler (Old) and /work/SRC/openSUSE:Factory/.python-APScheduler.new.1928 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-APScheduler" Thu Jan 8 15:28:42 2026 rev:26 rq:1325921 version:3.11.2 Changes: -------- --- /work/SRC/openSUSE:Factory/python-APScheduler/python-APScheduler.changes 2025-11-24 15:53:26.024904543 +0100 +++ /work/SRC/openSUSE:Factory/.python-APScheduler.new.1928/python-APScheduler.changes 2026-01-08 15:29:26.890107667 +0100 @@ -1,0 +2,11 @@ +Thu Jan 8 08:23:04 UTC 2026 - Anton Smorodskyi <[email protected]> + +- Update to 3.11.2: + * Fixed an issue where a job using a CronTrigger scheduled + in a repeated time interval during DST transitions could + cause the scheduler to get stuck in an infinite loop + (#1021; PR by @soulofakuma) +- update build and runtime dependencies to latest state in upstream + + +------------------------------------------------------------------- Old: ---- apscheduler-3.11.1.tar.gz New: ---- apscheduler-3.11.2.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-APScheduler.spec ++++++ --- /var/tmp/diff_new_pack.3QnzsC/_old 2026-01-08 15:29:27.342126874 +0100 +++ /var/tmp/diff_new_pack.3QnzsC/_new 2026-01-08 15:29:27.342126874 +0100 @@ -1,7 +1,7 @@ # # spec file for package python-APScheduler # -# Copyright (c) 2025 SUSE LLC +# Copyright (c) 2026 SUSE LLC # # All modifications and additions to the file contributed by third parties # remain the property of their copyright owners, unless otherwise agreed @@ -18,7 +18,7 @@ %{?sle15_python_module_pythons} Name: python-APScheduler -Version: 3.11.1 +Version: 3.11.2 Release: 0 Summary: In-process task scheduler with Cron-like capabilities License: MIT @@ -26,21 +26,25 @@ Source: https://files.pythonhosted.org/packages/source/a/apscheduler/apscheduler-%{version}.tar.gz BuildRequires: %{python_module SQLAlchemy >= 1.4} BuildRequires: %{python_module Twisted} +BuildRequires: %{python_module anyio >= 4.0} +BuildRequires: %{python_module attrs >= 21.3} BuildRequires: %{python_module gevent} BuildRequires: %{python_module pip} BuildRequires: %{python_module pytest-asyncio} +BuildRequires: %{python_module pytest-mock} BuildRequires: %{python_module pytest-tornado} BuildRequires: %{python_module pytest} BuildRequires: %{python_module pytz} -BuildRequires: %{python_module setuptools >= 36.2.7} -BuildRequires: %{python_module setuptools_scm >= 1.7.0} -BuildRequires: %{python_module tornado} -BuildRequires: %{python_module tzlocal >= 2.0} +BuildRequires: %{python_module setuptools >= 77} +BuildRequires: %{python_module setuptools_scm >= 6.4} +BuildRequires: %{python_module tzlocal >= 3.0} BuildRequires: %{python_module wheel} BuildRequires: fdupes BuildRequires: python-rpm-macros + +Requires: python-attrs >= 21.3 Requires: python-pytz -Requires: python-tzlocal >= 2.0 +Requires: python-tzlocal >= 3.0 Recommends: python-SQLAlchemy >= 1.4 Recommends: python-Twisted Recommends: python-gevent @@ -49,17 +53,6 @@ Suggests: python-redis Suggests: python-tornado >= 4.3 BuildArch: noarch -%if %{with python2} -BuildRequires: python-funcsigs -BuildRequires: python-futures -BuildRequires: python-mock -BuildRequires: python-trollius -%endif -%ifpython2 -Requires: python-funcsigs -Requires: python-futures -Requires: python-trollius -%endif %python_subpackages %description @@ -85,7 +78,7 @@ %prep %setup -q -n apscheduler-%{version} -sed -i 's/--cov//' setup.cfg +sed -i 's/--cov//' pyproject.toml || true %build %pyproject_wheel @@ -95,13 +88,11 @@ %python_expand %fdupes %{buildroot}%{$python_sitelib} %check -# https://github.com/agronholm/apscheduler/issues/601 -%pytest -k 'not test_broken_pool' +%pytest -p asyncio -k "not (redis or mongodb or rethinkdb or zookeeper)" %files %{python_files} %license LICENSE.txt %doc README.rst -%doc examples/ %{python_sitelib}/apscheduler %{python_sitelib}/[Aa][Pp][Ss]cheduler-%{version}.dist-info ++++++ apscheduler-3.11.1.tar.gz -> apscheduler-3.11.2.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/.github/workflows/publish.yml new/apscheduler-3.11.2/.github/workflows/publish.yml --- old/apscheduler-3.11.1/.github/workflows/publish.yml 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/.github/workflows/publish.yml 2025-12-22 01:39:06.000000000 +0100 @@ -14,9 +14,9 @@ runs-on: ubuntu-latest environment: release steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.x - name: Install dependencies @@ -24,7 +24,7 @@ - name: Create packages run: python -m build - name: Archive packages - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: dist path: dist @@ -38,7 +38,10 @@ id-token: write steps: - name: Retrieve packages - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 + with: + name: dist + path: dist - name: Upload packages uses: pypa/gh-action-pypi-publish@release/v1 @@ -49,7 +52,7 @@ permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - id: changelog uses: agronholm/release-notes@v1 with: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/.pre-commit-config.yaml new/apscheduler-3.11.2/.pre-commit-config.yaml --- old/apscheduler-3.11.1/.pre-commit-config.yaml 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/.pre-commit-config.yaml 2025-12-22 01:39:06.000000000 +0100 @@ -5,7 +5,7 @@ # * Run "pre-commit install". repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-added-large-files - id: check-case-conflict @@ -20,14 +20,14 @@ - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.4 + rev: v0.14.8 hooks: - id: ruff args: [--fix, --show-fixes] - id: ruff-format - repo: https://github.com/codespell-project/codespell - rev: v2.3.0 + rev: v2.4.1 hooks: - id: codespell diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/PKG-INFO new/apscheduler-3.11.2/PKG-INFO --- old/apscheduler-3.11.1/PKG-INFO 2025-10-31 19:55:16.113325000 +0100 +++ new/apscheduler-3.11.2/PKG-INFO 2025-12-22 01:39:10.236482000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: APScheduler -Version: 3.11.1 +Version: 3.11.2 Summary: In-process task scheduler with Cron-like capabilities Author-email: Alex Grönholm <[email protected]> License: MIT @@ -47,6 +47,7 @@ Provides-Extra: test Requires-Dist: APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]; extra == "test" Requires-Dist: pytest; extra == "test" +Requires-Dist: pytest-timeout; extra == "test" Requires-Dist: anyio>=4.5.2; extra == "test" Requires-Dist: PySide6; (platform_python_implementation == "CPython" and python_version < "3.14") and extra == "test" Requires-Dist: gevent; python_version < "3.14" and extra == "test" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/docs/versionhistory.rst new/apscheduler-3.11.2/docs/versionhistory.rst --- old/apscheduler-3.11.1/docs/versionhistory.rst 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/docs/versionhistory.rst 2025-12-22 01:39:06.000000000 +0100 @@ -4,6 +4,13 @@ To find out how to migrate your application from a previous version of APScheduler, see the :doc:`migration section <migration>`. +**3.11.2** + +- Fixed an issue where a job using a ``CronTrigger`` scheduled in a repeated time + interval during DST transitions could cause the scheduler to get stuck in an infinite + loop + (#1021 <https://github.com/agronholm/apscheduler/issues/1021>_; PR by @soulofakuma) + **3.11.1** - Fixed ``scheduler.shutdown()`` not raising ``SchedulerNotRunning`` (or raising the diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/pyproject.toml new/apscheduler-3.11.2/pyproject.toml --- old/apscheduler-3.11.1/pyproject.toml 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/pyproject.toml 2025-12-22 01:39:06.000000000 +0100 @@ -51,6 +51,7 @@ test = [ "APScheduler[mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper,etcd]", "pytest", + "pytest-timeout", "anyio >= 4.5.2", "PySide6; python_implementation == 'CPython' and python_version < '3.14'", "gevent; python_version < '3.14'", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/src/APScheduler.egg-info/PKG-INFO new/apscheduler-3.11.2/src/APScheduler.egg-info/PKG-INFO --- old/apscheduler-3.11.1/src/APScheduler.egg-info/PKG-INFO 2025-10-31 19:55:16.000000000 +0100 +++ new/apscheduler-3.11.2/src/APScheduler.egg-info/PKG-INFO 2025-12-22 01:39:10.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: APScheduler -Version: 3.11.1 +Version: 3.11.2 Summary: In-process task scheduler with Cron-like capabilities Author-email: Alex Grönholm <[email protected]> License: MIT @@ -47,6 +47,7 @@ Provides-Extra: test Requires-Dist: APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]; extra == "test" Requires-Dist: pytest; extra == "test" +Requires-Dist: pytest-timeout; extra == "test" Requires-Dist: anyio>=4.5.2; extra == "test" Requires-Dist: PySide6; (platform_python_implementation == "CPython" and python_version < "3.14") and extra == "test" Requires-Dist: gevent; python_version < "3.14" and extra == "test" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/src/APScheduler.egg-info/requires.txt new/apscheduler-3.11.2/src/APScheduler.egg-info/requires.txt --- old/apscheduler-3.11.1/src/APScheduler.egg-info/requires.txt 2025-10-31 19:55:16.000000000 +0100 +++ new/apscheduler-3.11.2/src/APScheduler.egg-info/requires.txt 2025-12-22 01:39:10.000000000 +0100 @@ -30,6 +30,7 @@ [test] APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper] pytest +pytest-timeout anyio>=4.5.2 pytz diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/src/apscheduler/events.py new/apscheduler-3.11.2/src/apscheduler/events.py --- old/apscheduler-3.11.1/src/apscheduler/events.py 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/src/apscheduler/events.py 2025-12-22 01:39:06.000000000 +0100 @@ -1,26 +1,26 @@ __all__ = ( - "EVENT_SCHEDULER_STARTED", - "EVENT_SCHEDULER_SHUTDOWN", - "EVENT_SCHEDULER_PAUSED", - "EVENT_SCHEDULER_RESUMED", + "EVENT_ALL", + "EVENT_ALL_JOBS_REMOVED", "EVENT_EXECUTOR_ADDED", "EVENT_EXECUTOR_REMOVED", "EVENT_JOBSTORE_ADDED", "EVENT_JOBSTORE_REMOVED", - "EVENT_ALL_JOBS_REMOVED", "EVENT_JOB_ADDED", - "EVENT_JOB_REMOVED", - "EVENT_JOB_MODIFIED", - "EVENT_JOB_EXECUTED", "EVENT_JOB_ERROR", + "EVENT_JOB_EXECUTED", + "EVENT_JOB_MAX_INSTANCES", "EVENT_JOB_MISSED", + "EVENT_JOB_MODIFIED", + "EVENT_JOB_REMOVED", "EVENT_JOB_SUBMITTED", - "EVENT_JOB_MAX_INSTANCES", - "EVENT_ALL", - "SchedulerEvent", + "EVENT_SCHEDULER_PAUSED", + "EVENT_SCHEDULER_RESUMED", + "EVENT_SCHEDULER_SHUTDOWN", + "EVENT_SCHEDULER_STARTED", "JobEvent", "JobExecutionEvent", "JobSubmissionEvent", + "SchedulerEvent", ) @@ -76,7 +76,7 @@ self.alias = alias def __repr__(self): - return "<%s (code=%d)>" % (self.__class__.__name__, self.code) + return f"<self.__class__.__name__ (code={self.code})>" class JobEvent(SchedulerEvent): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/src/apscheduler/executors/base.py new/apscheduler-3.11.2/src/apscheduler/executors/base.py --- old/apscheduler-3.11.1/src/apscheduler/executors/base.py 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/src/apscheduler/executors/base.py 2025-12-22 01:39:06.000000000 +0100 @@ -17,8 +17,8 @@ class MaxInstancesReachedError(Exception): def __init__(self, job): super().__init__( - 'Job "%s" has already reached its maximum number of instances (%d)' - % (job.id, job.max_instances) + f'Job "{job.id}" has already reached its maximum number of instances ' + f"({job.max_instances})" ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/src/apscheduler/job.py new/apscheduler-3.11.2/src/apscheduler/job.py --- old/apscheduler-3.11.1/src/apscheduler/job.py 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/src/apscheduler/job.py 2025-12-22 01:39:06.000000000 +0100 @@ -1,4 +1,5 @@ from collections.abc import Iterable, Mapping +from datetime import timezone from inspect import isclass, ismethod from uuid import uuid4 @@ -12,6 +13,8 @@ ref_to_obj, ) +UTC = timezone.utc + class Job: """ @@ -38,21 +41,21 @@ """ __slots__ = ( - "_scheduler", + "__weakref__", "_jobstore_alias", - "id", - "trigger", + "_scheduler", + "args", + "coalesce", "executor", "func", "func_ref", - "args", + "id", "kwargs", - "name", - "misfire_grace_time", - "coalesce", "max_instances", + "misfire_grace_time", + "name", "next_run_time", - "__weakref__", + "trigger", ) def __init__(self, scheduler, id=None, **kwargs): @@ -145,7 +148,7 @@ """ run_times = [] next_run_time = self.next_run_time - while next_run_time and next_run_time <= now: + while next_run_time and next_run_time.astimezone(UTC) <= now.astimezone(UTC): run_times.append(next_run_time) next_run_time = self.trigger.get_next_fire_time(next_run_time, now) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/src/apscheduler/triggers/calendarinterval.py new/apscheduler-3.11.2/src/apscheduler/triggers/calendarinterval.py --- old/apscheduler-3.11.1/src/apscheduler/triggers/calendarinterval.py 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/src/apscheduler/triggers/calendarinterval.py 2025-12-22 01:39:06.000000000 +0100 @@ -65,15 +65,15 @@ """ __slots__ = ( - "years", - "months", - "weeks", + "_time", "days", - "start_date", "end_date", - "timezone", "jitter", - "_time", + "months", + "start_date", + "timezone", + "weeks", + "years", ) def __init__( @@ -183,4 +183,4 @@ fields.append(f"end_date='{self.end_date}'") fields.append(f"timezone={timezone_repr(self.timezone)!r}") - return f'{self.__class__.__name__}({", ".join(fields)})' + return f"{self.__class__.__name__}({', '.join(fields)})" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/src/apscheduler/triggers/combining.py new/apscheduler-3.11.2/src/apscheduler/triggers/combining.py --- old/apscheduler-3.11.1/src/apscheduler/triggers/combining.py 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/src/apscheduler/triggers/combining.py 2025-12-22 01:39:06.000000000 +0100 @@ -3,7 +3,7 @@ class BaseCombiningTrigger(BaseTrigger): - __slots__ = ("triggers", "jitter") + __slots__ = ("jitter", "triggers") def __init__(self, triggers, jitter=None): self.triggers = triggers diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/src/apscheduler/triggers/cron/__init__.py new/apscheduler-3.11.2/src/apscheduler/triggers/cron/__init__.py --- old/apscheduler-3.11.1/src/apscheduler/triggers/cron/__init__.py 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/src/apscheduler/triggers/cron/__init__.py 2025-12-22 01:39:06.000000000 +0100 @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from tzlocal import get_localzone @@ -16,8 +16,11 @@ convert_to_datetime, datetime_ceil, datetime_repr, + datetime_utc_add, ) +UTC = timezone.utc + class CronTrigger(BaseTrigger): """ @@ -62,7 +65,7 @@ "second": BaseField, } - __slots__ = "timezone", "start_date", "end_date", "fields", "jitter" + __slots__ = "end_date", "fields", "jitter", "start_date", "timezone" def __init__( self, @@ -183,9 +186,7 @@ i += 1 difference = datetime(**values) - dateval.replace(tzinfo=None) - dateval = datetime.fromtimestamp( - dateval.timestamp() + difference.total_seconds(), self.timezone - ) + dateval = datetime_utc_add(dateval, difference) return dateval, fieldnum def _set_field_value(self, dateval, fieldnum, new_value): @@ -202,19 +203,23 @@ return datetime(**values, tzinfo=self.timezone, fold=dateval.fold) def get_next_fire_time(self, previous_fire_time, now): - # If datetime is folded, cast in ISO format to ensure they advance correctly - if previous_fire_time and previous_fire_time.fold == 1: - previous_fire_time = datetime.fromisoformat(previous_fire_time.isoformat()) - - if now.fold == 1: - now = datetime.fromisoformat(now.isoformat()) + timedelta(microseconds=1) - if previous_fire_time: - start_date = min(now, previous_fire_time + timedelta(microseconds=1)) + start_date = min( + now.astimezone(UTC), + datetime_utc_add( + previous_fire_time, timedelta(microseconds=1) + ).astimezone(UTC), + ).astimezone(self.timezone) if start_date == previous_fire_time: - start_date += timedelta(microseconds=1) + start_date = datetime_utc_add(start_date, timedelta(microseconds=1)) else: - start_date = max(now, self.start_date) if self.start_date else now + start_date = ( + max(now.astimezone(UTC), self.start_date.astimezone(UTC)).astimezone( + self.timezone + ) + if self.start_date + else now + ) fieldnum = 0 next_date = datetime_ceil(start_date).astimezone(self.timezone) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/src/apscheduler/triggers/cron/expressions.py new/apscheduler-3.11.2/src/apscheduler/triggers/cron/expressions.py --- old/apscheduler-3.11.1/src/apscheduler/triggers/cron/expressions.py 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/src/apscheduler/triggers/cron/expressions.py 2025-12-22 01:39:06.000000000 +0100 @@ -2,10 +2,10 @@ __all__ = ( "AllExpression", + "LastDayOfMonthExpression", "RangeExpression", - "WeekdayRangeExpression", "WeekdayPositionExpression", - "LastDayOfMonthExpression", + "WeekdayRangeExpression", ) import re @@ -68,7 +68,7 @@ def __str__(self): if self.step: - return "*/%d" % self.step + return f"*/{self.step}" return "*" def __repr__(self): @@ -136,18 +136,18 @@ def __str__(self): if self.last != self.first and self.last is not None: - range = "%d-%d" % (self.first, self.last) + range = f"{self.first}-{self.last}" else: range = str(self.first) if self.step: - return "%s/%d" % (range, self.step) + return f"{range}/{self.step}" return range def __repr__(self): args = [str(self.first)] - if self.last != self.first and self.last is not None or self.step: + if (self.last != self.first and self.last is not None) or self.step: args.append(str(self.last)) if self.step: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/src/apscheduler/triggers/cron/fields.py new/apscheduler-3.11.2/src/apscheduler/triggers/cron/fields.py --- old/apscheduler-3.11.1/src/apscheduler/triggers/cron/fields.py 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/src/apscheduler/triggers/cron/fields.py 2025-12-22 01:39:06.000000000 +0100 @@ -1,13 +1,13 @@ """Fields represent CronTrigger options which map to :class:`~datetime.datetime` fields.""" __all__ = ( - "MIN_VALUES", - "MAX_VALUES", "DEFAULT_VALUES", + "MAX_VALUES", + "MIN_VALUES", "BaseField", - "WeekField", "DayOfMonthField", "DayOfWeekField", + "WeekField", ) import re diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/src/apscheduler/triggers/interval.py new/apscheduler-3.11.2/src/apscheduler/triggers/interval.py --- old/apscheduler-3.11.1/src/apscheduler/triggers/interval.py 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/src/apscheduler/triggers/interval.py 2025-12-22 01:39:06.000000000 +0100 @@ -29,12 +29,12 @@ """ __slots__ = ( - "timezone", - "start_date", "end_date", "interval", "interval_length", "jitter", + "start_date", + "timezone", ) def __init__( @@ -79,7 +79,7 @@ next_fire_time = self.start_date.timestamp() else: timediff = now.timestamp() - self.start_date.timestamp() - next_interval_num = int(ceil(timediff / self.interval_length)) + next_interval_num = ceil(timediff / self.interval_length) next_fire_time = ( self.start_date.timestamp() + self.interval_length * next_interval_num ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/src/apscheduler/util.py new/apscheduler-3.11.2/src/apscheduler/util.py --- old/apscheduler-3.11.1/src/apscheduler/util.py 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/src/apscheduler/util.py 2025-12-22 01:39:06.000000000 +0100 @@ -1,21 +1,21 @@ """This module contains several handy functions primarily meant for internal use.""" __all__ = ( - "asint", "asbool", + "asint", "astimezone", + "check_callable_args", "convert_to_datetime", - "datetime_to_utc_timestamp", - "utc_timestamp_to_datetime", "datetime_ceil", + "datetime_to_utc_timestamp", "get_callable_name", - "obj_to_ref", - "ref_to_obj", + "localize", "maybe_ref", - "check_callable_args", "normalize", - "localize", + "obj_to_ref", + "ref_to_obj", "undefined", + "utc_timestamp_to_datetime", ) import re @@ -35,6 +35,8 @@ else: from zoneinfo import ZoneInfo +UTC = timezone.utc + class _Undefined: def __nonzero__(self): @@ -236,10 +238,32 @@ """ if dateval.microsecond > 0: - return dateval + timedelta(seconds=1, microseconds=-dateval.microsecond) + return datetime_utc_add( + dateval, timedelta(seconds=1, microseconds=-dateval.microsecond) + ) + return dateval +def datetime_utc_add(dateval: datetime, tdelta: timedelta) -> datetime: + """ + Adds an timedelta to a datetime in UTC for correct datetime arithmetic across + Daylight Saving Time changes + + :param dateval: The date to add to + :type dateval: datetime + :param operand: The timedelta to add to the datetime + :type operand: timedelta + :return: The sum of the datetime and the timedelta + :rtype: datetime + """ + original_tz = dateval.tzinfo + if original_tz is None: + return dateval + tdelta + + return (dateval.astimezone(UTC) + tdelta).astimezone(original_tz) + + def datetime_repr(dateval): return dateval.strftime("%Y-%m-%d %H:%M:%S %Z") if dateval else "None" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/tests/test_job.py new/apscheduler-3.11.2/tests/test_job.py --- old/apscheduler-3.11.1/tests/test_job.py 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/tests/test_job.py 2025-12-22 01:39:06.000000000 +0100 @@ -1,4 +1,5 @@ import gc +import sys import weakref from datetime import datetime, timedelta from functools import partial @@ -11,6 +12,11 @@ from apscheduler.triggers.date import DateTrigger from apscheduler.util import localize +if sys.version_info < (3, 9): + from backports.zoneinfo import ZoneInfo +else: + from zoneinfo import ZoneInfo + def dummyfunc(): pass @@ -103,6 +109,32 @@ assert run_times == expected_times [email protected](5) +def test_get_run_times_dst_transition(create_job): + """Tests that Job._get_run_times does not run into an endless loop due to datetime comparison""" + timezone = ZoneInfo("US/Eastern") + next_run_time = datetime(2025, 11, 2, 1, 0, 10, tzinfo=timezone) + now = datetime(2025, 11, 2, 1, 0, 10, 10, tzinfo=timezone) + next_next_run_time = datetime(2025, 11, 2, 1, 0, 10, fold=1, tzinfo=timezone) + job = create_job( + trigger="cron", + trigger_args={"timezone": timezone, "hour": 1, "minute": 0, "second": 10}, + next_run_time=next_next_run_time, + func=dummyfunc, + ) + job.next_run_time = next_run_time + + run_times = job._get_run_times(now) + assert len(run_times) == 1 + assert str(run_times[0]) == str(next_run_time) + + run_times = job._get_run_times(now.replace(fold=1)) + assert len(run_times) == 2 + assert list(map(str, run_times)) == list( + map(str, [next_run_time, next_next_run_time]) + ) + + def test_private_modify_bad_id(job): """Tests that only strings are accepted for job IDs.""" del job.id diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/tests/test_jobstores.py new/apscheduler-3.11.2/tests/test_jobstores.py --- old/apscheduler-3.11.1/tests/test_jobstores.py 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/tests/test_jobstores.py 2025-12-22 01:39:06.000000000 +0100 @@ -297,7 +297,7 @@ """ jobs = [ - create_add_job(jobstore, dummy_job, datetime(2014, 2, 26), "job%d" % i) + create_add_job(jobstore, dummy_job, datetime(2014, 2, 26), f"job{i}") for i in range(3) ] jobs[index].next_run_time = ( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/tests/test_util.py new/apscheduler-3.11.2/tests/test_util.py --- old/apscheduler-3.11.1/tests/test_util.py 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/tests/test_util.py 2025-12-22 01:39:06.000000000 +0100 @@ -190,8 +190,7 @@ ValueError, convert_to_datetime, "2009-8-1", None, "argname" ) assert str(exc.value) == ( - 'The "tz" argument must be specified if argname has no timezone ' - "information" + 'The "tz" argument must be specified if argname has no timezone information' ) def test_text_timezone(self): @@ -460,8 +459,7 @@ func = eval("lambda x, *, y, z=1: None") exc = pytest.raises(ValueError, check_callable_args, func, [1], {}) assert str(exc.value) == ( - "The following keyword-only arguments have not been supplied in " - "kwargs: y" + "The following keyword-only arguments have not been supplied in kwargs: y" ) def test_wrapped_func(self): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apscheduler-3.11.1/tests/triggers/test_cron.py new/apscheduler-3.11.2/tests/triggers/test_cron.py --- old/apscheduler-3.11.1/tests/triggers/test_cron.py 2025-10-31 19:54:58.000000000 +0100 +++ new/apscheduler-3.11.2/tests/triggers/test_cron.py 2025-12-22 01:39:06.000000000 +0100 @@ -48,8 +48,7 @@ def test_cron_trigger_4(timezone): trigger = CronTrigger(year="2012", month="2", day="last", timezone=timezone) assert repr(trigger) == ( - "<CronTrigger (year='2012', month='2', day='last', " - "timezone='Europe/Berlin')>" + "<CronTrigger (year='2012', month='2', day='last', timezone='Europe/Berlin')>" ) start_date = localize(datetime(2012, 2, 1), timezone) correct_next_date = localize(datetime(2012, 2, 29), timezone) @@ -265,31 +264,59 @@ @pytest.mark.parametrize( - "trigger_args, start_date, start_date_fold, correct_next_date", + "trigger_args, start_date, start_date_fold, correct_next_date, correct_next_date_fold", [ - ({"hour": 8}, datetime(2013, 3, 9, 12), False, datetime(2013, 3, 10, 8)), - ({"hour": 8}, datetime(2013, 11, 2, 12), True, datetime(2013, 11, 3, 8)), + ({"hour": 8}, datetime(2013, 3, 9, 12), 0, datetime(2013, 3, 10, 8), 0), + ({"hour": 8}, datetime(2013, 11, 2, 12), 1, datetime(2013, 11, 3, 8), 0), + ( + {"hour": 1, "minute": 30}, + datetime(2013, 11, 3, 0, 30), + 0, + datetime(2013, 11, 3, 1, 30), + 0, + ), + ( + {"hour": 1, "minute": 30}, + datetime(2013, 11, 3, 1, 30, 5), + 0, + datetime(2013, 11, 3, 1, 30), + 1, + ), + ( + {"hour": 1, "minute": 30}, + datetime(2013, 11, 3, 1, 30, 5), + 1, + datetime(2013, 11, 4, 1, 30), + 0, + ), ( {"minute": "*/30"}, datetime(2013, 3, 10, 1, 35), 1, datetime(2013, 3, 10, 3), + 0, ), ( {"minute": "*/30"}, datetime(2013, 11, 3, 1, 35), 0, datetime(2013, 11, 3, 1), + 1, ), ], ids=[ "absolute_spring", "absolute_autumn", + "absolute_autumn_from_before_into_repeated_interval", + "absolute_autumn_from_repeated_into_repeated_interval", + "absolute_autumn_from_repeated_interval_to_after", "interval_spring", "interval_autumn", ], ) -def test_dst_change(trigger_args, start_date, start_date_fold, correct_next_date): +def test_dst_change( + trigger_args, start_date, start_date_fold, correct_next_date, correct_next_date_fold +): """ Making sure that CronTrigger works correctly when crossing the DST switch threshold. Note that you should explicitly compare datetimes as strings to avoid the internal datetime @@ -299,8 +326,16 @@ timezone = ZoneInfo("US/Eastern") trigger = CronTrigger(timezone=timezone, **trigger_args) start_date = start_date.replace(tzinfo=timezone, fold=start_date_fold) - correct_next_date = correct_next_date.replace(tzinfo=timezone, fold=1) + correct_next_date = correct_next_date.replace( + tzinfo=timezone, fold=correct_next_date_fold + ) assert str(trigger.get_next_fire_time(None, start_date)) == str(correct_next_date) + assert str(trigger.get_next_fire_time(start_date, start_date)) == str( + correct_next_date + ) + assert str(trigger.get_next_fire_time(start_date, correct_next_date)) == str( + correct_next_date + ) def test_dst_change_2(timezone): @@ -310,7 +345,7 @@ """ timezone = ZoneInfo("Europe/Helsinki") trigger = CronTrigger(minute=30, timezone=timezone) - start_date = datetime(2017, 10, 29, 3, 30, tzinfo=timezone, fold=1) + start_date = datetime(2017, 10, 29, 3, 30, 0, 5, tzinfo=timezone, fold=1) correct_next_date = datetime(2017, 10, 29, 4, 30, tzinfo=timezone, fold=0) assert str(trigger.get_next_fire_time(None, start_date)) == str(correct_next_date) assert str(trigger.get_next_fire_time(start_date, start_date)) == str(
