Skip to content

Commit

Permalink
Stdlib timezones() strategy
Browse files Browse the repository at this point in the history
  • Loading branch information
Zac-HD committed Dec 1, 2020
1 parent a7610be commit 0db20ac
Show file tree
Hide file tree
Showing 12 changed files with 240 additions and 5 deletions.
8 changes: 8 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
RELEASE_TYPE: minor

This release adds new :func:`~hypothesis.strategies.timezones` and
:func:`~hypothesis.strategies.timezone_keys` strategies (:issue:`2630`)
based on the new :mod:`python:zoneinfo` module in Python 3.9.

``pip install hypothesis[zoneinfo]`` will ensure that you have the
appropriate backports installed if you need them.
1 change: 1 addition & 0 deletions hypothesis-python/docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def setup(app):
"pandas": ("https://pandas.pydata.org/pandas-docs/stable/", None),
"pytest": ("https://docs.pytest.org/en/stable/", None),
"django": ("https://django.readthedocs.io/en/stable/", None),
"dateutil": ("https://dateutil.readthedocs.io/en/stable/", None),
"redis": ("https://redis-py.readthedocs.io/en/stable/", None),
"attrs": ("https://www.attrs.org/en/stable/", None),
}
Expand Down
2 changes: 1 addition & 1 deletion hypothesis-python/scripts/basic-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ PYTEST="python -m pytest -n2"
$PYTEST tests/cover tests/pytest

# Run tests for each extra module while the requirements are installed
pip install ".[pytz, dateutil]"
pip install ".[pytz, dateutil, zoneinfo]"
$PYTEST tests/datetime/
pip uninstall -y pytz python-dateutil

Expand Down
7 changes: 7 additions & 0 deletions hypothesis-python/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ def local_file(name):
"pytest": ["pytest>=4.3"],
"dpcontracts": ["dpcontracts>=0.4"],
"redis": ["redis>=3.0.0"],
# zoneinfo is an odd one: every dependency is conditional, because they're
# only necessary on old versions of Python or Windows systems.
"zoneinfo": [
"tzdata>=2020.4 ; sys_platform == 'win32'",
"backports.zoneinfo>=0.2.1 ; python_version<'3.9'",
"importlib_resources>=3.3.0 ; python_version<'3.7'",
],
# We only support Django versions with upstream support - see
# https://www.djangoproject.com/download/#supported-versions
"django": ["pytz>=2014.1", "django>=2.2"],
Expand Down
8 changes: 8 additions & 0 deletions hypothesis-python/src/hypothesis/internal/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@
import sys
import typing

try:
import zoneinfo
except ImportError:
try:
from backports import zoneinfo
except ImportError:
zoneinfo = None

PYPY = platform.python_implementation() == "PyPy"
WINDOWS = platform.system() == "Windows"

Expand Down
11 changes: 10 additions & 1 deletion hypothesis-python/src/hypothesis/strategies/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,14 @@
tuples,
uuids,
)
from hypothesis.strategies._internal.datetime import dates, datetimes, timedeltas, times
from hypothesis.strategies._internal.datetime import (
dates,
datetimes,
timedeltas,
times,
timezone_keys,
timezones,
)
from hypothesis.strategies._internal.ipaddress import ip_addresses

# The implementation of all of these lives in `_strategies.py`, but we
Expand Down Expand Up @@ -107,6 +114,8 @@
"text",
"timedeltas",
"times",
"timezone_keys",
"timezones",
"tuples",
"uuids",
"SearchStrategy",
Expand Down
134 changes: 133 additions & 1 deletion hypothesis-python/src/hypothesis/strategies/_internal/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,21 @@
# END HEADER

import datetime as dt
import os.path
from calendar import monthrange
from functools import lru_cache
from typing import Optional

from hypothesis.errors import InvalidArgument
from hypothesis.internal.compat import zoneinfo
from hypothesis.internal.conjecture import utils
from hypothesis.internal.validation import check_type, check_valid_interval
from hypothesis.strategies._internal.core import (
defines_strategy,
deprecated_posargs,
just,
none,
sampled_from,
)
from hypothesis.strategies._internal.strategies import SearchStrategy

Expand All @@ -42,7 +46,7 @@ def is_pytz_timezone(tz):
def replace_tzinfo(value, timezone):
if is_pytz_timezone(timezone):
# Pytz timezones are a little complicated, and using the .replace method
# can cause some wierd issues, so we use their special "localise" instead.
# can cause some weird issues, so we use their special "localize" instead.
#
# We use the fold attribute as a convenient boolean for is_dst, even though
# they're semantically distinct. For ambiguous or imaginary hours, fold says
Expand Down Expand Up @@ -314,3 +318,131 @@ def timedeltas(
if min_value == max_value:
return just(min_value)
return TimedeltaStrategy(min_value=min_value, max_value=max_value)


@defines_strategy(force_reusable_values=True)
def timezone_keys(
*,
# allow_alias: bool = True,
# allow_deprecated: bool = True,
allow_prefix: bool = True,
) -> SearchStrategy[str]:
"""A strategy for :wikipedia:`IANA timezone names <List_of_tz_database_time_zones>`.
As well as timezone names like ``"UTC"``, ``"Australia/Sydney"``, or
``"America/New_York"``, this strategy can generate:
- Aliases such as ``"Antarctica/McMurdo"``, which links to ``"Pacific/Auckland"``.
- Deprecated names such as ``"Antarctica/South_Pole"``, which *also* links to
``"Pacific/Auckland"``. Note that most but
not all deprecated timezone names are also aliases.
- Timezone names with the ``"posix/"`` or ``"right/"`` prefixes, unless
``allow_prefix=False``.
These strings are provided separately from Tzinfo objects - such as ZoneInfo
instances from the timezones() strategy - to facilitate testing of timezone
logic without needing workarounds to access non-canonical names.
.. note::
The :mod:`python:zoneinfo` module is new in Python 3.9, so you will need
to install the :pypi:`backports.zoneinfo` module on earlier versions, and
the :pypi:`importlib_resources` backport on Python 3.6.
``pip install hypothesis[zoneinfo]`` will install these conditional
dependencies if and only if they are needed.
On Windows, you may need to access IANA timezone data via the :pypi:`tzdata`
package. For non-IANA timezones, such as Windows-native names or GNU TZ
strings, we recommend using :func:`~hypothesis.strategies.sampled_from` with
the :pypi:`dateutil` package, e.g. :meth:`dateutil:dateutil.tz.tzwin.list`.
"""
# check_type(bool, allow_alias, "allow_alias")
# check_type(bool, allow_deprecated, "allow_deprecated")
check_type(bool, allow_prefix, "allow_prefix")
if zoneinfo is None: # pragma: no cover
raise ModuleNotFoundError(
"The zoneinfo module is required, but could not be imported. "
"Run `pip install hypothesis[zoneinfo]` and try again."
)

available_timezones = ("UTC",) + tuple(sorted(zoneinfo.available_timezones()))

# TODO: filter out alias and deprecated names if disallowed

# When prefixes are allowed, we first choose a key and then flatmap to get our
# choice with one of the available prefixes. That in turn means that we need
# some logic to determine which prefixes are available for a given key:
try:
from importlib import resources
except ImportError:
try:
import importlib_resources as resources # type: ignore
except ImportError as err:
raise type(err)(
"Run `pip install hypothesis[zoneinfo]` and try again."
) from err

@lru_cache(maxsize=None)
def valid_key(key):
# Caching this is actually a little dodgy, since zoneinfo.TZPATH can be
# changed at runtime, but it's a substantial performance win and very
# unlikely to be a problem in practice.
if key == "UTC":
return True
for root in zoneinfo.TZPATH:
if os.path.exists(os.path.join(root, key)): # pragma: no branch
# No branch because most systems only have one TZPATH component.
return True
else: # pragma: no cover
# This branch is only taken for names which are known to zoneinfo
# but not present on the filesystem, i.e. on Windows with tzdata,
# and so is never executed by our coverage tests.
*package_loc, resource_name = key.split("/")
package = "tzdata.zoneinfo." + ".".join(package_loc)
try:
return resources.is_resource(package, resource_name)
except ModuleNotFoundError:
return False

# TODO: work out how to place a higher priority on "weird" timezones
# For details see https://github.com/HypothesisWorks/hypothesis/issues/2414
strategy = sampled_from([key for key in available_timezones if valid_key(key)])

if not allow_prefix:
return strategy

def sample_with_prefixes(zone):
keys_with_prefixes = (zone, f"posix/{zone}", f"right/{zone}")
return sampled_from([key for key in keys_with_prefixes if valid_key(key)])

return strategy.flatmap(sample_with_prefixes)


@defines_strategy(force_reusable_values=True)
def timezones(*, no_cache: bool = False) -> SearchStrategy["zoneinfo.ZoneInfo"]:
"""A strategy for :class:`python:zoneinfo.ZoneInfo` objects.
If ``no_cache=True``, the generated instances are constructed using
:meth:`ZoneInfo.no_cache <python:zoneinfo.ZoneInfo.no_cache>` instead
of the usual constructor. This may change the semantics of your datetimes
in surprising ways, so only use it if you know that you need to!
.. note::
The :mod:`python:zoneinfo` module is new in Python 3.9, so you will need
to install the :pypi:`backports.zoneinfo` module on earlier versions, and
the :pypi:`importlib_resources` backport on Python 3.6.
``pip install hypothesis[zoneinfo]`` will install these conditional
dependencies if and only if they are needed.
"""
check_type(bool, no_cache, "no_cache")
if zoneinfo is None: # pragma: no cover
raise ModuleNotFoundError(
"The zoneinfo module is required, but could not be imported. "
"Run `pip install hypothesis[zoneinfo]` and try again."
)
return timezone_keys().map(
zoneinfo.ZoneInfo.no_cache if no_cache else zoneinfo.ZoneInfo
)
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

from hypothesis import strategies as st
from hypothesis.errors import InvalidArgument, ResolutionFailed
from hypothesis.internal.compat import ForwardRef, typing_root_type
from hypothesis.internal.compat import ForwardRef, typing_root_type, zoneinfo
from hypothesis.internal.conjecture.utils import many as conjecture_utils_many
from hypothesis.strategies._internal.ipaddress import (
SPECIAL_IPv4_RANGES,
Expand Down Expand Up @@ -348,6 +348,8 @@ def _networks(bits):
os.PathLike: st.builds(PurePath, st.text()),
# Pull requests with more types welcome!
}
if zoneinfo is not None: # pragma: no branch
_global_type_lookup[zoneinfo.ZoneInfo] = st.timezones()

_global_type_lookup[type] = st.sampled_from(
[type(None)] + sorted(_global_type_lookup, key=str)
Expand Down
2 changes: 1 addition & 1 deletion hypothesis-python/tests/cover/test_lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,7 @@ def test_can_cast():
@pytest.mark.parametrize("type_", [datetime.timezone, datetime.tzinfo])
def test_timezone_lookup(type_):
assert issubclass(type_, datetime.tzinfo)
assert_all_examples(st.from_type(type_), lambda t: isinstance(t, datetime.timezone))
assert_all_examples(st.from_type(type_), lambda t: isinstance(t, type_))


@pytest.mark.parametrize(
Expand Down
2 changes: 2 additions & 0 deletions hypothesis-python/tests/cover/test_type_lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
"randoms",
"runner",
"sampled_from",
"timezone_keys",
"timezones",
]
types_with_core_strat = set()
for thing in (
Expand Down
65 changes: 65 additions & 0 deletions hypothesis-python/tests/datetime/test_zoneinfo_timezones.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# This file is part of Hypothesis, which may be found at
# https://github.com/HypothesisWorks/hypothesis/
#
# Most of this work is copyright (C) 2013-2020 David R. MacIver
# ([email protected]), but it contains contributions by others. See
# CONTRIBUTING.rst for a full list of people who may hold copyright, and
# consult the git log if you need to determine who owns an individual
# contribution.
#
# This Source Code Form is subject to the terms of the Mozilla Public License,
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
# obtain one at https://mozilla.org/MPL/2.0/.
#
# END HEADER

import pytest

from hypothesis import given, strategies as st
from hypothesis.errors import InvalidArgument
from hypothesis.internal.compat import zoneinfo
from tests.common.debug import assert_no_examples, find_any, minimal


def test_utc_is_minimal():
assert minimal(st.timezones()) is zoneinfo.ZoneInfo("UTC")


def test_can_generate_non_utc():
find_any(
st.datetimes(timezones=st.timezones()).filter(lambda d: d.tzinfo.key != "UTC")
)


@given(st.data(), st.datetimes(), st.datetimes())
def test_datetimes_stay_within_naive_bounds(data, lo, hi):
if lo > hi:
lo, hi = hi, lo
out = data.draw(st.datetimes(lo, hi, timezones=st.timezones()))
assert lo <= out.replace(tzinfo=None) <= hi


@pytest.mark.parametrize("kwargs", [{"no_cache": 1}])
def test_timezones_argument_validation(kwargs):
with pytest.raises(InvalidArgument):
st.timezones(**kwargs).validate()


@pytest.mark.parametrize(
"kwargs",
[
# {"allow_alias": 1},
# {"allow_deprecated": 1},
{"allow_prefix": 1},
],
)
def test_timezone_keys_argument_validation(kwargs):
with pytest.raises(InvalidArgument):
st.timezone_keys(**kwargs).validate()


def test_can_disallow_prefixes():
assert_no_examples(
st.timezone_keys(allow_prefix=False),
lambda s: s.startswith(("posix/", "right/")),
)
1 change: 1 addition & 0 deletions hypothesis-python/tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ setenv=
HYPOTHESIS_INTERNAL_COVERAGE=true
commands =
rm -f branch-check
pip install .[zoneinfo]
python -m coverage --version
python -m coverage debug sys
python -m coverage run --rcfile=.coveragerc -m pytest -n0 --strict tests/cover tests/conjecture tests/datetime tests/numpy tests/pandas tests/lark --ff {posargs}
Expand Down

0 comments on commit 0db20ac

Please sign in to comment.