diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9e8d6dfb..52c9410f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,7 +14,7 @@ jobs:
strategy:
max-parallel: 6
matrix:
- python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12']
+ python-version: ['3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
diff --git a/README.rst b/README.rst
index a764cf6a..f3ba6657 100644
--- a/README.rst
+++ b/README.rst
@@ -17,7 +17,7 @@ Python job scheduling for humans. Run Python functions (or any other callable) p
- In-process scheduler for periodic jobs. No extra processes needed!
- Very lightweight and no external dependencies.
- Excellent test coverage.
-- Tested on Python and 3.7, 3.8, 3.9, 3.10, 3.11, 3.12
+- Tested on Python and 3.10, 3.11, 3.12, 3.13
Usage
-----
diff --git a/docs/faq.rst b/docs/faq.rst
index 98d65eeb..2fab5211 100644
--- a/docs/faq.rst
+++ b/docs/faq.rst
@@ -42,12 +42,6 @@ It might be that your IDE uses a different Python interpreter installation.
Still having problems? Use Google and StackOverflow before submitting an issue.
-ModuleNotFoundError: ModuleNotFoundError: No module named 'pytz'
-----------------------------------------------------------------
-
-This error happens when you try to set a timezone in ``.at()`` without having the `pytz `_ package installed.
-Pytz is a required dependency when working with timezones.
-To resolve this issue, install the ``pytz`` module by running ``pip install pytz``.
Does schedule support time zones?
---------------------------------
diff --git a/docs/index.rst b/docs/index.rst
index 966608aa..00b13059 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -17,7 +17,7 @@ Python job scheduling for humans. Run Python functions (or any other callable) p
- In-process scheduler for periodic jobs. No extra processes needed!
- Very lightweight and no external dependencies.
- Excellent test coverage.
-- Tested on Python 3.7, 3.8, 3.9, 3.10, 3.11 and 3.12
+- Tested on Python 3.10, 3.11, 3.12 and 3.13
:doc:`Example `
diff --git a/docs/installation.rst b/docs/installation.rst
index 6f15cccf..5069f9b8 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -6,7 +6,7 @@ Python version support
######################
We recommend using the latest version of Python.
-Schedule is tested on Python 3.7, 3.8, 3.9, 3.10, 3.11 and 3.12
+Schedule is tested on Python 3.10, 3.11, 3.12 and 3.13
Want to use Schedule on earlier Python versions? See the History.
@@ -14,9 +14,7 @@ Want to use Schedule on earlier Python versions? See the History.
Dependencies
############
-Schedule has 1 optional dependency:
-
-Only when you use ``.at()`` with a timezone, you must have `pytz `_ installed.
+Schedule has no external dependencies. Timezone support uses Python's built-in :mod:`zoneinfo` module.
Installation instructions
#########################
diff --git a/docs/timezones.rst b/docs/timezones.rst
index c72cdc8a..97af2f6a 100644
--- a/docs/timezones.rst
+++ b/docs/timezones.rst
@@ -6,12 +6,6 @@ Timezone in .at()
Schedule supports setting the job execution time in another timezone using the ``.at`` method.
-**To work with timezones** `pytz `_ **must be installed!** Get it:
-
-.. code-block:: bash
-
- pip install pytz
-
Timezones are only available in the ``.at`` function, like so:
.. code-block:: python
@@ -19,9 +13,9 @@ Timezones are only available in the ``.at`` function, like so:
# Pass a timezone as a string
schedule.every().day.at("12:42", "Europe/Amsterdam").do(job)
- # Pass an pytz timezone object
- from pytz import timezone
- schedule.every().friday.at("12:42", timezone("Africa/Lagos")).do(job)
+ # Pass a ZoneInfo object
+ from zoneinfo import ZoneInfo
+ schedule.every().friday.at("12:42", ZoneInfo("Africa/Lagos")).do(job)
Schedule uses the timezone to calculate the next runtime in local time.
All datetimes inside the library are stored `naive `_.
diff --git a/pyproject.toml b/pyproject.toml
index 8f8ab03e..0b6a562e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,15 +9,13 @@ dynamic = ["version", "classifiers", "keywords", "authors"]
readme = "README.rst"
license = {text = "MIT License"}
-requires-python = ">= 3.7"
+requires-python = ">= 3.10"
dependencies = []
maintainers = [
{name = "Sijmen Huizenga"}
]
-[project.optional-dependencies]
-timezone = ["pytz"]
[project.urls]
Documentation = "https://schedule.readthedocs.io"
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 7c12de0d..2e75c863 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -6,6 +6,4 @@ pytest-flake8
Sphinx
black==20.8b1
click==8.0.4
-mypy
-pytz
-types-pytz
\ No newline at end of file
+mypy
\ No newline at end of file
diff --git a/schedule/__init__.py b/schedule/__init__.py
index 8e12eeb7..8f3ed126 100644
--- a/schedule/__init__.py
+++ b/schedule/__init__.py
@@ -15,7 +15,7 @@
- A simple to use API for scheduling jobs.
- Very lightweight and no external dependencies.
- Excellent test coverage.
- - Tested on Python 3.7, 3.8, 3.9, 3.10, 3.11 and 3.12
+ - Tested on Python 3.10, 3.11, 3.12 and 3.13
Usage:
>>> import schedule
@@ -46,6 +46,7 @@
import re
import time
from typing import Set, List, Optional, Callable, Union
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
logger = logging.getLogger("schedule")
@@ -486,7 +487,7 @@ def at(self, time_str: str, tz: Optional[str] = None):
`every().minute.at(':30')`).
:param tz: The timezone that this timestamp refers to. Can be
- a string that can be parsed by pytz.timezone(), or a pytz.BaseTzInfo object
+ a string accepted by zoneinfo.ZoneInfo(), or a ZoneInfo object
:return: The invoked job instance
"""
@@ -496,16 +497,12 @@ def at(self, time_str: str, tz: Optional[str] = None):
)
if tz is not None:
- import pytz
-
if isinstance(tz, str):
- self.at_time_zone = pytz.timezone(tz) # type: ignore
- elif isinstance(tz, pytz.BaseTzInfo):
+ self.at_time_zone = ZoneInfo(tz) # type: ignore
+ elif isinstance(tz, ZoneInfo):
self.at_time_zone = tz
else:
- raise ScheduleValueError(
- "Timezone must be string or pytz.timezone object"
- )
+ raise ScheduleValueError("Timezone must be a string or ZoneInfo object")
if not isinstance(time_str, str):
raise TypeError("at() should be passed a string")
@@ -733,9 +730,7 @@ def _schedule_next_run(self) -> None:
while next_run <= now:
next_run += period
- next_run = self._correct_utc_offset(
- next_run, fixate_time=(self.at_time is not None)
- )
+ next_run = self._correct_utc_offset(next_run)
# To keep the api consistent with older versions, we have to set the 'next_run' to a naive timestamp in the local timezone.
# Because we want to stay backwards compatible with older versions.
@@ -766,55 +761,18 @@ def _move_to_at_time(self, moment: datetime.datetime) -> datetime.datetime:
# When we set the time elements, we might end up in a different UTC-offset than the current offset.
# This happens when we cross into or out of daylight saving time.
- moment = self._correct_utc_offset(moment, fixate_time=True)
+ moment = self._correct_utc_offset(moment)
return moment
- def _correct_utc_offset(
- self, moment: datetime.datetime, fixate_time: bool
- ) -> datetime.datetime:
+ def _correct_utc_offset(self, moment: datetime.datetime) -> datetime.datetime:
"""
Given a datetime, corrects any mistakes in the utc offset.
- This is similar to pytz' normalize, but adds the ability to attempt
- keeping the time-component at the same hour/minute/second.
"""
if self.at_time_zone is None:
return moment
- # Normalize corrects the utc-offset to match the timezone
- # For example: When a date&time&offset does not exist within a timezone,
- # the normalization will change the utc-offset to where it is valid.
- # It does this while keeping the moment in time the same, by moving the
- # time component opposite of the utc-change.
- offset_before_normalize = moment.utcoffset()
- moment = self.at_time_zone.normalize(moment)
- offset_after_normalize = moment.utcoffset()
-
- if offset_before_normalize == offset_after_normalize:
- # There was no change in the utc-offset, datetime didn't change.
- return moment
- # The utc-offset and time-component has changed
-
- if not fixate_time:
- # No need to fixate the time.
- return moment
-
- offset_diff = offset_after_normalize - offset_before_normalize
-
- # Adjust the time to reset the date-time to have the same HH:mm components
- moment -= offset_diff
-
- # Check if moving the timestamp back by the utc-offset-difference made it end up
- # in a moment that does not exist within the current timezone/utc-offset
- re_normalized_offset = self.at_time_zone.normalize(moment).utcoffset()
- if re_normalized_offset != offset_after_normalize:
- # We ended up in a DST Gap. The requested 'at' time does not exist
- # within the current timezone/utc-offset. As a best effort, we will
- # schedule the job 1 offset later than possible.
- # For example, if 02:23 does not exist (because DST moves from 02:00
- # to 03:00), this will schedule the job at 03:23.
- moment += offset_diff
- return moment
+ return moment.astimezone(datetime.timezone.utc).astimezone(self.at_time_zone)
def _is_overdue(self, when: datetime.datetime):
return self.cancel_after is not None and when > self.cancel_after
diff --git a/setup.py b/setup.py
index 3b340337..4a2e015b 100644
--- a/setup.py
+++ b/setup.py
@@ -43,13 +43,10 @@ def read_file(filename):
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.7",
- "Programming Language :: Python :: 3.8",
- "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Natural Language :: English",
],
- python_requires=">=3.7",
+ python_requires=">=3.10",
)
diff --git a/test_schedule.py b/test_schedule.py
index f497826d..cd89ba72 100644
--- a/test_schedule.py
+++ b/test_schedule.py
@@ -10,6 +10,8 @@
# "class already defined", and "too many public methods" messages:
# pylint: disable-msg=R0201,C0111,E0102,R0904,R0901
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
import schedule
from schedule import (
every,
@@ -98,11 +100,6 @@ def setUp(self):
schedule.clear()
def make_tz_mock_job(self, name=None):
- try:
- import pytz
- except ModuleNotFoundError:
- self.skipTest("pytz unavailable")
- return
return make_mock_job(name)
def test_time_units(self):
@@ -508,27 +505,12 @@ def test_next_run_time_day_end(self):
assert job.next_run.hour == 23
def test_next_run_time_hour_end(self):
- try:
- import pytz
- except ModuleNotFoundError:
- self.skipTest("pytz unavailable")
-
self.tst_next_run_time_hour_end(None, 0)
def test_next_run_time_hour_end_london(self):
- try:
- import pytz
- except ModuleNotFoundError:
- self.skipTest("pytz unavailable")
-
self.tst_next_run_time_hour_end("Europe/London", 0)
def test_next_run_time_hour_end_katmandu(self):
- try:
- import pytz
- except ModuleNotFoundError:
- self.skipTest("pytz unavailable")
-
# 12:00 in Berlin is 15:45 in Kathmandu
# this test schedules runs at :10 minutes, so job runs at
# 16:10 in Kathmandu, which is 13:25 in Berlin
@@ -558,19 +540,9 @@ def test_next_run_time_minute_end(self):
self.tst_next_run_time_minute_end(None)
def test_next_run_time_minute_end_london(self):
- try:
- import pytz
- except ModuleNotFoundError:
- self.skipTest("pytz unavailable")
-
self.tst_next_run_time_minute_end("Europe/London")
def test_next_run_time_minute_end_katmhandu(self):
- try:
- import pytz
- except ModuleNotFoundError:
- self.skipTest("pytz unavailable")
-
self.tst_next_run_time_minute_end("Asia/Kathmandu")
def tst_next_run_time_minute_end(self, tz):
@@ -632,14 +604,13 @@ def test_tz_daily_half_hour_offset(self):
def test_tz_daily_dst(self):
mock_job = self.make_tz_mock_job()
- import pytz
with mock_datetime(2022, 3, 20, 10, 0):
# Current Berlin time: 10:00 (local) (NOT during daylight saving)
# Current NY time: 04:00 (during daylight saving)
# Expected to run NY time: 10:30
# Next run Berlin time: 15:30
- tz = pytz.timezone("America/New_York")
+ tz = ZoneInfo("America/New_York")
next = every().day.at("10:30", tz).do(mock_job).next_run
assert next.hour == 15
assert next.minute == 30
@@ -1062,7 +1033,6 @@ def test_tz_weekly_large_interval_forward(self):
def test_tz_weekly_large_interval_backward(self):
mock_job = self.make_tz_mock_job()
- import pytz
# Testing scheduling large intervals that skip over clock move back
with mock_datetime(2024, 10, 25, 11, 0, 0, TZ_BERLIN):
@@ -1145,9 +1115,8 @@ def test_tz_daily_opposite_dst_change(self):
def test_tz_invalid_timezone_exceptions(self):
mock_job = self.make_tz_mock_job()
- import pytz
- with self.assertRaises(pytz.exceptions.UnknownTimeZoneError):
+ with self.assertRaises(ZoneInfoNotFoundError):
every().day.at("10:30", "FakeZone").do(mock_job)
with self.assertRaises(ScheduleValueError):
@@ -1156,33 +1125,29 @@ def test_tz_invalid_timezone_exceptions(self):
def test_align_utc_offset_no_timezone(self):
job = schedule.every().day.at("10:00").do(make_mock_job())
now = datetime.datetime(2024, 5, 11, 10, 30, 55, 0)
- aligned_time = job._correct_utc_offset(now, fixate_time=True)
+ aligned_time = job._correct_utc_offset(now)
self.assertEqual(now, aligned_time)
def setup_utc_offset_test(self):
- try:
- import pytz
- except ModuleNotFoundError:
- self.skipTest("pytz unavailable")
job = (
schedule.every()
.day.at("10:00", "Europe/Berlin")
.do(make_mock_job("tz-test"))
)
- tz = pytz.timezone("Europe/Berlin")
+ tz = ZoneInfo("Europe/Berlin")
return (job, tz)
def test_align_utc_offset_no_change(self):
(job, tz) = self.setup_utc_offset_test()
- now = tz.localize(datetime.datetime(2023, 3, 26, 1, 30))
- aligned_time = job._correct_utc_offset(now, fixate_time=False)
+ now = datetime.datetime(2023, 3, 26, 1, 30, tzinfo=tz)
+ aligned_time = job._correct_utc_offset(now)
self.assertEqual(now, aligned_time)
def test_align_utc_offset_with_dst_gap(self):
(job, tz) = self.setup_utc_offset_test()
# Non-existent time in Berlin timezone
- gap_time = tz.localize(datetime.datetime(2024, 3, 31, 2, 30, 0))
- aligned_time = job._correct_utc_offset(gap_time, fixate_time=True)
+ gap_time = datetime.datetime(2024, 3, 31, 2, 30, 0, tzinfo=tz)
+ aligned_time = job._correct_utc_offset(gap_time)
assert aligned_time.utcoffset() == datetime.timedelta(hours=2)
assert aligned_time.day == 31
@@ -1192,21 +1157,21 @@ def test_align_utc_offset_with_dst_gap(self):
def test_align_utc_offset_with_dst_fold(self):
(job, tz) = self.setup_utc_offset_test()
# This time exists twice, this is the first occurance
- overlap_time = tz.localize(datetime.datetime(2024, 10, 27, 2, 30))
- aligned_time = job._correct_utc_offset(overlap_time, fixate_time=False)
- # Since the time exists twice, no fixate_time flag should yield the first occurrence
- first_occurrence = tz.localize(datetime.datetime(2024, 10, 27, 2, 30, fold=0))
+ overlap_time = datetime.datetime(2024, 10, 27, 2, 30, fold=0, tzinfo=tz)
+ aligned_time = job._correct_utc_offset(overlap_time)
+ # Since the time exists twice, normalizing should yield the first occurrence
+ first_occurrence = datetime.datetime(2024, 10, 27, 2, 30, fold=0, tzinfo=tz)
self.assertEqual(first_occurrence, aligned_time)
def test_align_utc_offset_with_dst_fold_fixate_1(self):
(job, tz) = self.setup_utc_offset_test()
# This time exists twice, this is the 1st occurance
- overlap_time = tz.localize(datetime.datetime(2024, 10, 27, 1, 30), is_dst=True)
+ overlap_time = datetime.datetime(2024, 10, 27, 1, 30, fold=0, tzinfo=tz)
overlap_time += datetime.timedelta(
hours=1
) # puts it at 02:30+02:00 (Which exists once)
- aligned_time = job._correct_utc_offset(overlap_time, fixate_time=True)
+ aligned_time = job._correct_utc_offset(overlap_time)
# The time should not have moved, because the original time is valid
assert aligned_time.utcoffset() == datetime.timedelta(hours=2)
assert aligned_time.hour == 2
@@ -1216,10 +1181,10 @@ def test_align_utc_offset_with_dst_fold_fixate_1(self):
def test_align_utc_offset_with_dst_fold_fixate_2(self):
(job, tz) = self.setup_utc_offset_test()
# 02:30 exists twice, this is the 2nd occurance
- overlap_time = tz.localize(datetime.datetime(2024, 10, 27, 2, 30), is_dst=False)
+ overlap_time = datetime.datetime(2024, 10, 27, 2, 30, fold=1, tzinfo=tz)
# The time 2024-10-27 02:30:00+01:00 exists once
- aligned_time = job._correct_utc_offset(overlap_time, fixate_time=True)
+ aligned_time = job._correct_utc_offset(overlap_time)
# The time was valid, should not have been moved
assert aligned_time.utcoffset() == datetime.timedelta(hours=1)
assert aligned_time.hour == 2
@@ -1229,10 +1194,10 @@ def test_align_utc_offset_with_dst_fold_fixate_2(self):
def test_align_utc_offset_after_fold_fixate(self):
(job, tz) = self.setup_utc_offset_test()
# This time is 30 minutes after a folded hour.
- duplicate_time = tz.localize(datetime.datetime(2024, 10, 27, 2, 30))
+ duplicate_time = datetime.datetime(2024, 10, 27, 2, 30, fold=0, tzinfo=tz)
duplicate_time += datetime.timedelta(hours=1)
- aligned_time = job._correct_utc_offset(duplicate_time, fixate_time=False)
+ aligned_time = job._correct_utc_offset(duplicate_time)
assert aligned_time.utcoffset() == datetime.timedelta(hours=1)
assert aligned_time.hour == 3
diff --git a/tox.ini b/tox.ini
index b3d60a7f..5e6effeb 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,24 +1,20 @@
[tox]
-envlist = py3{7,8,9,10,11,12}{,-pytz}
+envlist = py3{10,11,12,13,14}
skip_missing_interpreters = true
[gh-actions]
python =
- 3.7: py37, py37-pytz
- 3.8: py38, py38-pytz
- 3.9: py39, py39-pytz
- 3.10: py310, py310-pytz
- 3.11: py311, py311-pytz
- 3.12: py312, py312-pytz
+ 3.10: py310
+ 3.11: py311
+ 3.12: py312
+ 3.13: py313
[testenv]
deps =
pytest
pytest-cov
mypy
- types-pytz
- pytz: pytz
commands =
py.test test_schedule.py schedule -v --cov schedule --cov-report term-missing
python -m mypy -p schedule --install-types --non-interactive