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