Skip to content

Commit

Permalink
New datetimes() arg: allow_imaginary
Browse files Browse the repository at this point in the history
  • Loading branch information
Zac-HD committed Apr 13, 2020
1 parent 580656d commit fab3197
Show file tree
Hide file tree
Showing 5 changed files with 113 additions and 15 deletions.
3 changes: 3 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ value should be ignored.

For consistency, timezones provided by the :pypi:`pytz` package can now
generate imaginary times. This has always been the case for other timezones.

If you prefer the previous behaviour, :func:`~hypothesis.strategies.datetimes`
now takes an argument ``allow_imaginary`` which defaults to ``True``.
55 changes: 45 additions & 10 deletions hypothesis-python/src/hypothesis/strategies/_internal/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,24 @@ def replace_tzinfo(value, timezone):
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.
naive = value.replace(tzinfo=None)
roundtrip = value.astimezone(dt.timezone.utc).astimezone(value.tzinfo)
return naive != roundtrip.replace(tzinfo=None)
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


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)
Expand Down Expand Up @@ -81,21 +99,37 @@ def draw_capped_multipart(data, min_value, max_value):


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:
return replace_tzinfo(dt.datetime(**result), timezone=tz)
except (ValueError, OverflowError):
Expand All @@ -110,7 +144,8 @@ 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())
Expand All @@ -125,11 +160,10 @@ def datetimes(
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.
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.
:py:func:`hypothesis.extra.pytz.timezones` requires the :pypi:`pytz`
package, but provides all timezones in the Olsen database.
Expand All @@ -154,6 +188,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:
Expand All @@ -166,7 +201,7 @@ 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):
Expand Down
1 change: 1 addition & 0 deletions hypothesis-python/tests/cover/test_direct_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)},
Expand Down
46 changes: 43 additions & 3 deletions hypothesis-python/tests/datetime/test_dateutil_timezones.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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
23 changes: 21 additions & 2 deletions hypothesis-python/tests/datetime/test_pytz_timezones.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -110,3 +116,16 @@ 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


def test_datetimes_can_exclude_imaginary():
# The day of a spring-forward transition; 2am is imaginary
kwargs = {
"min_value": dt.datetime(2020, 10, 4),
"max_value": dt.datetime(2020, 10, 5),
"timezones": just(pytz.timezone("Australia/Sydney")),
}
find_any(
datetimes(**kwargs, allow_imaginary=True), lambda x: not datetime_exists(x),
)
assert_all_examples(datetimes(**kwargs, allow_imaginary=False), datetime_exists)

0 comments on commit fab3197

Please sign in to comment.