diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3b4f60be..d8aa8c22 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,6 +18,7 @@ jobs: config: # [Python version, tox env] - ["3.8", "py38"] + - ["3.8", "nopytz"] - ["3.9", "py39"] - ["3.10", "py310"] - ["pypy-3.9", "pypy3"] diff --git a/CHANGES.rst b/CHANGES.rst index 348f76b2..222066cd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -47,9 +47,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: diff --git a/setup.py b/setup.py index 86a97946..11e15ad0 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.9"', diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index 3f9424bf..28119db3 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -4,7 +4,11 @@ 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 from icalendar.timezone import tzp as _tzp @@ -13,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)''' @@ -76,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)) ]) @@ -193,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. @@ -207,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): @@ -228,12 +242,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.remove("zoneinfo") - assert tzp_names, "Use pytz_only or zoneinfo_only but not both!" + tzp_names = ["zoneinfo"] + if "pytz_only" in metafunc.fixturenames: + 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") @@ -243,13 +257,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.""" @@ -257,4 +277,6 @@ 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 + } 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 fe450a75..b1f34561 100644 --- a/src/icalendar/tests/test_with_doctest.py +++ b/src/icalendar/tests/test_with_doctest.py @@ -14,6 +14,8 @@ import os import pytest import importlib +import sys +import re HERE = os.path.dirname(__file__) or "." ICALENDAR_PATH = os.path.dirname(HERE) @@ -35,7 +37,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}" @@ -62,14 +69,22 @@ 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) + 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)}" def test_can_import_zoneinfo(env_for_doctest): """Allow importing zoneinfo for tests.""" + 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 e8b2d54a..44cec7a2 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ # to run for a specific environment, use ``tox -e ENVNAME`` [tox] -envlist = py38,py39,py310,py311,312,pypy3,docs +envlist = py38,py39,py310,py311,312,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