From e72a3a6d8334624550b379972b51cc7a5108dff3 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 24 Jun 2024 18:16:49 +0100 Subject: [PATCH 1/3] Remove pytz from dependencies Until now, icalendar was using pytz. This removes the dependency pytz. Tests run with pytz and without. The CI also runs the tests without pytz. The default implementation of icalendar uses zoneinfo. --- .github/workflows/tests.yml | 1 + setup.py | 1 - src/icalendar/tests/conftest.py | 50 ++++++++++++++----- .../tests/prop/test_identity_and_equality.py | 8 ++- src/icalendar/tests/test_equality.py | 5 +- .../tests/test_pytz_zoneinfo_integration.py | 22 ++++++-- src/icalendar/tests/test_with_doctest.py | 14 ++++-- src/icalendar/timezone/tzp.py | 2 +- tox.ini | 21 +++++++- 9 files changed, 96 insertions(+), 28 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 566a2904..fe1abf68 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,6 +19,7 @@ jobs: # [Python version, tox env] - ["3.7", "py37"] - ["3.8", "py38"] + - ["3.8", "nopytz"] - ["3.9", "py39"] - ["3.10", "py310"] - ["pypy-3.9", "pypy3"] diff --git a/setup.py b/setup.py index edbaa4f2..dce316a7 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,6 @@ tests_require = [] install_requires = [ 'python-dateutil', - 'pytz', # install requirements depending on python version # see https://www.python.org/dev/peps/pep-0508/#environment-markers 'backports.zoneinfo; python_version == "3.7" or python_version == "3.8"', diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index a1891b90..ae2168af 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -4,7 +4,10 @@ import zoneinfo import pytest import icalendar -import pytz +try: + import pytz +except ImportError: + pytz = None from datetime import datetime from dateutil import tz from icalendar.cal import Component, Calendar, Event, ComponentFactory @@ -14,6 +17,21 @@ import itertools import sys +HAS_PYTZ = pytz is not None +if HAS_PYTZ: + PYTZ_UTC = [ + pytz.utc, + pytz.timezone('UTC'), + ] + PYTZ_IN_TIMEZONE = [ + lambda dt, tzname: pytz.timezone(tzname).localize(dt), + ] + PYTZ_TZP = ["pytz"] +else: + PYTZ_UTC = [] + PYTZ_IN_TIMEZONE = [] + PYTZ_TZP = [] + class DataSource: '''A collection of parsed ICS elements (e.g calendars, timezones, events)''' @@ -77,17 +95,14 @@ def timezones(tzp): def events(tzp): return DataSource(EVENTS_FOLDER, icalendar.Event.from_ical) -@pytest.fixture(params=[ - pytz.utc, +@pytest.fixture(params=PYTZ_UTC + [ zoneinfo.ZoneInfo('UTC'), - pytz.timezone('UTC'), tz.UTC, tz.gettz('UTC')]) def utc(request, tzp): return request.param -@pytest.fixture(params=[ - lambda dt, tzname: pytz.timezone(tzname).localize(dt), +@pytest.fixture(params=PYTZ_IN_TIMEZONE + [ lambda dt, tzname: dt.replace(tzinfo=tz.gettz(tzname)), lambda dt, tzname: dt.replace(tzinfo=zoneinfo.ZoneInfo(tzname)) ]) @@ -194,7 +209,7 @@ def tzp(tzp_name): _tzp.use_default() -@pytest.fixture(params=["pytz", "zoneinfo"]) +@pytest.fixture(params=PYTZ_TZP + ["zoneinfo"]) def other_tzp(request, tzp): """This is annother timezone provider. @@ -229,12 +244,12 @@ def pytest_generate_tests(metafunc): See https://docs.pytest.org/en/6.2.x/example/parametrize.html#deferring-the-setup-of-parametrized-resources """ if "tzp_name" in metafunc.fixturenames: - tzp_names = ["pytz", "zoneinfo"] + tzp_names = PYTZ_TZP + ["zoneinfo"] if "zoneinfo_only" in metafunc.fixturenames: - tzp_names.remove("pytz") - if "pytz_only" in metafunc.fixturenames: + tzp_names = ["zoneinfo"] + if "pytz_only" in metafunc.fixturenames: tzp_names.remove("zoneinfo") - assert tzp_names, "Use pytz_only or zoneinfo_only but not both!" + assert not ("zoneinfo_only" in metafunc.fixturenames and "pytz_only" in metafunc.fixturenames), "Use pytz_only or zoneinfo_only but not both!" metafunc.parametrize("tzp_name", tzp_names, scope="module") @@ -244,13 +259,19 @@ def __repr__(self): return f"ZoneInfo(key={repr(self.key)})" -def test_print(obj): +def doctest_print(obj): """doctest print""" if isinstance(obj, bytes): obj = obj.decode("UTF-8") print(str(obj).strip().replace("\r\n", "\n").replace("\r", "\n")) +def doctest_import(name, *args, **kw): + """Replace the import mechanism to skip the whole doctest if we import pytz.""" + if name == "pytz": + return pytz + return __import__(name, *args, **kw) + @pytest.fixture() def env_for_doctest(monkeypatch): """Modify the environment to make doctests run.""" @@ -258,4 +279,7 @@ def env_for_doctest(monkeypatch): monkeypatch.setattr(zoneinfo, "ZoneInfo", DoctestZoneInfo) from icalendar.timezone.zoneinfo import ZONEINFO monkeypatch.setattr(ZONEINFO, "utc", zoneinfo.ZoneInfo("UTC")) - return {"print": test_print} + return { + "print": doctest_print, + "__import__": doctest_import, + } diff --git a/src/icalendar/tests/prop/test_identity_and_equality.py b/src/icalendar/tests/prop/test_identity_and_equality.py index 8e5682d0..c2aed769 100644 --- a/src/icalendar/tests/prop/test_identity_and_equality.py +++ b/src/icalendar/tests/prop/test_identity_and_equality.py @@ -2,7 +2,10 @@ from icalendar import vDDDTypes from datetime import datetime, date, time from icalendar.timezone.zoneinfo import zoneinfo -import pytz +try: + import pytz +except ImportError: + pytz = None from dateutil import tz import pytest from copy import deepcopy @@ -10,7 +13,6 @@ vDDDTypes_list = [ - vDDDTypes(pytz.timezone('EST').localize(datetime(year=2022, month=7, day=22, hour=12, minute=7))), vDDDTypes(datetime(year=2022, month=7, day=22, hour=12, minute=7, tzinfo=zoneinfo.ZoneInfo("Europe/London"))), vDDDTypes(datetime(year=2022, month=7, day=22, hour=12, minute=7)), vDDDTypes(datetime(year=2022, month=7, day=22, hour=12, minute=7, tzinfo=tz.UTC)), @@ -18,6 +20,8 @@ vDDDTypes(date(year=2022, month=7, day=23)), vDDDTypes(time(hour=22, minute=7, second=2)) ] +if pytz: + vDDDTypes_list.append(vDDDTypes(pytz.timezone('EST').localize(datetime(year=2022, month=7, day=22, hour=12, minute=7))),) def identity(x): return x diff --git a/src/icalendar/tests/test_equality.py b/src/icalendar/tests/test_equality.py index 0f55008d..8dadb109 100644 --- a/src/icalendar/tests/test_equality.py +++ b/src/icalendar/tests/test_equality.py @@ -1,6 +1,9 @@ """Test the equality and inequality of components.""" import copy -import pytz +try: + import pytz +except ImportError: + pytz = None from icalendar.prop import * from datetime import datetime, date, timedelta import pytest diff --git a/src/icalendar/tests/test_pytz_zoneinfo_integration.py b/src/icalendar/tests/test_pytz_zoneinfo_integration.py index c9f70b9a..372c351c 100644 --- a/src/icalendar/tests/test_pytz_zoneinfo_integration.py +++ b/src/icalendar/tests/test_pytz_zoneinfo_integration.py @@ -2,19 +2,31 @@ These are mostly located in icalendar.timezone. """ -import pytz +try: + import pytz + from icalendar.timezone.pytz import PYTZ +except ImportError: + pytz = None from icalendar.timezone.zoneinfo import zoneinfo, ZONEINFO from dateutil.tz.tz import _tzicalvtz -from icalendar.timezone.pytz import PYTZ import pytest import copy import pickle from dateutil.rrule import rrule, MONTHLY from datetime import datetime +if pytz: + PYTZ_TIMEZONES = pytz.all_timezones + TZP_ = [PYTZ(), ZONEINFO()] + NEW_TZP_NAME = ["pytz", "zoneinfo"] +else: + PYTZ_TIMEZONES = [] + TZP_ = [ZONEINFO()] + NEW_TZP_NAME = ["zoneinfo"] -@pytest.mark.parametrize("tz_name", pytz.all_timezones + list(zoneinfo.available_timezones())) -@pytest.mark.parametrize("tzp_", [PYTZ(), ZONEINFO()]) + +@pytest.mark.parametrize("tz_name", PYTZ_TIMEZONES + list(zoneinfo.available_timezones())) +@pytest.mark.parametrize("tzp_", TZP_) def test_timezone_names_are_known(tz_name, tzp_): """Make sure that all timezones are understood.""" if tz_name in ("Factory", "localtime"): @@ -55,7 +67,7 @@ def test_cache_reuse_timezone_cache(tzp, timezones): assert tzp1 is tzp.timezone("custom_Pacific/Fiji"), "Cache is not replaced." -@pytest.mark.parametrize("new_tzp_name", ["pytz", "zoneinfo"]) +@pytest.mark.parametrize("new_tzp_name", NEW_TZP_NAME) def test_cache_is_emptied_when_tzp_is_switched(tzp, timezones, new_tzp_name): """Make sure we do not reuse the timezones created when we switch the provider.""" tzp.cache_timezone_component(timezones.pacific_fiji) diff --git a/src/icalendar/tests/test_with_doctest.py b/src/icalendar/tests/test_with_doctest.py index 59660fbc..4b053907 100644 --- a/src/icalendar/tests/test_with_doctest.py +++ b/src/icalendar/tests/test_with_doctest.py @@ -14,6 +14,7 @@ import os import pytest import importlib +import sys HERE = os.path.dirname(__file__) or "." ICALENDAR_PATH = os.path.dirname(HERE) @@ -35,7 +36,12 @@ def test_this_module_is_among_them(): @pytest.mark.parametrize("module_name", MODULE_NAMES) def test_docstring_of_python_file(module_name): """This test runs doctest on the Python module.""" - module = importlib.import_module(module_name) + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError as e: + if e.name == "pytz": + pytest.skip("pytz is not installed, skipping this module.") + raise test_result = doctest.testmod(module, name=module_name) assert test_result.failed == 0, f"{test_result.failed} errors in {module_name}" @@ -68,11 +74,11 @@ def test_documentation_file(document, zoneinfo_only, env_for_doctest): functions are also replaced to work. """ test_result = doctest.testfile(document, module_relative=False, globs=env_for_doctest) + if env_for_doctest.get("pytz") is None: + pytest.skip("pytz was imported but could not be used.") assert test_result.failed == 0, f"{test_result.failed} errors in {os.path.basename(document)}" def test_can_import_zoneinfo(env_for_doctest): """Allow importing zoneinfo for tests.""" - import pytz - import zoneinfo - from dateutil import tz + assert "zoneinfo" in sys.modules diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index 698fdbcb..48cc1e4b 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -8,7 +8,7 @@ from dateutil.rrule import rrule -DEFAULT_TIMEZONE_PROVIDER = "pytz" +DEFAULT_TIMEZONE_PROVIDER = "zoneinfo" class TZP: diff --git a/tox.ini b/tox.ini index 7572fe7a..24880ef8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ # to run for a specific environment, use ``tox -e ENVNAME`` [tox] -envlist = py37,py38,py39,py310,py311,pypy3,docs +envlist = py37,py38,py39,py310,py311,pypy3,docs,nopytz # Note: the 'docs' env creates a 'build' directory which may interfere in strange ways # with the other environments. You might see this when you run the tests in parallel. # See https://github.com/collective/icalendar/pull/359#issuecomment-1214150269 @@ -11,11 +11,30 @@ deps = pytest coverage hypothesis + pytz commands = coverage run --source=src/icalendar --omit=*/tests/hypothesis/* --omit=*/tests/fuzzed/* --module pytest [] coverage report coverage html +[testenv:nopytz] +# install with dependencies +usedevelop = False +# use lowest version +basepython = python3.8 +allowlist_externals = + rm +deps = + setuptools>=70.1.0 + pytest + coverage + hypothesis +commands = + rm -rf build # do not mess up import + coverage run --source=src/icalendar --omit=*/tests/hypothesis/* --omit=*/tests/fuzzed/* --module pytest [] + coverage report + coverage html + [testenv:docs] deps = -r {toxinidir}/requirements_docs.txt From c981e4d9b03384caaa8fda4c51fc53465185adf8 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 24 Jun 2024 18:19:04 +0100 Subject: [PATCH 2/3] log changes --- CHANGES.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index dd67432a..1dcd1c41 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -46,9 +46,14 @@ Breaking changes: - Remove untested and broken ``LocalTimezone`` and ``FixedOffset`` tzinfo sub-classes, see `Issue 67 `_ +- Remove ``pytz`` as a dependency of ``icalendar``. If you require ``pytz``, + add it to your dependency list or install it additionally with:: + + pip install icalendar>=6.0.0 pytz + New features: -- ... +- Add function ``icalendar.use_pytz()``. Bug fixes: From 45adb836c4432f26fbfbf367350148bbd9eeb9e0 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 24 Jun 2024 19:15:49 +0100 Subject: [PATCH 3/3] Run doctest with zoneinfo and skip them if they use pytz when pytz is not installed --- src/icalendar/tests/conftest.py | 15 ++++++--------- src/icalendar/tests/test_with_doctest.py | 14 ++++++++++---- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index ae2168af..54c9563c 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -223,15 +223,13 @@ def other_tzp(request, tzp): @pytest.fixture() def pytz_only(tzp): """Skip tests that are not running under pytz.""" - if not tzp.uses_pytz(): - pytest.skip("Not using pytz. Skipping this test.") + assert tzp.uses_pytz() @pytest.fixture() -def zoneinfo_only(tzp): - """Skip tests that are not running under pytz.""" - if not tzp.uses_zoneinfo(): - pytest.skip("Not using zoneinfo. Skipping this test.") +def zoneinfo_only(tzp, request, tzp_name): + """Skip tests that are not running under zoneinfo.""" + assert tzp.uses_zoneinfo() def pytest_generate_tests(metafunc): @@ -248,7 +246,7 @@ def pytest_generate_tests(metafunc): if "zoneinfo_only" in metafunc.fixturenames: tzp_names = ["zoneinfo"] if "pytz_only" in metafunc.fixturenames: - tzp_names.remove("zoneinfo") + tzp_names = PYTZ_TZP assert not ("zoneinfo_only" in metafunc.fixturenames and "pytz_only" in metafunc.fixturenames), "Use pytz_only or zoneinfo_only but not both!" metafunc.parametrize("tzp_name", tzp_names, scope="module") @@ -280,6 +278,5 @@ def env_for_doctest(monkeypatch): from icalendar.timezone.zoneinfo import ZONEINFO monkeypatch.setattr(ZONEINFO, "utc", zoneinfo.ZoneInfo("UTC")) return { - "print": doctest_print, - "__import__": doctest_import, + "print": doctest_print } diff --git a/src/icalendar/tests/test_with_doctest.py b/src/icalendar/tests/test_with_doctest.py index 4b053907..316939bf 100644 --- a/src/icalendar/tests/test_with_doctest.py +++ b/src/icalendar/tests/test_with_doctest.py @@ -15,6 +15,7 @@ import pytest import importlib import sys +import re HERE = os.path.dirname(__file__) or "." ICALENDAR_PATH = os.path.dirname(HERE) @@ -68,14 +69,19 @@ def test_files_is_included(filename): @pytest.mark.parametrize("document", DOCUMENT_PATHS) -def test_documentation_file(document, zoneinfo_only, env_for_doctest): +def test_documentation_file(document, zoneinfo_only, env_for_doctest, tzp): """This test runs doctest on a documentation file. functions are also replaced to work. """ - test_result = doctest.testfile(document, module_relative=False, globs=env_for_doctest) - if env_for_doctest.get("pytz") is None: - pytest.skip("pytz was imported but could not be used.") + try: + test_result = doctest.testfile(document, module_relative=False, globs=env_for_doctest, raise_on_error=True) + except doctest.UnexpectedException as e: + ty, err, tb = e.exc_info + if issubclass(ty, ModuleNotFoundError) and err.name == "pytz": + pytest.skip("pytz not installed, skipping this file.") + finally: + tzp.use_zoneinfo() assert test_result.failed == 0, f"{test_result.failed} errors in {os.path.basename(document)}"