Repository: HypothesisWorks/hypothesis

Issue:
Folds and imaginary datetimes in the datetime strategy
I was just testing a function using hypothesis and I realized that hypothesis *never generates a datetime with the `fold` value set*.  When I went to fix this, I realized that there are two ways to handle how ambiguous and imaginary datetimes are handled with aware datetimes, and the current strategy is actually *inconsistent* in this regard.

The problem is whether you think of `strategies.datetime` as generating *arbitrary* `datetime` objects or whether they are intended to generate *valid* datetimes in a given zone. Currently, if you generate an aware `datetime` and pass a strategy that generates `pytz` zones, you'll always get *valid* datetimes, because the library actively detects `pytz` zones and calls `normalize(localize(dt))` on the datetimes generated. If it draws any other kind of zone, `hypothesis` may generate an imaginary datetime, but if `hypothesis` picks an ambiguous datetime, it will only ever generate one side of the transition (whichever one happens chronologically first, for `dateutil`).

If you want it to generate *arbitrary* datetimes, we can drop the `normalize` call, and set `fold` randomly for every datetime generated. If you draw from `st.booleans()`, which shrinks towards `False`, `fold` will almost always shrink away because it usually doesn't affect the behavior of the datetime. You will need to special-case `pytz` here, and instead of setting `fold` set the `is_dst` flag in the `localize` call.

If you always want the aware datetimes generated by `hypothesis` to be *valid* (non-imaginary), then you can avoid hard-coding any awareness of `pytz`'s unusual interface and just draw the datetimes as UTC, then convert. This will make the behavior uniform between `dateutil` and `pytz` and will set `fold` only for ambiguous datetimes *but* it will never generate imaginary datetimes.

I think these are both reasonable behaviors for `datetimes`. My inclination is to add a flag like `allow_imaginary` that switches between them and defaults to `True`. An alternative to this would be to stick to one or the other and have people who want the behavior *not* chosen create it themselves by generating a naive datetime and a timezone separately, then combining them themselves, but I think that will make it much harder for #69 to be effective.

I'm happy to implement whatever decision we come to.


Discussion:
Fantastic writeup, thank you!

I'd be delighted to accept a PR adding `allow_imaginary=True` as a keyword-only argument.

Also very interested in setting the fold attribute (on 3.6+, or is_dst on any version), and the associated machinery.  Just fiddly to stay compatible with Python 3.5 in the meantime...
@pganssle - how does https://github.com/HypothesisWorks/hypothesis/compare/master...Zac-HD:better-datetimes look to you?  

It adds the `allow_imaginary` argument and generates `.fold` (py36+), but I'm not entirely convinced that I'm getting all the subtleties of utcoffsets and timezones right... or excluding imaginary datetimes when using pytz quite the way I hope to.
@Zac-HD Sorry I haven't had time to make a proper PR for this.

Few things:

1. Seems like you went with `allow_imaginary=False` by default. I thought we were going to go with `allow_imaginary=True` as the default.
2. I'm not clear on why you are doing the `utcoffset` math like this at all, but I don't think it's quite going to work for `pytz`, and it seems unnecessary for `dateutil` or other zones like that. Here's a little mock function that I think does what you want:

```python
def _draw_naive(min_dt, max_dt):
    ... # Assume that this draws a naive datetime in the range [min_dt, max_dt]
        # and sets `fold` randomly in Python 3.6+

def _draw_real(min_dt, max_dt, tz):
    def to_naive_utc(dt):
        if dt.tzinfo is None:
            dt.replace(tzinfo=tz)
        return dt.astimezone(timezone.utc).replace(tzinfo=None)

    min_dt_utc, max_dt_utc = map(to_naive_utc, (min_dt, max_dt))

    dt_utc = _draw_naive(min_dt, max_dt)
    return dt_utc.replace(tzinfo=timezone.utc).astimezone(tz)

def _draw_any(min_dt, max_dt, tz):
    dt = _draw_naive(min_dt, max_dt)
    if is_pytz_timezone(tz):
        # fold and is_dst aren't necessarily the exact same thing
        # but they're both booleans and it's randomly chosen anyway
        dt = tz.localize(dt, is_dst=not dt.fold)
    else:
        dt = dt.replace(tzinfo=tz)

def do_draw(self, data):
    tz = data.draw(self.tz_strat)
    if self.allow_imaginary:
        return _draw_any(self.min_dt, self.max_dt, tz)
    else:
        return _draw_real(self.min_dt, self.max_dt, tz)
```

I think you'll need to refactor what I have here to get it to work correctly with the `_draw_naive_datetime` that you have, but hopefully you get the gist. For `allow_imaginary=False`, you want:

1. Convert `min_dt` and `max_dt` into UTC
2. Draw a naive datetime in that range
3. Reinterpret it as a UTC datetime
4. Convert that UTC datetime to the zone you want (it will set `fold` correctly in all cases and skip imaginary datetimes)

For `allow_imaginary=True`, you want:

1. Draw a naive datetime with a randomly-assigned `fold`
2. If it's `pytz`, pass the fold as the `is_dst` to `localize` (the two have inverted senses so I've inverted it here, but since they're randomly drawn bools you can just pass directly as well)
3. If it's not `pytz`, just do `.replace(tzinfo=tz)` on the naive datetime and you are done.

I think the biggest downside with the "ensure that it's a real time zone" thing is that it requires invoking `.astimezone()`, which means that when you're testing time zone libraries I bet it's going to give some gnarly errors if you break that function. I don't really see any way around using the time zone functions for this, though.
> Seems like you went with `allow_imaginary=False` by default. I thought we were going to go with `allow_imaginary=True` as the default.

It's true-by-default in the public API (function); false in the class because we reuse the machinery for `times()`.  Which I really need to split out into it's own class, actually, not all timezones exist at all dates...  I think there's going to be a refactoring PR merged before I actually get to the `allow_imaginary` part.

> I'm not clear on why you are doing the `utcoffset` math like this at all

Because the bounds are currently expressed in *naive time*, and as I understand it interpreting as UTC then converting can change the naive part of the datetime to possibly-out-of-bounds.

On reflection it's probably a lot safer just to check for and reject imaginary times!  (interpret as timezone, convert to UTC and back, reject if unequal)

Supporting tz-aware bounds is definitely on my list of nice-to-haves, but there's a fair bit of refactoring to do first!

> I think the biggest downside with the "ensure that it's a real time zone" thing is that it requires invoking .astimezone(), which means that when you're testing time zone libraries I bet it's going to give some gnarly errors if you break that function. I don't really see any way around using the time zone functions for this, though.

Eh, this sounds pretty normal to me really - *everything* breaks when I point Hypothesis at it.  So long as the bugs get fixed I'm happy!
> Because the bounds are currently expressed in _naive time_, and as I understand it interpreting as UTC then converting can change the naive part of the datetime to possibly-out-of-bounds.
>
> On reflection it's probably a lot safer just to check for and reject imaginary times! (interpret as timezone, convert to UTC and back, reject if unequal)

Hm.. So I did consider this, though obviously didn't put enough thought into actually dealing with the edge cases.

I think you may have the same problem with the "convert to UTC and back" problem if it's a valid local time but it can't be represented in UTC because of the boundary conditions. You'll still fail to generate the UTC times for something like `datetime(1, 1, 1, tzinfo=timezone(timedelta(hours=1))`.

I think the options are to either:

1. Assume that there are no gaps in the regions that cannot be represented in UTC, and include those in the resulting outputs.
2. Restrict the domain of valid datetimes to the overlap between valid local times and valid UTC times.

I'm not sure if either is better than the other. The non-restricted version is more likely to turn up bugs, but they are likely to have no real effect on "business logic", since time zone offsets are very close to meaningless before ~1900 and decreasingly accurate the further into the future you go, so correctly handling time zones at 0 CE or 9999 CE are very much YAGNI situations.

I'm inclined to say restrict the domain of valid datetimes to the overlap between the two and just do the draw in UTC, because I *think* that's the domain that people who would opt in to `allow_imaginary=False` would want anyway.

One other point to consider about the "draw a naive and reject if it can't round trip" option is that people may accidentally set the boundaries in such a way that imaginary datetimes are a large fraction of the valid search space, which I assume would be very slow.
OK, I think I've been trying to fix too many issues with datetime support at once.  Here they are:

- [x] Moving the relevant functions from `strategies._internal.core` to live with the `SearchStrategy` subclasses in `strategies._internal.datetime`
- [x] Refactoring the `dates()` strategy for structured shrinking
- [x] Give the `times()` strategy cleaner tz support by splitting it from `datetimes()`

After the refactoring, some feature improvements:

- [ ] Generate a `fold` attribute on Python >= 3.6
- [ ] Make `pytz` use `localize(is_dst=...)` without `normalize()`, for consistency
- [ ] Add an `allow_imaginary=True` arg, initially with a filter-based implementation

which should be much easier to both write and review as two PRs!

---------------------

And finally some options which would be nice-to-have but are not on the roadmap:

- Valid datetimes by construction (not filtering), with nice boundary condition support
- Timezone-aware bounds.  Clearly useful, obvious a big change to the semantics, different issue and one I'm not working on until someone actually asks for the feature.




Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/extra/dateutil.py b/hypothesis-python/src/hypothesis/extra/dateutil.py
--- a/hypothesis-python/src/hypothesis/extra/dateutil.py
+++ b/hypothesis-python/src/hypothesis/extra/dateutil.py
@@ -18,11 +18,10 @@
 hypothesis[dateutil]
 --------------------
 
-This module provides ``dateutil`` timezones.
+This module provides :pypi:`dateutil <python-dateutil>` timezones.
 
-You can use this strategy to make
-:py:func:`hypothesis.strategies.datetimes` and
-:py:func:`hypothesis.strategies.times` produce timezone-aware values.
+You can use this strategy to make :func:`~hypothesis.strategies.datetimes`
+and :func:`~hypothesis.strategies.times` produce timezone-aware values.
 """
 
 import datetime as dt
@@ -47,7 +46,7 @@ def __zone_sort_key(zone):
 @st.cacheable
 @st.defines_strategy
 def timezones() -> st.SearchStrategy[dt.tzinfo]:
-    """Any timezone in dateutil.
+    """Any timezone from :pypi:`dateutil <python-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
diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/datetime.py b/hypothesis-python/src/hypothesis/strategies/_internal/datetime.py
--- a/hypothesis-python/src/hypothesis/strategies/_internal/datetime.py
+++ b/hypothesis-python/src/hypothesis/strategies/_internal/datetime.py
@@ -39,6 +39,45 @@ def is_pytz_timezone(tz):
     return module == "pytz" or module.startswith("pytz.")
 
 
+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.
+        #
+        # 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
+        # whether you should use the offset that applies before the gap (fold=0) or
+        # the offset that applies after the gap (fold=1). is_dst says whether you
+        # should choose the side that is "DST" or "STD" (STD->STD or DST->DST
+        # transitions are unclear as you might expect).
+        #
+        # WARNING: this is INCORRECT for timezones with negative DST offsets such as
+        #       "Europe/Dublin", but it's unclear what we could do instead beyond
+        #       documenting the problem and recommending use of `dateutil` instead.
+        #
+        # TODO: after dropping Python 3.5 support we won't need the getattr
+        return timezone.localize(value, is_dst=not getattr(value, "fold", 0))
+    return value.replace(tzinfo=timezone)
+
+
+def datetime_does_not_exist(value):
+    # This function tests whether the given datetime can be round-tripped to and
+    # from UTC.  It is an exact inverse of (and very similar to) the dateutil method
+    # https://dateutil.readthedocs.io/en/stable/tz.html#dateutil.tz.datetime_exists
+    try:
+        # Does the naive portion of the datetime change when round-tripped to
+        # UTC?  If so, or if this overflows, we say that it does not exist.
+        roundtrip = value.astimezone(dt.timezone.utc).astimezone(value.tzinfo)
+    except OverflowError:
+        # Overflows at datetime.min or datetime.max boundary condition.
+        # Rejecting these is acceptable, because timezones are close to
+        # meaningless before ~1900 and subject to a lot of change by
+        # 9999, so it should be a very small fraction of possible values.
+        return True
+    assert value.tzinfo is roundtrip.tzinfo, "so only the naive portions are compared"
+    return value != roundtrip
+
+
 def draw_capped_multipart(data, min_value, max_value):
     assert isinstance(min_value, (dt.date, dt.time, dt.datetime))
     assert type(min_value) == type(max_value)
@@ -62,30 +101,50 @@ def draw_capped_multipart(data, min_value, max_value):
         result[name] = val
         cap_low = cap_low and val == low
         cap_high = cap_high and val == high
+    if hasattr(min_value, "fold"):
+        # The `fold` attribute is ignored in comparison of naive datetimes.
+        # In tz-aware datetimes it would require *very* invasive changes to
+        # the logic above, and be very sensitive to the specific timezone
+        # (at the cost of efficient shrinking and mutation), so at least for
+        # now we stick with the status quo and generate it independently.
+        result["fold"] = utils.integer_range(data, 0, 1)
     return result
 
 
 class DatetimeStrategy(SearchStrategy):
-    def __init__(self, min_value, max_value, timezones_strat):
+    def __init__(self, min_value, max_value, timezones_strat, allow_imaginary):
         assert isinstance(min_value, dt.datetime)
         assert isinstance(max_value, dt.datetime)
         assert min_value.tzinfo is None
         assert max_value.tzinfo is None
         assert min_value <= max_value
         assert isinstance(timezones_strat, SearchStrategy)
+        assert isinstance(allow_imaginary, bool)
         self.min_value = min_value
         self.max_value = max_value
         self.tz_strat = timezones_strat
+        self.allow_imaginary = allow_imaginary
 
     def do_draw(self, data):
-        result = draw_capped_multipart(data, self.min_value, self.max_value)
-        result = dt.datetime(**result)
+        # We start by drawing a timezone, and an initial datetime.
         tz = data.draw(self.tz_strat)
+        result = self.draw_naive_datetime_and_combine(data, tz)
+
+        # TODO: with some probability, systematically search for one of
+        #   - an imaginary time (if allowed),
+        #   - a time within 24hrs of a leap second (if there any are within bounds),
+        #   - other subtle, little-known, or nasty issues as described in
+        #     https://github.com/HypothesisWorks/hypothesis/issues/69
+
+        # If we happened to end up with a disallowed imaginary time, reject it.
+        if (not self.allow_imaginary) and datetime_does_not_exist(result):
+            data.mark_invalid()
+        return result
+
+    def draw_naive_datetime_and_combine(self, data, tz):
+        result = draw_capped_multipart(data, self.min_value, self.max_value)
         try:
-            if is_pytz_timezone(tz):
-                # Can't just construct; see http://pytz.sourceforge.net
-                return tz.normalize(tz.localize(result))
-            return result.replace(tzinfo=tz)
+            return replace_tzinfo(dt.datetime(**result), timezone=tz)
         except (ValueError, OverflowError):
             msg = "Failed to draw a datetime between %r and %r with timezone from %r."
             data.note_event(msg % (self.min_value, self.max_value, self.tz_strat))
@@ -98,37 +157,29 @@ def datetimes(
     min_value: dt.datetime = dt.datetime.min,
     max_value: dt.datetime = dt.datetime.max,
     *,
-    timezones: SearchStrategy[Optional[dt.tzinfo]] = none()
+    timezones: SearchStrategy[Optional[dt.tzinfo]] = none(),
+    allow_imaginary: bool = True
 ) -> SearchStrategy[dt.datetime]:
-    """datetimes(min_value=datetime.datetime.min, max_value=datetime.datetime.max, *, timezones=none())
+    """datetimes(min_value=datetime.datetime.min, max_value=datetime.datetime.max, *, timezones=none(), allow_imaginary=True)
 
     A strategy for generating datetimes, which may be timezone-aware.
 
     This strategy works by drawing a naive datetime between ``min_value``
     and ``max_value``, which must both be naive (have no timezone).
 
-    ``timezones`` must be a strategy that generates
-    :class:`~python:datetime.tzinfo` objects (or None,
-    which is valid for naive datetimes).  A value drawn from this strategy
-    will be added to a naive datetime, and the resulting tz-aware datetime
-    returned.
-
-    .. note::
-        tz-aware datetimes from this strategy may be ambiguous or non-existent
-        due to daylight savings, leap seconds, timezone and calendar
-        adjustments, etc.  This is intentional, as malformed timestamps are a
-        common source of bugs.
-
-    :py:func:`hypothesis.extra.pytz.timezones` requires the :pypi:`pytz`
-    package, but provides all timezones in the Olsen database.
-    :py:func:`hypothesis.extra.dateutil.timezones` requires the
-    :pypi:`python-dateutil` package, and similarly provides all timezones
-    there.  If you want to allow naive datetimes, combine strategies
-    like ``none() | timezones()``.
-
-    Alternatively, you can create a list of the timezones you wish to allow
-    (e.g. from the standard library, :pypi:`dateutil <python-dateutil>`,
-    or :pypi:`pytz`) and use :py:func:`sampled_from`.
+    ``timezones`` must be a strategy that generates either ``None``, for naive
+    datetimes, or :class:`~python:datetime.tzinfo` objects for 'aware' datetimes.
+    You can construct your own, though we recommend using the :pypi:`dateutil
+    <python-dateutil>` package and :func:`hypothesis.extra.dateutil.timezones`
+    strategy, and also provide :func:`hypothesis.extra.pytz.timezones`.
+
+    You may pass ``allow_imaginary=False`` to filter out "imaginary" datetimes
+    which did not (or will not) occur due to daylight savings, leap seconds,
+    timezone and calendar adjustments, etc.  Imaginary datetimes are allowed
+    by default, because malformed timestamps are a common source of bugs.
+    Note that because :pypi:`pytz` predates :pep:`495`, this does not work
+    correctly with timezones that use a negative DST offset (such as
+    ``"Europe/Dublin"``).
 
     Examples from this strategy shrink towards midnight on January 1st 2000,
     local time.
@@ -142,6 +193,7 @@ def datetimes(
     # handle datetimes in e.g. a four-microsecond span which is not
     # representable in UTC.  Handling (d), all of the above, leads to a much
     # more complex API for all users and a useful feature for very few.
+    check_type(bool, allow_imaginary, "allow_imaginary")
     check_type(dt.datetime, min_value, "min_value")
     check_type(dt.datetime, max_value, "max_value")
     if min_value.tzinfo is not None:
@@ -154,7 +206,19 @@ def datetimes(
             "timezones=%r must be a SearchStrategy that can provide tzinfo "
             "for datetimes (either None or dt.tzinfo objects)" % (timezones,)
         )
-    return DatetimeStrategy(min_value, max_value, timezones)
+    return DatetimeStrategy(min_value, max_value, timezones, allow_imaginary)
+
+
+class TimeStrategy(SearchStrategy):
+    def __init__(self, min_value, max_value, timezones_strat):
+        self.min_value = min_value
+        self.max_value = max_value
+        self.tz_strat = timezones_strat
+
+    def do_draw(self, data):
+        result = draw_capped_multipart(data, self.min_value, self.max_value)
+        tz = data.draw(self.tz_strat)
+        return dt.time(**result, tzinfo=tz)
 
 
 @defines_strategy_with_reusable_values
@@ -181,12 +245,7 @@ def times(
     if max_value.tzinfo is not None:
         raise InvalidArgument("max_value=%r must not have tzinfo" % max_value)
     check_valid_interval(min_value, max_value, "min_value", "max_value")
-    day = dt.date(2000, 1, 1)
-    return datetimes(
-        min_value=dt.datetime.combine(day, min_value),
-        max_value=dt.datetime.combine(day, max_value),
-        timezones=timezones,
-    ).map(lambda t: t.timetz())
+    return TimeStrategy(min_value, max_value, timezones)
 
 
 class DateStrategy(SearchStrategy):


Test patch:
diff --git a/hypothesis-python/tests/cover/test_datetimes.py b/hypothesis-python/tests/cover/test_datetimes.py
--- a/hypothesis-python/tests/cover/test_datetimes.py
+++ b/hypothesis-python/tests/cover/test_datetimes.py
@@ -14,10 +14,12 @@
 # END HEADER
 
 import datetime as dt
+import sys
 
 import pytest
 
 from hypothesis import given, settings
+from hypothesis.internal.compat import PYPY
 from hypothesis.strategies import dates, datetimes, timedeltas, times
 from tests.common.debug import find_any, minimal
 
@@ -135,3 +137,15 @@ def test_can_generate_naive_time():
 @given(times())
 def test_naive_times_are_naive(dt):
     assert dt.tzinfo is None
+
+
+# The fold attribute was added in Python 3.6.  It's less clear why this also fails
+# on pypy3.6, but it seems to canonicalise fold to 0 for non-ambiguous times...
+@pytest.mark.skipif(PYPY or sys.version_info[:2] < (3, 6), reason="see comment")
+def test_can_generate_datetime_with_fold_1():
+    find_any(datetimes(), lambda d: d.fold)
+
+
+@pytest.mark.skipif(PYPY or sys.version_info[:2] < (3, 6), reason="see comment")
+def test_can_generate_time_with_fold_1():
+    find_any(times(), lambda d: d.fold)
diff --git a/hypothesis-python/tests/cover/test_direct_strategies.py b/hypothesis-python/tests/cover/test_direct_strategies.py
--- a/hypothesis-python/tests/cover/test_direct_strategies.py
+++ b/hypothesis-python/tests/cover/test_direct_strategies.py
@@ -68,6 +68,7 @@ def fn_ktest(*fnkwargs):
     (ds.dates, {"min_value": date(2017, 8, 22), "max_value": date(2017, 8, 21)}),
     (ds.datetimes, {"min_value": "fish"}),
     (ds.datetimes, {"max_value": "fish"}),
+    (ds.datetimes, {"allow_imaginary": 0}),
     (
         ds.datetimes,
         {"min_value": datetime(2017, 8, 22), "max_value": datetime(2017, 8, 21)},
diff --git a/hypothesis-python/tests/datetime/test_dateutil_timezones.py b/hypothesis-python/tests/datetime/test_dateutil_timezones.py
--- a/hypothesis-python/tests/datetime/test_dateutil_timezones.py
+++ b/hypothesis-python/tests/datetime/test_dateutil_timezones.py
@@ -19,10 +19,12 @@
 from dateutil import tz, zoneinfo
 
 from hypothesis import assume, given
-from hypothesis.errors import InvalidArgument
+from hypothesis.errors import FailedHealthCheck, InvalidArgument
 from hypothesis.extra.dateutil import timezones
-from hypothesis.strategies import data, datetimes, sampled_from, times
-from tests.common.debug import minimal
+from hypothesis.strategies import data, datetimes, just, sampled_from, times
+from hypothesis.strategies._internal.datetime import datetime_does_not_exist
+from tests.common.debug import assert_all_examples, find_any, minimal
+from tests.common.utils import fails_with
 
 
 def test_utc_is_minimal():
@@ -87,3 +89,41 @@ def test_datetimes_stay_within_naive_bounds(data, lo, hi):
         lo, hi = hi, lo
     out = data.draw(datetimes(lo, hi, timezones=timezones()))
     assert lo <= out.replace(tzinfo=None) <= hi
+
+
+DAY_WITH_IMAGINARY_HOUR_KWARGS = {
+    # The day of a spring-forward transition; 2am is imaginary
+    "min_value": dt.datetime(2020, 10, 4),
+    "max_value": dt.datetime(2020, 10, 5),
+    "timezones": just(tz.gettz("Australia/Sydney")),
+}
+
+
+@given(datetimes(timezones=timezones()) | datetimes(**DAY_WITH_IMAGINARY_HOUR_KWARGS))
+def test_dateutil_exists_our_not_exists_are_inverse(value):
+    assert datetime_does_not_exist(value) == (not tz.datetime_exists(value))
+
+
+def test_datetimes_can_exclude_imaginary():
+    find_any(
+        datetimes(**DAY_WITH_IMAGINARY_HOUR_KWARGS, allow_imaginary=True),
+        lambda x: not tz.datetime_exists(x),
+    )
+    assert_all_examples(
+        datetimes(**DAY_WITH_IMAGINARY_HOUR_KWARGS, allow_imaginary=False),
+        tz.datetime_exists,
+    )
+
+
+@fails_with(FailedHealthCheck)
+@given(
+    datetimes(
+        max_value=dt.datetime(1, 1, 1, 9),
+        timezones=just(tz.gettz("Australia/Sydney")),
+        allow_imaginary=False,
+    )
+)
+def test_non_imaginary_datetimes_at_boundary(val):
+    # This is expected to fail because Australia/Sydney is UTC+10,
+    # and the filter logic overflows when checking for round-trips.
+    assert False
diff --git a/hypothesis-python/tests/datetime/test_pytz_timezones.py b/hypothesis-python/tests/datetime/test_pytz_timezones.py
--- a/hypothesis-python/tests/datetime/test_pytz_timezones.py
+++ b/hypothesis-python/tests/datetime/test_pytz_timezones.py
@@ -17,12 +17,18 @@
 
 import pytest
 import pytz
+from dateutil.tz import datetime_exists
 
 from hypothesis import assume, given
 from hypothesis.errors import InvalidArgument
 from hypothesis.extra.pytz import timezones
-from hypothesis.strategies import data, datetimes, sampled_from, times
-from tests.common.debug import assert_can_trigger_event, minimal
+from hypothesis.strategies import data, datetimes, just, sampled_from, times
+from tests.common.debug import (
+    assert_all_examples,
+    assert_can_trigger_event,
+    find_any,
+    minimal,
+)
 
 
 def test_utc_is_minimal():
@@ -110,3 +116,36 @@ def test_datetimes_stay_within_naive_bounds(data, lo, hi):
         lo, hi = hi, lo
     out = data.draw(datetimes(lo, hi, timezones=timezones()))
     assert lo <= out.replace(tzinfo=None) <= hi
+
+
+@pytest.mark.xfail(reason="is_dst not equivalent to fold when DST offset is negative")
+def test_datetimes_can_exclude_imaginary():
+    # The day of a spring-forward transition; 2am is imaginary
+    australia = {
+        "min_value": dt.datetime(2020, 10, 4),
+        "max_value": dt.datetime(2020, 10, 5),
+        "timezones": just(pytz.timezone("Australia/Sydney")),
+    }
+    # Ireland uses  *negative* offset DST, which means that our sloppy interpretation
+    # of "is_dst=not fold" bypasses the filter for imaginary times.  This is basically
+    # unfixable without redesigning pytz per PEP-495, and it's much more likely to be
+    # replaced by dateutil or PEP-615 zoneinfo in the standard library instead.
+    # (we use both so an optimistic `is_dst=bool(fold)` also fails the test)
+    ireland = {
+        "min_value": dt.datetime(2019, 3, 31),
+        "max_value": dt.datetime(2019, 4, 1),
+        "timezones": just(pytz.timezone("Europe/Dublin")),
+    }
+    # Sanity check: fail unless those days contain an imaginary hour to filter out
+    find_any(
+        datetimes(**australia, allow_imaginary=True), lambda x: not datetime_exists(x),
+    )
+    find_any(
+        datetimes(**ireland, allow_imaginary=True), lambda x: not datetime_exists(x),
+    )
+    # Assert that with allow_imaginary=False we only generate existing datetimes.
+    assert_all_examples(
+        datetimes(**australia, allow_imaginary=False)
+        | datetimes(**ireland, allow_imaginary=False),
+        datetime_exists,
+    )