summaryrefslogtreecommitdiffstats
path: root/Lib
diff options
context:
space:
mode:
authorPaul Ganssle <1377457+pganssle@users.noreply.github.com>2023-05-12 12:35:53 (GMT)
committerGitHub <noreply@github.com>2023-05-12 12:35:53 (GMT)
commitd50c37d8adb2d2da9808089d959ca7d6791ac59f (patch)
treedf2daad96229ee0152ae38e5fb6060c03d5e4e09 /Lib
parent45f5aa8fc73acf516071d52ef8213532f0381316 (diff)
downloadcpython-d50c37d8adb2d2da9808089d959ca7d6791ac59f.zip
cpython-d50c37d8adb2d2da9808089d959ca7d6791ac59f.tar.gz
cpython-d50c37d8adb2d2da9808089d959ca7d6791ac59f.tar.bz2
GH-86275: Implementation of hypothesis stubs for property-based tests, with zoneinfo tests (#22863)
These are stubs to be used for adding hypothesis (https://hypothesis.readthedocs.io/en/latest/) tests to the standard library. When the tests are run in an environment where `hypothesis` and its various dependencies are not installed, the stubs will turn any tests with examples into simple parameterized tests and any tests without examples are skipped. It also adds hypothesis tests for the `zoneinfo` module, and a Github Actions workflow to run the hypothesis tests as a non-required CI job. The full hypothesis interface is not stubbed out — missing stubs can be added as necessary. Co-authored-by: Zac Hatfield-Dodds <zac.hatfield.dodds@gmail.com>
Diffstat (limited to 'Lib')
-rw-r--r--Lib/test/libregrtest/save_env.py4
-rw-r--r--Lib/test/support/_hypothesis_stubs/__init__.py111
-rw-r--r--Lib/test/support/_hypothesis_stubs/_helpers.py43
-rw-r--r--Lib/test/support/_hypothesis_stubs/strategies.py91
-rw-r--r--Lib/test/support/hypothesis_helper.py4
-rw-r--r--Lib/test/test_zoneinfo/__init__.py1
-rw-r--r--Lib/test/test_zoneinfo/test_zoneinfo_property.py368
7 files changed, 621 insertions, 1 deletions
diff --git a/Lib/test/libregrtest/save_env.py b/Lib/test/libregrtest/save_env.py
index cc5870ab..c7801b7 100644
--- a/Lib/test/libregrtest/save_env.py
+++ b/Lib/test/libregrtest/save_env.py
@@ -257,8 +257,10 @@ class saved_test_environment:
sysconfig._INSTALL_SCHEMES.update(saved[2])
def get_files(self):
+ # XXX: Maybe add an allow-list here?
return sorted(fn + ('/' if os.path.isdir(fn) else '')
- for fn in os.listdir())
+ for fn in os.listdir()
+ if not fn.startswith(".hypothesis"))
def restore_files(self, saved_value):
fn = os_helper.TESTFN
if fn not in saved_value and (fn + '/') not in saved_value:
diff --git a/Lib/test/support/_hypothesis_stubs/__init__.py b/Lib/test/support/_hypothesis_stubs/__init__.py
new file mode 100644
index 0000000..6ba5bb8
--- /dev/null
+++ b/Lib/test/support/_hypothesis_stubs/__init__.py
@@ -0,0 +1,111 @@
+from enum import Enum
+import functools
+import unittest
+
+__all__ = [
+ "given",
+ "example",
+ "assume",
+ "reject",
+ "register_random",
+ "strategies",
+ "HealthCheck",
+ "settings",
+ "Verbosity",
+]
+
+from . import strategies
+
+
+def given(*_args, **_kwargs):
+ def decorator(f):
+ if examples := getattr(f, "_examples", []):
+
+ @functools.wraps(f)
+ def test_function(self):
+ for example_args, example_kwargs in examples:
+ with self.subTest(*example_args, **example_kwargs):
+ f(self, *example_args, **example_kwargs)
+
+ else:
+ # If we have found no examples, we must skip the test. If @example
+ # is applied after @given, it will re-wrap the test to remove the
+ # skip decorator.
+ test_function = unittest.skip(
+ "Hypothesis required for property test with no " +
+ "specified examples"
+ )(f)
+
+ test_function._given = True
+ return test_function
+
+ return decorator
+
+
+def example(*args, **kwargs):
+ if bool(args) == bool(kwargs):
+ raise ValueError("Must specify exactly one of *args or **kwargs")
+
+ def decorator(f):
+ base_func = getattr(f, "__wrapped__", f)
+ if not hasattr(base_func, "_examples"):
+ base_func._examples = []
+
+ base_func._examples.append((args, kwargs))
+
+ if getattr(f, "_given", False):
+ # If the given decorator is below all the example decorators,
+ # it would be erroneously skipped, so we need to re-wrap the new
+ # base function.
+ f = given()(base_func)
+
+ return f
+
+ return decorator
+
+
+def assume(condition):
+ if not condition:
+ raise unittest.SkipTest("Unsatisfied assumption")
+ return True
+
+
+def reject():
+ assume(False)
+
+
+def register_random(*args, **kwargs):
+ pass # pragma: no cover
+
+
+def settings(*args, **kwargs):
+ return lambda f: f # pragma: nocover
+
+
+class HealthCheck(Enum):
+ data_too_large = 1
+ filter_too_much = 2
+ too_slow = 3
+ return_value = 5
+ large_base_example = 7
+ not_a_test_method = 8
+
+ @classmethod
+ def all(cls):
+ return list(cls)
+
+
+class Verbosity(Enum):
+ quiet = 0
+ normal = 1
+ verbose = 2
+ debug = 3
+
+
+class Phase(Enum):
+ explicit = 0
+ reuse = 1
+ generate = 2
+ target = 3
+ shrink = 4
+ explain = 5
diff --git a/Lib/test/support/_hypothesis_stubs/_helpers.py b/Lib/test/support/_hypothesis_stubs/_helpers.py
new file mode 100644
index 0000000..3f6244e
--- /dev/null
+++ b/Lib/test/support/_hypothesis_stubs/_helpers.py
@@ -0,0 +1,43 @@
+# Stub out only the subset of the interface that we actually use in our tests.
+class StubClass:
+ def __init__(self, *args, **kwargs):
+ self.__stub_args = args
+ self.__stub_kwargs = kwargs
+ self.__repr = None
+
+ def _with_repr(self, new_repr):
+ new_obj = self.__class__(*self.__stub_args, **self.__stub_kwargs)
+ new_obj.__repr = new_repr
+ return new_obj
+
+ def __repr__(self):
+ if self.__repr is not None:
+ return self.__repr
+
+ argstr = ", ".join(self.__stub_args)
+ kwargstr = ", ".join(f"{kw}={val}" for kw, val in self.__stub_kwargs.items())
+
+ in_parens = argstr
+ if kwargstr:
+ in_parens += ", " + kwargstr
+
+ return f"{self.__class__.__qualname__}({in_parens})"
+
+
+def stub_factory(klass, name, *, with_repr=None, _seen={}):
+ if (klass, name) not in _seen:
+
+ class Stub(klass):
+ def __init__(self, *args, **kwargs):
+ super().__init__()
+ self.__stub_args = args
+ self.__stub_kwargs = kwargs
+
+ Stub.__name__ = name
+ Stub.__qualname__ = name
+ if with_repr is not None:
+ Stub._repr = None
+
+ _seen.setdefault((klass, name, with_repr), Stub)
+
+ return _seen[(klass, name, with_repr)]
diff --git a/Lib/test/support/_hypothesis_stubs/strategies.py b/Lib/test/support/_hypothesis_stubs/strategies.py
new file mode 100644
index 0000000..d2b885d
--- /dev/null
+++ b/Lib/test/support/_hypothesis_stubs/strategies.py
@@ -0,0 +1,91 @@
+import functools
+
+from ._helpers import StubClass, stub_factory
+
+
+class StubStrategy(StubClass):
+ def __make_trailing_repr(self, transformation_name, func):
+ func_name = func.__name__ or repr(func)
+ return f"{self!r}.{transformation_name}({func_name})"
+
+ def map(self, pack):
+ return self._with_repr(self.__make_trailing_repr("map", pack))
+
+ def flatmap(self, expand):
+ return self._with_repr(self.__make_trailing_repr("flatmap", expand))
+
+ def filter(self, condition):
+ return self._with_repr(self.__make_trailing_repr("filter", condition))
+
+ def __or__(self, other):
+ new_repr = f"one_of({self!r}, {other!r})"
+ return self._with_repr(new_repr)
+
+
+_STRATEGIES = {
+ "binary",
+ "booleans",
+ "builds",
+ "characters",
+ "complex_numbers",
+ "composite",
+ "data",
+ "dates",
+ "datetimes",
+ "decimals",
+ "deferred",
+ "dictionaries",
+ "emails",
+ "fixed_dictionaries",
+ "floats",
+ "fractions",
+ "from_regex",
+ "from_type",
+ "frozensets",
+ "functions",
+ "integers",
+ "iterables",
+ "just",
+ "lists",
+ "none",
+ "nothing",
+ "one_of",
+ "permutations",
+ "random_module",
+ "randoms",
+ "recursive",
+ "register_type_strategy",
+ "runner",
+ "sampled_from",
+ "sets",
+ "shared",
+ "slices",
+ "timedeltas",
+ "times",
+ "text",
+ "tuples",
+ "uuids",
+}
+
+__all__ = sorted(_STRATEGIES)
+
+
+def composite(f):
+ strategy = stub_factory(StubStrategy, f.__name__)
+
+ @functools.wraps(f)
+ def inner(*args, **kwargs):
+ return strategy(*args, **kwargs)
+
+ return inner
+
+
+def __getattr__(name):
+ if name not in _STRATEGIES:
+ raise AttributeError(f"Unknown attribute {name}")
+
+ return stub_factory(StubStrategy, f"hypothesis.strategies.{name}")
+
+
+def __dir__():
+ return __all__
diff --git a/Lib/test/support/hypothesis_helper.py b/Lib/test/support/hypothesis_helper.py
new file mode 100644
index 0000000..76bd249
--- /dev/null
+++ b/Lib/test/support/hypothesis_helper.py
@@ -0,0 +1,4 @@
+try:
+ import hypothesis
+except ImportError:
+ from . import _hypothesis_stubs as hypothesis
diff --git a/Lib/test/test_zoneinfo/__init__.py b/Lib/test/test_zoneinfo/__init__.py
index 98cc441..c3ea567 100644
--- a/Lib/test/test_zoneinfo/__init__.py
+++ b/Lib/test/test_zoneinfo/__init__.py
@@ -1 +1,2 @@
from .test_zoneinfo import *
+from .test_zoneinfo_property import *
diff --git a/Lib/test/test_zoneinfo/test_zoneinfo_property.py b/Lib/test/test_zoneinfo/test_zoneinfo_property.py
new file mode 100644
index 0000000..feaa77f
--- /dev/null
+++ b/Lib/test/test_zoneinfo/test_zoneinfo_property.py
@@ -0,0 +1,368 @@
+import contextlib
+import datetime
+import os
+import pickle
+import unittest
+import zoneinfo
+
+from test.support.hypothesis_helper import hypothesis
+
+import test.test_zoneinfo._support as test_support
+
+ZoneInfoTestBase = test_support.ZoneInfoTestBase
+
+py_zoneinfo, c_zoneinfo = test_support.get_modules()
+
+UTC = datetime.timezone.utc
+MIN_UTC = datetime.datetime.min.replace(tzinfo=UTC)
+MAX_UTC = datetime.datetime.max.replace(tzinfo=UTC)
+ZERO = datetime.timedelta(0)
+
+
+def _valid_keys():
+ """Get available time zones, including posix/ and right/ directories."""
+ from importlib import resources
+
+ available_zones = sorted(zoneinfo.available_timezones())
+ TZPATH = zoneinfo.TZPATH
+
+ def valid_key(key):
+ for root in TZPATH:
+ key_file = os.path.join(root, key)
+ if os.path.exists(key_file):
+ return True
+
+ components = key.split("/")
+ package_name = ".".join(["tzdata.zoneinfo"] + components[:-1])
+ resource_name = components[-1]
+
+ try:
+ return resources.files(package_name).joinpath(resource_name).is_file()
+ except ModuleNotFoundError:
+ return False
+
+ # This relies on the fact that dictionaries maintain insertion order — for
+ # shrinking purposes, it is preferable to start with the standard version,
+ # then move to the posix/ version, then to the right/ version.
+ out_zones = {"": available_zones}
+ for prefix in ["posix", "right"]:
+ prefix_out = []
+ for key in available_zones:
+ prefix_key = f"{prefix}/{key}"
+ if valid_key(prefix_key):
+ prefix_out.append(prefix_key)
+
+ out_zones[prefix] = prefix_out
+
+ output = []
+ for keys in out_zones.values():
+ output.extend(keys)
+
+ return output
+
+
+VALID_KEYS = _valid_keys()
+if not VALID_KEYS:
+ raise unittest.SkipTest("No time zone data available")
+
+
+def valid_keys():
+ return hypothesis.strategies.sampled_from(VALID_KEYS)
+
+
+KEY_EXAMPLES = [
+ "Africa/Abidjan",
+ "Africa/Casablanca",
+ "America/Los_Angeles",
+ "America/Santiago",
+ "Asia/Tokyo",
+ "Australia/Sydney",
+ "Europe/Dublin",
+ "Europe/Lisbon",
+ "Europe/London",
+ "Pacific/Kiritimati",
+ "UTC",
+]
+
+
+def add_key_examples(f):
+ for key in KEY_EXAMPLES:
+ f = hypothesis.example(key)(f)
+ return f
+
+
+class ZoneInfoTest(ZoneInfoTestBase):
+ module = py_zoneinfo
+
+ @hypothesis.given(key=valid_keys())
+ @add_key_examples
+ def test_str(self, key):
+ zi = self.klass(key)
+ self.assertEqual(str(zi), key)
+
+ @hypothesis.given(key=valid_keys())
+ @add_key_examples
+ def test_key(self, key):
+ zi = self.klass(key)
+
+ self.assertEqual(zi.key, key)
+
+ @hypothesis.given(
+ dt=hypothesis.strategies.one_of(
+ hypothesis.strategies.datetimes(), hypothesis.strategies.times()
+ )
+ )
+ @hypothesis.example(dt=datetime.datetime.min)
+ @hypothesis.example(dt=datetime.datetime.max)
+ @hypothesis.example(dt=datetime.datetime(1970, 1, 1))
+ @hypothesis.example(dt=datetime.datetime(2039, 1, 1))
+ @hypothesis.example(dt=datetime.time(0))
+ @hypothesis.example(dt=datetime.time(12, 0))
+ @hypothesis.example(dt=datetime.time(23, 59, 59, 999999))
+ def test_utc(self, dt):
+ zi = self.klass("UTC")
+ dt_zi = dt.replace(tzinfo=zi)
+
+ self.assertEqual(dt_zi.utcoffset(), ZERO)
+ self.assertEqual(dt_zi.dst(), ZERO)
+ self.assertEqual(dt_zi.tzname(), "UTC")
+
+
+class CZoneInfoTest(ZoneInfoTest):
+ module = c_zoneinfo
+
+
+class ZoneInfoPickleTest(ZoneInfoTestBase):
+ module = py_zoneinfo
+
+ def setUp(self):
+ with contextlib.ExitStack() as stack:
+ stack.enter_context(test_support.set_zoneinfo_module(self.module))
+ self.addCleanup(stack.pop_all().close)
+
+ super().setUp()
+
+ @hypothesis.given(key=valid_keys())
+ @add_key_examples
+ def test_pickle_unpickle_cache(self, key):
+ zi = self.klass(key)
+ pkl_str = pickle.dumps(zi)
+ zi_rt = pickle.loads(pkl_str)
+
+ self.assertIs(zi, zi_rt)
+
+ @hypothesis.given(key=valid_keys())
+ @add_key_examples
+ def test_pickle_unpickle_no_cache(self, key):
+ zi = self.klass.no_cache(key)
+ pkl_str = pickle.dumps(zi)
+ zi_rt = pickle.loads(pkl_str)
+
+ self.assertIsNot(zi, zi_rt)
+ self.assertEqual(str(zi), str(zi_rt))
+
+ @hypothesis.given(key=valid_keys())
+ @add_key_examples
+ def test_pickle_unpickle_cache_multiple_rounds(self, key):
+ """Test that pickle/unpickle is idempotent."""
+ zi_0 = self.klass(key)
+ pkl_str_0 = pickle.dumps(zi_0)
+ zi_1 = pickle.loads(pkl_str_0)
+ pkl_str_1 = pickle.dumps(zi_1)
+ zi_2 = pickle.loads(pkl_str_1)
+ pkl_str_2 = pickle.dumps(zi_2)
+
+ self.assertEqual(pkl_str_0, pkl_str_1)
+ self.assertEqual(pkl_str_1, pkl_str_2)
+
+ self.assertIs(zi_0, zi_1)
+ self.assertIs(zi_0, zi_2)
+ self.assertIs(zi_1, zi_2)
+
+ @hypothesis.given(key=valid_keys())
+ @add_key_examples
+ def test_pickle_unpickle_no_cache_multiple_rounds(self, key):
+ """Test that pickle/unpickle is idempotent."""
+ zi_cache = self.klass(key)
+
+ zi_0 = self.klass.no_cache(key)
+ pkl_str_0 = pickle.dumps(zi_0)
+ zi_1 = pickle.loads(pkl_str_0)
+ pkl_str_1 = pickle.dumps(zi_1)
+ zi_2 = pickle.loads(pkl_str_1)
+ pkl_str_2 = pickle.dumps(zi_2)
+
+ self.assertEqual(pkl_str_0, pkl_str_1)
+ self.assertEqual(pkl_str_1, pkl_str_2)
+
+ self.assertIsNot(zi_0, zi_1)
+ self.assertIsNot(zi_0, zi_2)
+ self.assertIsNot(zi_1, zi_2)
+
+ self.assertIsNot(zi_0, zi_cache)
+ self.assertIsNot(zi_1, zi_cache)
+ self.assertIsNot(zi_2, zi_cache)
+
+
+class CZoneInfoPickleTest(ZoneInfoPickleTest):
+ module = c_zoneinfo
+
+
+class ZoneInfoCacheTest(ZoneInfoTestBase):
+ module = py_zoneinfo
+
+ @hypothesis.given(key=valid_keys())
+ @add_key_examples
+ def test_cache(self, key):
+ zi_0 = self.klass(key)
+ zi_1 = self.klass(key)
+
+ self.assertIs(zi_0, zi_1)
+
+ @hypothesis.given(key=valid_keys())
+ @add_key_examples
+ def test_no_cache(self, key):
+ zi_0 = self.klass.no_cache(key)
+ zi_1 = self.klass.no_cache(key)
+
+ self.assertIsNot(zi_0, zi_1)
+
+
+class CZoneInfoCacheTest(ZoneInfoCacheTest):
+ klass = c_zoneinfo.ZoneInfo
+
+
+class PythonCConsistencyTest(unittest.TestCase):
+ """Tests that the C and Python versions do the same thing."""
+
+ def _is_ambiguous(self, dt):
+ return dt.replace(fold=not dt.fold).utcoffset() == dt.utcoffset()
+
+ @hypothesis.given(dt=hypothesis.strategies.datetimes(), key=valid_keys())
+ @hypothesis.example(dt=datetime.datetime.min, key="America/New_York")
+ @hypothesis.example(dt=datetime.datetime.max, key="America/New_York")
+ @hypothesis.example(dt=datetime.datetime(1970, 1, 1), key="America/New_York")
+ @hypothesis.example(dt=datetime.datetime(2020, 1, 1), key="Europe/Paris")
+ @hypothesis.example(dt=datetime.datetime(2020, 6, 1), key="Europe/Paris")
+ def test_same_str(self, dt, key):
+ py_dt = dt.replace(tzinfo=py_zoneinfo.ZoneInfo(key))
+ c_dt = dt.replace(tzinfo=c_zoneinfo.ZoneInfo(key))
+
+ self.assertEqual(str(py_dt), str(c_dt))
+
+ @hypothesis.given(dt=hypothesis.strategies.datetimes(), key=valid_keys())
+ @hypothesis.example(dt=datetime.datetime(1970, 1, 1), key="America/New_York")
+ @hypothesis.example(dt=datetime.datetime(2020, 2, 5), key="America/New_York")
+ @hypothesis.example(dt=datetime.datetime(2020, 8, 12), key="America/New_York")
+ @hypothesis.example(dt=datetime.datetime(2040, 1, 1), key="Africa/Casablanca")
+ @hypothesis.example(dt=datetime.datetime(1970, 1, 1), key="Europe/Paris")
+ @hypothesis.example(dt=datetime.datetime(2040, 1, 1), key="Europe/Paris")
+ @hypothesis.example(dt=datetime.datetime.min, key="Asia/Tokyo")
+ @hypothesis.example(dt=datetime.datetime.max, key="Asia/Tokyo")
+ def test_same_offsets_and_names(self, dt, key):
+ py_dt = dt.replace(tzinfo=py_zoneinfo.ZoneInfo(key))
+ c_dt = dt.replace(tzinfo=c_zoneinfo.ZoneInfo(key))
+
+ self.assertEqual(py_dt.tzname(), c_dt.tzname())
+ self.assertEqual(py_dt.utcoffset(), c_dt.utcoffset())
+ self.assertEqual(py_dt.dst(), c_dt.dst())
+
+ @hypothesis.given(
+ dt=hypothesis.strategies.datetimes(timezones=hypothesis.strategies.just(UTC)),
+ key=valid_keys(),
+ )
+ @hypothesis.example(dt=MIN_UTC, key="Asia/Tokyo")
+ @hypothesis.example(dt=MAX_UTC, key="Asia/Tokyo")
+ @hypothesis.example(dt=MIN_UTC, key="America/New_York")
+ @hypothesis.example(dt=MAX_UTC, key="America/New_York")
+ @hypothesis.example(
+ dt=datetime.datetime(2006, 10, 29, 5, 15, tzinfo=UTC),
+ key="America/New_York",
+ )
+ def test_same_from_utc(self, dt, key):
+ py_zi = py_zoneinfo.ZoneInfo(key)
+ c_zi = c_zoneinfo.ZoneInfo(key)
+
+ # Convert to UTC: This can overflow, but we just care about consistency
+ py_overflow_exc = None
+ c_overflow_exc = None
+ try:
+ py_dt = dt.astimezone(py_zi)
+ except OverflowError as e:
+ py_overflow_exc = e
+
+ try:
+ c_dt = dt.astimezone(c_zi)
+ except OverflowError as e:
+ c_overflow_exc = e
+
+ if (py_overflow_exc is not None) != (c_overflow_exc is not None):
+ raise py_overflow_exc or c_overflow_exc # pragma: nocover
+
+ if py_overflow_exc is not None:
+ return # Consistently raises the same exception
+
+ # PEP 495 says that an inter-zone comparison between ambiguous
+ # datetimes is always False.
+ if py_dt != c_dt:
+ self.assertEqual(
+ self._is_ambiguous(py_dt),
+ self._is_ambiguous(c_dt),
+ (py_dt, c_dt),
+ )
+
+ self.assertEqual(py_dt.tzname(), c_dt.tzname())
+ self.assertEqual(py_dt.utcoffset(), c_dt.utcoffset())
+ self.assertEqual(py_dt.dst(), c_dt.dst())
+
+ @hypothesis.given(dt=hypothesis.strategies.datetimes(), key=valid_keys())
+ @hypothesis.example(dt=datetime.datetime.max, key="America/New_York")
+ @hypothesis.example(dt=datetime.datetime.min, key="America/New_York")
+ @hypothesis.example(dt=datetime.datetime.min, key="Asia/Tokyo")
+ @hypothesis.example(dt=datetime.datetime.max, key="Asia/Tokyo")
+ def test_same_to_utc(self, dt, key):
+ py_dt = dt.replace(tzinfo=py_zoneinfo.ZoneInfo(key))
+ c_dt = dt.replace(tzinfo=c_zoneinfo.ZoneInfo(key))
+
+ # Convert from UTC: Overflow OK if it happens in both implementations
+ py_overflow_exc = None
+ c_overflow_exc = None
+ try:
+ py_utc = py_dt.astimezone(UTC)
+ except OverflowError as e:
+ py_overflow_exc = e
+
+ try:
+ c_utc = c_dt.astimezone(UTC)
+ except OverflowError as e:
+ c_overflow_exc = e
+
+ if (py_overflow_exc is not None) != (c_overflow_exc is not None):
+ raise py_overflow_exc or c_overflow_exc # pragma: nocover
+
+ if py_overflow_exc is not None:
+ return # Consistently raises the same exception
+
+ self.assertEqual(py_utc, c_utc)
+
+ @hypothesis.given(key=valid_keys())
+ @add_key_examples
+ def test_cross_module_pickle(self, key):
+ py_zi = py_zoneinfo.ZoneInfo(key)
+ c_zi = c_zoneinfo.ZoneInfo(key)
+
+ with test_support.set_zoneinfo_module(py_zoneinfo):
+ py_pkl = pickle.dumps(py_zi)
+
+ with test_support.set_zoneinfo_module(c_zoneinfo):
+ c_pkl = pickle.dumps(c_zi)
+
+ with test_support.set_zoneinfo_module(c_zoneinfo):
+ # Python → C
+ py_to_c_zi = pickle.loads(py_pkl)
+ self.assertIs(py_to_c_zi, c_zi)
+
+ with test_support.set_zoneinfo_module(py_zoneinfo):
+ # C → Python
+ c_to_py_zi = pickle.loads(c_pkl)
+ self.assertIs(c_to_py_zi, py_zi)