Repository: HypothesisWorks/hypothesis

Issue:
Provide a timezones() strategy based on dateutil.tz
I would like Hypothesis to provide `hypothesis.extra.dateutil.timezones()`, based on [`dateutil.tz`](https://dateutil.readthedocs.io), as our recommended timezone strategy.

- The officially recommended source of [`tzinfo` objects](https://docs.python.org/3/library/datetime.html#tzinfo-objects) is `dateutil.tz`
- Hard dependency of Pandas, and preferred by the pydata stack
- Compatible with all the obvious ways to use timezone objects!

Implementation: take the values from `dateutil.zoneinfo.get_zonefile_instance().zones`, sort them according to some ordering\*, and apply `sampled_from`.  Note that this requires `dateutil >= 2.7` (recent); we'll need to either find an older way to get this info or else provide informative errors as well as correct version requirements.

Ordering of timezones for useful shrinking is... tricky - the best we can do is probably a partial ordering by "seems more likely to cause bugs".  For [pytz (here)](https://github.com/HypothesisWorks/hypothesis/blob/master/hypothesis-python/src/hypothesis/extra/pytz.py) we sort 'UTC, fixed offsets by abs(offset), variable offsets by name'.

<details>
<summary>Why isn't pytz enough?</summary>

I want to keep `hypothesis.extra.pytz.timezones()` indefinitely, but following [PEP 495](https://www.python.org/dev/peps/pep-0495/) `pytz` has a "weird" interface and is very easy to use incorrectly.

Just go read [*The Fastest Footgun in the West*](https://blog.ganssle.io/articles/2018/03/pytz-fastest-footgun.html) by Paul Ganssle, and you'll have a decent idea.  Hypothesis actually has [special logic](https://github.com/HypothesisWorks/hypothesis/blob/1ae8830483fcb748cae2c708c0d0c1c24b71858a/hypothesis-python/src/hypothesis/searchstrategy/datetime.py#L62-L68) to support `pytz`, while every other source follows Python's standard model.

</details>


Discussion:
I certainly have no objections.

I think it would probably make sense to stick to the same ordering as we use for pytz. It's a bit anglocentric (but so is the whole system!), but shrinking by offset from some zero point is a pretty natural default and UTC is basically the only zero point that makes sense.
@Zac-HD Can I work on this issue. I have looked over the code and think that I can do it.

Resolving patch:
diff --git a/hypothesis-python/setup.py b/hypothesis-python/setup.py
--- a/hypothesis-python/setup.py
+++ b/hypothesis-python/setup.py
@@ -55,6 +55,7 @@ def local_file(name):
 extras = {
     'datetime': ['pytz'],
     'pytz': ['pytz'],
+    'dateutil': ['python-dateutil'],
     'fakefactory': ['Faker>=0.7'],
     'numpy': ['numpy>=1.9.0'],
     'pytest': ['pytest>=2.8.0'],
diff --git a/hypothesis-python/src/hypothesis/extra/dateutil.py b/hypothesis-python/src/hypothesis/extra/dateutil.py
new file mode 100644
--- /dev/null
+++ b/hypothesis-python/src/hypothesis/extra/dateutil.py
@@ -0,0 +1,58 @@
+# coding=utf-8
+#
+# This file is part of Hypothesis, which may be found at
+# https://github.com/HypothesisWorks/hypothesis-python
+#
+# Most of this work is copyright (C) 2013-2018 David R. MacIver
+# (david@drmaciver.com), 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 http://mozilla.org/MPL/2.0/.
+#
+# END HEADER
+
+"""This module provides ``dateutil`` timezones.
+
+You can use this strategy to make
+:py:func:`hypothesis.strategies.datetimes` and
+:py:func:`hypothesis.strategies.times` produce timezone-aware values.
+"""
+
+from __future__ import division, print_function, absolute_import
+
+import datetime as dt
+
+from dateutil import tz, zoneinfo  # type: ignore
+
+import hypothesis.strategies as st
+
+__all__ = ['timezones']
+
+
+@st.cacheable
+@st.defines_strategy
+def timezones():
+    # type: () -> st.SearchStrategy[dt.tzinfo]
+    """Any timezone in dateutil.
+
+    This strategy minimises to UTC, or the timezone with the smallest offset
+    from UTC as of 2000-01-01, and is designed for use with
+    :py:func:`~hypothesis.strategies.datetimes`.
+
+    Note that the timezones generated by the strategy may vary depending on the
+    configuration of your machine. See the dateutil documentation for more
+    information.
+    """
+    reference_date = dt.datetime(2000, 1, 1)
+    tz_names = zoneinfo.get_zonefile_instance().zones
+
+    all_timezones = [tz.UTC]  # type: ignore
+    all_timezones += sorted(
+        [tz.gettz(t) for t in tz_names],
+        key=lambda zone: abs(zone.utcoffset(reference_date))
+    )
+    return st.sampled_from(all_timezones)
diff --git a/hypothesis-python/src/hypothesis/strategies.py b/hypothesis-python/src/hypothesis/strategies.py
--- a/hypothesis-python/src/hypothesis/strategies.py
+++ b/hypothesis-python/src/hypothesis/strategies.py
@@ -1533,6 +1533,10 @@ def datetimes(
     package, but provides all timezones in the Olsen database.  If you want to
     allow naive datetimes, combine strategies like ``none() | timezones()``.
 
+    :py:func:`hypothesis.extra.dateutil.timezones` requires the
+    :pypi:`python-dateutil` package, and similarly provides all timezones
+    there.
+
     Alternatively, you can create a list of the timezones you wish to allow
     (e.g. from the standard library, ``datetutil``, or ``pytz``) and use
     :py:func:`sampled_from`.  Ensure that simple values such as None or UTC


Test patch:
diff --git a/hypothesis-python/tests/datetime/test_dateutil_timezones.py b/hypothesis-python/tests/datetime/test_dateutil_timezones.py
new file mode 100644
--- /dev/null
+++ b/hypothesis-python/tests/datetime/test_dateutil_timezones.py
@@ -0,0 +1,89 @@
+# coding=utf-8
+#
+# This file is part of Hypothesis, which may be found at
+# https://github.com/HypothesisWorks/hypothesis-python
+#
+# Most of this work is copyright (C) 2013-2018 David R. MacIver
+# (david@drmaciver.com), 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 http://mozilla.org/MPL/2.0/.
+#
+# END HEADER
+
+from __future__ import division, print_function, absolute_import
+
+import datetime as dt
+
+import pytest
+from dateutil import tz, zoneinfo
+
+from hypothesis import given, assume
+from hypothesis.errors import InvalidArgument
+from tests.common.debug import minimal
+from hypothesis.strategies import times, datetimes, sampled_from
+from hypothesis.extra.dateutil import timezones
+
+
+def test_utc_is_minimal():
+    assert tz.UTC is minimal(timezones())
+
+
+def test_can_generate_non_naive_time():
+    assert minimal(times(timezones=timezones()),
+                   lambda d: d.tzinfo).tzinfo == tz.UTC
+
+
+def test_can_generate_non_naive_datetime():
+    assert minimal(datetimes(timezones=timezones()),
+                   lambda d: d.tzinfo).tzinfo == tz.UTC
+
+
+@given(datetimes(timezones=timezones()))
+def test_timezone_aware_datetimes_are_timezone_aware(dt):
+    assert dt.tzinfo is not None
+
+
+@given(sampled_from(['min_value', 'max_value']),
+       datetimes(timezones=timezones()))
+def test_datetime_bounds_must_be_naive(name, val):
+    with pytest.raises(InvalidArgument):
+        datetimes(**{name: val}).validate()
+
+
+def test_timezones_arg_to_datetimes_must_be_search_strategy():
+    all_timezones = zoneinfo.get_zonefile_instance().zones
+    with pytest.raises(InvalidArgument):
+        datetimes(timezones=all_timezones).validate()
+
+
+@given(times(timezones=timezones()))
+def test_timezone_aware_times_are_timezone_aware(dt):
+    assert dt.tzinfo is not None
+
+
+def test_can_generate_non_utc():
+    times(timezones=timezones()).filter(
+        lambda d: assume(d.tzinfo) and d.tzinfo.zone != u'UTC'
+    ).validate()
+
+
+@given(sampled_from(['min_value', 'max_value']), times(timezones=timezones()))
+def test_time_bounds_must_be_naive(name, val):
+    with pytest.raises(InvalidArgument):
+        times(**{name: val}).validate()
+
+
+def test_should_have_correct_ordering():
+    def offset(timezone):
+        return abs(timezone.utcoffset(dt.datetime(2000, 1, 1)))
+
+    next_interesting_tz = minimal(
+        timezones(),
+        lambda tz: offset(tz) > dt.timedelta(0)
+    )
+    assert offset(next_interesting_tz) == dt.timedelta(seconds=3600)
diff --git a/hypothesis-python/tests/datetime/test_timezones.py b/hypothesis-python/tests/datetime/test_pytz_timezones.py
similarity index 100%
rename from hypothesis-python/tests/datetime/test_timezones.py
rename to hypothesis-python/tests/datetime/test_pytz_timezones.py