1# This file is part of Hypothesis, which may be found at
2# https://github.com/HypothesisWorks/hypothesis/
3#
4# Copyright the Hypothesis Authors.
5# Individual contributors are listed in AUTHORS.rst and the git log.
6#
7# This Source Code Form is subject to the terms of the Mozilla Public License,
8# v. 2.0. If a copy of the MPL was not distributed with this file, You can
9# obtain one at https://mozilla.org/MPL/2.0/.
10
11import datetime as dt
12import operator as op
13import sys
14import warnings
15import zoneinfo
16from functools import cache, partial
17from importlib import resources
18from pathlib import Path
19from typing import TYPE_CHECKING, Annotated, Any, overload
20
21from hypothesis.errors import CannotInvert, InvalidArgument
22from hypothesis.internal.conjecture.choice import ChoiceT
23from hypothesis.internal.validation import check_type, check_valid_interval
24from hypothesis.strategies._internal.core import sampled_from
25from hypothesis.strategies._internal.lazy import unwrap_strategies
26from hypothesis.strategies._internal.misc import just, none, nothing
27from hypothesis.strategies._internal.strategies import (
28 FilteredStrategy,
29 OneOfStrategy,
30 SampledFromStrategy,
31 SearchStrategy,
32 current_filter_call_site,
33 one_of,
34)
35from hypothesis.strategies._internal.utils import defines_strategy
36
37if TYPE_CHECKING:
38 from annotated_types import Timezone
39
40 NaiveDatetime = Annotated[dt.datetime, Timezone(None)]
41 AwareDatetime = Annotated[dt.datetime, Timezone(...)]
42elif at := sys.modules.get("annotated_types"):
43 NaiveDatetime = Annotated[dt.datetime, at.Timezone(None)]
44 AwareDatetime = Annotated[dt.datetime, at.Timezone(...)]
45else:
46 NaiveDatetime = AwareDatetime = dt.datetime
47
48DATENAMES = ("year", "month", "day")
49TIMENAMES = ("hour", "minute", "second", "microsecond")
50
51_MICROSECOND = dt.timedelta(microseconds=1)
52
53
54def _comparator_bound(condition):
55 """Return ``(op, bound)`` for filter conditions like ``partial(op, bound)``,
56 with a single positional argument to one of the five comparison operators."""
57 if (
58 isinstance(condition, partial)
59 and len(condition.args) == 1
60 and not condition.keywords
61 and condition.func in (op.lt, op.le, op.eq, op.ge, op.gt)
62 ):
63 return condition.func, condition.args[0]
64 return None
65
66
67def _narrowed_bounds(func, arg, min_value, max_value, shift):
68 """Narrow [min_value, max_value] to satisfy the condition ``func(arg, x)``.
69
70 ``shift(value, steps)`` moves value by that many of the smallest representable
71 steps, raising OverflowError if the result would be unrepresentable. Returns
72 the narrowed (min_value, max_value), or None if no values can satisfy the
73 condition.
74 """
75 if func in (op.lt, op.gt):
76 try:
77 arg = shift(arg, 1 if func is op.lt else -1)
78 except OverflowError: # gt the maximum value, or lt the minimum
79 return None
80 lo, hi = {
81 # We're talking about op(arg, x) - the reverse of our usual intuition!
82 op.lt: (arg, max_value), # lambda x: arg < x
83 op.le: (arg, max_value), # lambda x: arg <= x
84 op.eq: (arg, arg), # lambda x: arg == x
85 op.ge: (min_value, arg), # lambda x: arg >= x
86 op.gt: (min_value, arg), # lambda x: arg > x
87 }[func]
88 lo = max(lo, min_value)
89 hi = min(hi, max_value)
90 if hi < lo:
91 return None
92 return lo, hi
93
94
95def _timezones_kind(strat):
96 """Classify the values a timezones= strategy can generate: "none" if only
97 None, "aware" if only tzinfo instances, or "unknown" if we can't tell."""
98 strat = unwrap_strategies(strat)
99 if isinstance(strat, SampledFromStrategy) and all(
100 name == "filter" for name, *_ in strat._transformations
101 ):
102 kinds = {
103 "none" if e is None else "aware" if isinstance(e, dt.tzinfo) else "unknown"
104 for e in strat.elements
105 }
106 return kinds.pop() if len(kinds) == 1 else "unknown"
107 if isinstance(strat, OneOfStrategy):
108 kinds = {_timezones_kind(s) for s in strat.original_strategies}
109 return kinds.pop() if len(kinds) == 1 else "unknown"
110 return "unknown"
111
112
113def is_pytz_timezone(tz):
114 if not isinstance(tz, dt.tzinfo):
115 return False
116 module = type(tz).__module__
117 return module == "pytz" or module.startswith("pytz.")
118
119
120def replace_tzinfo(value, timezone):
121 if is_pytz_timezone(timezone):
122 # Pytz timezones are a little complicated, and using the .replace method
123 # can cause some weird issues, so we use their special "localize" instead.
124 #
125 # We use the fold attribute as a convenient boolean for is_dst, even though
126 # they're semantically distinct. For ambiguous or imaginary hours, fold says
127 # whether you should use the offset that applies before the gap (fold=0) or
128 # the offset that applies after the gap (fold=1). is_dst says whether you
129 # should choose the side that is "DST" or "STD" (STD->STD or DST->DST
130 # transitions are unclear as you might expect).
131 #
132 # WARNING: this is INCORRECT for timezones with negative DST offsets such as
133 # "Europe/Dublin", but it's unclear what we could do instead beyond
134 # documenting the problem and recommending use of `dateutil` instead.
135 return timezone.localize(value, is_dst=not value.fold)
136 return value.replace(tzinfo=timezone)
137
138
139def _instant(value):
140 """A sort key ordering aware datetimes by the moment they refer to.
141
142 Unlike comparison of datetimes which share a tzinfo - which falls back to
143 ignoring both the timezone and the fold attribute - this respects the fold,
144 and unlike .astimezone() it cannot overflow near datetime.min/max.
145 """
146 return value.replace(tzinfo=None) - dt.datetime.min - value.utcoffset()
147
148
149def _ambiguous(value, tz):
150 # Whether the naive value is inside a DST fold, i.e. is a wall time which
151 # occurs twice in tz, so that its utcoffset depends on the fold attribute.
152 return (
153 replace_tzinfo(value.replace(fold=0), tz).utcoffset()
154 != replace_tzinfo(value.replace(fold=1), tz).utcoffset()
155 )
156
157
158def datetime_does_not_exist(value):
159 """This function tests whether the given datetime can be round-tripped to and
160 from UTC. It is an exact inverse of (and very similar to) the dateutil method
161 https://dateutil.readthedocs.io/en/stable/tz.html#dateutil.tz.datetime_exists
162 """
163 # Naive datetimes cannot be imaginary, but we need this special case because
164 # chaining .astimezone() ends with *the system local timezone*, not None.
165 # See bug report in https://github.com/HypothesisWorks/hypothesis/issues/2662
166 if value.tzinfo is None:
167 return False
168 try:
169 # Does the naive portion of the datetime change when round-tripped to
170 # UTC? If so, or if this overflows, we say that it does not exist.
171 roundtrip = value.astimezone(dt.timezone.utc).astimezone(value.tzinfo)
172 except OverflowError:
173 # Overflows at datetime.min or datetime.max boundary condition.
174 # Rejecting these is acceptable, because timezones are close to
175 # meaningless before ~1900 and subject to a lot of change by
176 # 9999, so it should be a very small fraction of possible values.
177 return True
178
179 if (
180 value.tzinfo is not roundtrip.tzinfo
181 and value.utcoffset() != roundtrip.utcoffset()
182 ):
183 # This only ever occurs during imaginary (i.e. nonexistent) datetimes,
184 # and only for pytz timezones which do not follow PEP-495 semantics.
185 # (may exclude a few other edge cases, but you should use zoneinfo anyway)
186 return True
187
188 assert value.tzinfo is roundtrip.tzinfo, "so only the naive portions are compared"
189 return value != roundtrip
190
191
192def _num_days_in_month(year, month):
193 """Branchless equivalent of ``monthrange(year, month)[1]`` for valid inputs.
194
195 Written using only arithmetic and (in)equality, with no branching or indexing.
196 This avoids concretizing the input or adding more path constraints than necessary.
197 """
198 leap = (year % 4 == 0) * (1 - (year % 100 == 0) * (year % 400 != 0))
199 is_feb = month == 2
200 is_30_day = 1 - (month != 4) * (month != 6) * (month != 9) * (month != 11)
201 return 31 - is_30_day - is_feb * (3 - leap)
202
203
204def draw_capped_multipart(
205 data, min_value, max_value, duration_names=DATENAMES + TIMENAMES
206):
207 assert isinstance(min_value, (dt.date, dt.time, dt.datetime))
208 assert type(min_value) == type(max_value)
209 assert min_value <= max_value
210
211 # cap_{low, high} records whether every field drawn so far has equalled
212 # ``min_value``'s / ``max_value``'s, i.e. whether that bound is still "active" and
213 # constrains the next field.
214 #
215 # cap_{low, high} are conceptually booleans. We define them as integers and interpret
216 # boolean operations on them as multiplication, so that we don't concretize or
217 # branch under symbolic backends. See
218 # https://github.com/HypothesisWorks/hypothesis/issues/4759.
219 cap_low = 1
220 cap_high = 1
221 result = {}
222 for name in duration_names:
223 natural_low = getattr(dt.datetime.min, name)
224 if name == "day":
225 natural_high = _num_days_in_month(result["year"], result["month"])
226 else:
227 natural_high = getattr(dt.datetime.max, name)
228 # equivalent to:
229 # low = min_value.<name> if cap_low else natural_low
230 # high = max_value.<name> if cap_high else natural_high
231 low = natural_low + cap_low * (getattr(min_value, name) - natural_low)
232 high = natural_high + cap_high * (getattr(max_value, name) - natural_high)
233 if name == "year":
234 val = data.draw_integer(low, high, shrink_towards=2000)
235 else:
236 val = data.draw_integer(low, high)
237 result[name] = val
238 cap_low = cap_low * (val == low)
239 cap_high = cap_high * (val == high)
240 if hasattr(min_value, "fold"):
241 # The `fold` attribute is ignored in comparison of naive datetimes.
242 # In tz-aware datetimes it would require *very* invasive changes to
243 # the logic above, and be very sensitive to the specific timezone
244 # (at the cost of efficient shrinking and mutation), so at least for
245 # now we stick with the status quo and generate it independently.
246 result["fold"] = data.draw_integer(0, 1)
247 return result
248
249
250def _shift_datetime(value, steps):
251 return value + steps * _MICROSECOND
252
253
254class _UnrepresentableBound(Exception):
255 """No wall time in the timezone lies within the strategy's bounds."""
256
257
258class DatetimeStrategy(SearchStrategy):
259 def __init__(self, min_value, max_value, timezones_strat, allow_imaginary):
260 super().__init__()
261 assert isinstance(timezones_strat, SearchStrategy)
262 assert isinstance(allow_imaginary, bool)
263 self.aware = (min_value is not None and min_value.tzinfo is not None) or (
264 max_value is not None and max_value.tzinfo is not None
265 )
266 if self.aware:
267 for value in (min_value, max_value):
268 assert value is None or (
269 isinstance(value, dt.datetime) and value.tzinfo is not None
270 )
271 # The instants bounding this strategy, as _instant() sort keys.
272 # UTC offsets are less than a day, so a None bound is replaced by
273 # a key which lies outside the representable range.
274 self.min_instant = (
275 dt.timedelta(days=-2) if min_value is None else _instant(min_value)
276 )
277 self.max_instant = (
278 dt.datetime.max - dt.datetime.min + dt.timedelta(days=2)
279 if max_value is None
280 else _instant(max_value)
281 )
282 assert self.min_instant <= self.max_instant
283 else:
284 for value in (min_value, max_value):
285 assert isinstance(value, dt.datetime)
286 assert value.tzinfo is None
287 assert min_value <= max_value
288 self.min_value = min_value
289 self.max_value = max_value
290 self.tz_strat = timezones_strat
291 self.allow_imaginary = allow_imaginary
292
293 def do_draw(self, data):
294 # We start by drawing a timezone, and an initial datetime.
295 tz = data.draw(self.tz_strat)
296 if self.aware:
297 if not isinstance(tz, dt.tzinfo):
298 raise InvalidArgument(
299 f"Drew {tz!r} from the timezones strategy {self.tz_strat!r}, "
300 "but with aware min_value/max_value bounds the timezones "
301 "strategy must only generate tzinfo objects (not None)"
302 )
303 result = self.draw_aware_datetime(data, tz)
304 else:
305 result = self.draw_naive_datetime_and_combine(data, tz)
306
307 # TODO: with some probability, systematically search for one of
308 # - an imaginary time (if allowed),
309 # - a time within 24hrs of a leap second (if there any are within bounds),
310 # - other subtle, little-known, or nasty issues as described in
311 # https://github.com/HypothesisWorks/hypothesis/issues/69
312
313 # If we happened to end up with a disallowed imaginary time, reject it.
314 if (not self.allow_imaginary) and datetime_does_not_exist(result):
315 data.mark_invalid(f"{result} does not exist (usually a DST transition)")
316 return result
317
318 def in_bounds(self, value):
319 return self.min_instant <= _instant(value) <= self.max_instant
320
321 def draw_aware_datetime(self, data, tz):
322 try:
323 window = self._wall_clock_window(tz)
324 except _UnrepresentableBound as err:
325 data.mark_invalid(str(err))
326 if window is None:
327 # A large fraction of the wall times between bounds inside or close
328 # to a DST fold would risk rejection below - and bounds inside the
329 # same fold may even be in inverted wall-clock order, like
330 # 01:59 EDT < 01:01 EST - so we recurse to draw in UTC, where wall
331 # times are unambiguous and ordered, and convert. This is the
332 # standard draw with the standard shrink order, except that
333 # simplicity is judged on the UTC wall time rather than the local.
334 value = self.draw_aware_datetime(data, dt.timezone.utc)
335 try:
336 return value.astimezone(tz)
337 except OverflowError:
338 data.mark_invalid(f"{value!r} is not representable in {tz!r}")
339 result = draw_capped_multipart(data, *window)
340 value = replace_tzinfo(dt.datetime(**result), timezone=tz)
341 if not self.in_bounds(value):
342 # An ambiguous wall time next to a bound, with the out-of-bounds fold.
343 data.mark_invalid(f"{value!r} is outside the bounds")
344 return value
345
346 def _wall_clock_window(self, tz):
347 """The naive (min, max) wall-clock bounds for drawing in ``tz``, or
348 None to draw in UTC and convert. A pure function of (bounds, tz),
349 shared by generation and inversion; raises _UnrepresentableBound when
350 no wall time in ``tz`` lies within the bounds."""
351
352 def wall_clock(bound, extreme):
353 if bound is None:
354 return extreme
355 try:
356 return bound.astimezone(tz).replace(tzinfo=None)
357 except OverflowError:
358 # UTC offsets are less than a day, so an overflowing bound
359 # must be within a day of datetime.min/max, converting to a
360 # moment beyond them. If every wall time representable in tz
361 # is on the in-bounds side, the bound is simply vacuous here;
362 # otherwise nothing in tz is in bounds.
363 near_min = bound.replace(tzinfo=None) - dt.datetime.min < dt.timedelta(
364 days=2
365 )
366 if near_min == (extreme is dt.datetime.min):
367 return extreme
368 raise _UnrepresentableBound(
369 f"{bound!r} is not representable in {tz!r}"
370 ) from None
371
372 min_local = wall_clock(self.min_value, dt.datetime.min)
373 max_local = wall_clock(self.max_value, dt.datetime.max)
374 if min_local > max_local or (
375 max_local - min_local <= dt.timedelta(days=1)
376 and (_ambiguous(min_local, tz) or _ambiguous(max_local, tz))
377 ):
378 return None
379 return min_local, max_local
380
381 def draw_naive_datetime_and_combine(self, data, tz):
382 result = draw_capped_multipart(data, self.min_value, self.max_value)
383 try:
384 return replace_tzinfo(dt.datetime(**result), timezone=tz)
385 except (ValueError, OverflowError):
386 data.mark_invalid(
387 f"Failed to draw a datetime between {self.min_value!r} and "
388 f"{self.max_value!r} with timezone from {self.tz_strat!r}."
389 )
390
391 def _invert(self, value: Any) -> tuple[ChoiceT, ...]:
392 if self.aware:
393 if type(value) is not dt.datetime or value.tzinfo is None:
394 raise CannotInvert(f"{value!r} is not an aware datetime")
395 try:
396 in_bounds = self.in_bounds(value)
397 imaginary = datetime_does_not_exist(value)
398 except Exception:
399 raise CannotInvert(
400 f"could not locate {value!r} relative to {self!r}"
401 ) from None
402 if not in_bounds:
403 raise CannotInvert(f"{value!r} outside the instant bounds of {self!r}")
404 if imaginary and not self.allow_imaginary:
405 raise CannotInvert(
406 f"{value!r} is an imaginary datetime, but allow_imaginary=False"
407 )
408 return (
409 *self.tz_strat._invert(value.tzinfo),
410 *self._invert_aware_fields(value, value.tzinfo, imaginary=imaginary),
411 )
412 if type(value) is not dt.datetime:
413 raise CannotInvert(f"{value!r} is not a datetime")
414 naive = value.replace(tzinfo=None)
415 if not (self.min_value <= naive <= self.max_value):
416 raise CannotInvert(
417 f"{value!r} outside [{self.min_value!r}, {self.max_value!r}]"
418 )
419 if not self.allow_imaginary and datetime_does_not_exist(value):
420 raise CannotInvert(
421 f"{value!r} is an imaginary datetime, but allow_imaginary=False"
422 )
423 # do_draw draws the timezone first, then the naive parts (with fold
424 # drawn last, since it is ignored in datetime comparisons).
425 return (
426 *self.tz_strat._invert(value.tzinfo),
427 value.year,
428 value.month,
429 value.day,
430 value.hour,
431 value.minute,
432 value.second,
433 value.microsecond,
434 value.fold,
435 )
436
437 def _invert_aware_fields(self, value, tz, *, imaginary):
438 # The multipart fields draw_aware_datetime would consume to produce
439 # ``value``, expressed in the frame it would draw in for ``tz``.
440 try:
441 window = self._wall_clock_window(tz)
442 except _UnrepresentableBound as err:
443 raise CannotInvert(str(err)) from None
444 if window is None:
445 # do_draw would draw in UTC and convert; converting an imaginary
446 # wall time to UTC and back does not round-trip, so be conservative.
447 if imaginary:
448 raise CannotInvert(
449 f"the UTC drawing frame cannot reproduce imaginary {value!r}"
450 )
451 try:
452 utc_value = value.astimezone(dt.timezone.utc)
453 except OverflowError:
454 raise CannotInvert(f"{value!r} is not representable in UTC") from None
455 return self._invert_aware_fields(
456 utc_value, dt.timezone.utc, imaginary=False
457 )
458 min_local, max_local = window
459 naive = value.replace(tzinfo=None)
460 if not (min_local <= naive <= max_local):
461 raise CannotInvert(
462 f"{value!r} is outside the wall-clock window of {self!r} in {tz!r}"
463 )
464 return (
465 naive.year,
466 naive.month,
467 naive.day,
468 naive.hour,
469 naive.minute,
470 naive.second,
471 naive.microsecond,
472 value.fold,
473 )
474
475 def filter(self, condition):
476 if (parsed := _comparator_bound(condition)) is not None and isinstance(
477 arg := parsed[1], dt.datetime
478 ):
479 func = parsed[0]
480 try:
481 bound_aware = arg.utcoffset() is not None
482 except Exception:
483 # A tzinfo whose utcoffset() raises; comparing against this
484 # bound will raise the same error at draw time.
485 return super().filter(condition)
486 if not bound_aware:
487 # The bound compares as naive (either no tzinfo, or a tzinfo
488 # without a UTC offset), so we can only rewrite it into the
489 # naive wall-clock bounds if it really is naive and every
490 # generated value is too.
491 if (
492 arg.tzinfo is None
493 and not self.aware
494 and _timezones_kind(self.tz_strat) == "none"
495 ):
496 bounds = _narrowed_bounds(
497 func, arg, self.min_value, self.max_value, _shift_datetime
498 )
499 if bounds is None:
500 return nothing()
501 if bounds == (self.min_value, self.max_value):
502 return self
503 return datetimes(
504 *bounds,
505 timezones=self.tz_strat,
506 allow_imaginary=self.allow_imaginary,
507 )
508 else:
509 # An aware bound constrains the instant of generated values,
510 # so we narrow our aware bounds to the closed interval of
511 # satisfying instants - retaining strict predicates below,
512 # which then reject at most the boundary instant per timezone.
513 # We compare bounds by their _instant() key, since comparison
514 # of datetimes which share a tzinfo would fall back to
515 # wall-clock order, ignoring the fold.
516 if self.aware:
517 min_value, max_value = self.min_value, self.max_value
518 elif (self.min_value, self.max_value) == (
519 dt.datetime.min,
520 dt.datetime.max,
521 ) and _timezones_kind(self.tz_strat) != "none":
522 # An unbounded naive-mode strategy whose values are all
523 # aware: promote to aware mode, bounded by the filter.
524 min_value = max_value = None
525 else:
526 return super().filter(condition)
527 key = _instant(arg)
528 if func in (op.lt, op.le, op.eq) and (
529 min_value is None or _instant(min_value) < key
530 ):
531 min_value = arg
532 if func in (op.gt, op.ge, op.eq) and (
533 max_value is None or key < _instant(max_value)
534 ):
535 max_value = arg
536 if min_value is not None and max_value is not None:
537 lo, hi = _instant(min_value), _instant(max_value)
538 if hi < lo or (func in (op.lt, op.gt) and lo == hi == key):
539 # Only aware-mode strategies can reach this, and they
540 # generate only aware values (or raise for a bad
541 # timezones strategy), so this is provably empty.
542 return nothing()
543 if min_value is self.min_value and max_value is self.max_value:
544 result = self
545 else:
546 result = DatetimeStrategy(
547 min_value, max_value, self.tz_strat, self.allow_imaginary
548 )
549 if func in (op.lt, op.gt):
550 return FilteredStrategy(
551 result, (condition,), (current_filter_call_site(),)
552 )
553 return result
554 return super().filter(condition)
555
556
557@overload
558def datetimes(
559 min_value: NaiveDatetime | None = None,
560 max_value: NaiveDatetime | None = None,
561 *,
562 timezones: SearchStrategy[None] | None = None,
563) -> SearchStrategy[NaiveDatetime]: ...
564
565
566@overload
567def datetimes(
568 min_value: dt.datetime | None = None,
569 max_value: dt.datetime | None = None,
570 *,
571 timezones: SearchStrategy[dt.tzinfo],
572 allow_imaginary: bool = True,
573) -> SearchStrategy[AwareDatetime]: ...
574
575
576@overload
577def datetimes(
578 min_value: None = None,
579 max_value: None = None,
580 *,
581 timezones: SearchStrategy[dt.tzinfo | None],
582 allow_imaginary: bool = True,
583) -> SearchStrategy[dt.datetime]: ...
584
585
586@defines_strategy(force_reusable_values=True)
587def datetimes(
588 min_value: dt.datetime | None = None,
589 max_value: dt.datetime | None = None,
590 *,
591 timezones: SearchStrategy[dt.tzinfo | None] | None = None,
592 allow_imaginary: bool = True,
593) -> SearchStrategy[dt.datetime]:
594 """datetimes(min_value=None, max_value=None, *, timezones=None, allow_imaginary=True)
595
596 A strategy for generating datetimes, which may be timezone-aware.
597
598 If ``min_value`` and ``max_value`` are naive datetimes, or omitted, this
599 strategy works by drawing a naive datetime between them - defaulting to
600 ``datetime.min`` and ``datetime.max`` respectively - and then attaching
601 a timezone drawn from ``timezones``, which defaults to
602 :func:`~hypothesis.strategies.none`.
603
604 If instead both bounds are timezone-aware, they are treated as moments in
605 time, and ``timezones`` defaults to :func:`~hypothesis.strategies.timezones`.
606 Each generated datetime is aware, in a timezone drawn from ``timezones`` -
607 which must not generate ``None`` - and lies between the two moments.
608 Passing one aware and one naive bound is an error.
609
610 ``timezones`` must be a strategy that generates either ``None``, for naive
611 datetimes, or :class:`~python:datetime.tzinfo` objects for 'aware' datetimes.
612 You can construct your own, though we recommend using one of these built-in
613 strategies:
614
615 * with the standard library: :func:`hypothesis.strategies.timezones`;
616 * with :pypi:`dateutil <python-dateutil>`:
617 :func:`hypothesis.extra.dateutil.timezones`; or
618 * with :pypi:`pytz`: :func:`hypothesis.extra.pytz.timezones`.
619
620 You may pass ``allow_imaginary=False`` to filter out "imaginary" datetimes
621 which did not (or will not) occur due to daylight savings, leap seconds,
622 timezone and calendar adjustments, etc. Imaginary datetimes are allowed
623 by default, because malformed timestamps are a common source of bugs.
624
625 .. note::
626
627 Arithmetic and comparisons on timezone-aware datetimes can be very
628 surprising around daylight-savings changes. See `this CPython issue
629 <https://github.com/python/cpython/issues/116035>`__ for details
630 and discussion.
631
632 Examples from this strategy shrink towards midnight on January 1st 2000,
633 local time.
634 """
635 check_type(bool, allow_imaginary, "allow_imaginary")
636 if min_value is not None:
637 check_type(dt.datetime, min_value, "min_value")
638 if max_value is not None:
639 check_type(dt.datetime, max_value, "max_value")
640 if timezones is not None and not isinstance(timezones, SearchStrategy):
641 raise InvalidArgument(
642 f"{timezones=} must be a SearchStrategy that can "
643 "provide tzinfo for datetimes (either None or dt.tzinfo objects)"
644 )
645 if (min_value is None or min_value.tzinfo is None) and (
646 max_value is None or max_value.tzinfo is None
647 ):
648 min_value = dt.datetime.min if min_value is None else min_value
649 max_value = dt.datetime.max if max_value is None else max_value
650 if timezones is None:
651 timezones = none()
652 check_valid_interval(min_value, max_value, "min_value", "max_value")
653 else:
654 # Aware bounds describe moments in time; we check both are aware here,
655 # and then at draw time convert them to the drawn timezone and proceed
656 # as in the naive case.
657 for name, value in [("min_value", min_value), ("max_value", max_value)]:
658 if value is not None and value.tzinfo is None:
659 raise InvalidArgument(
660 f"{name}={value!r} is naive, but the other bound is "
661 "timezone-aware; the bounds must be both naive or both aware"
662 )
663 if timezones is None:
664 timezones = _timezones()
665 # Compare explicitly as moments in time: comparison of datetimes which
666 # share a tzinfo falls back to wall-clock order, ignoring the fold.
667 if (
668 min_value is not None
669 and max_value is not None
670 and _instant(max_value) < _instant(min_value)
671 ):
672 raise InvalidArgument(
673 f"Cannot have {max_value=} < {min_value=}, comparing as "
674 "moments in time"
675 )
676 return DatetimeStrategy(min_value, max_value, timezones, allow_imaginary)
677
678
679_ARBITRARY_DATE = dt.date(2000, 1, 1)
680
681
682def _shift_time(value, steps):
683 # dt.time supports no arithmetic, so we go via a datetime on a fixed day
684 # and treat crossing midnight as overflowing the representable range.
685 shifted = dt.datetime.combine(_ARBITRARY_DATE, value) + steps * _MICROSECOND
686 if shifted.date() != _ARBITRARY_DATE:
687 raise OverflowError
688 return shifted.time()
689
690
691class TimeStrategy(SearchStrategy):
692 def __init__(self, min_value, max_value, timezones_strat):
693 super().__init__()
694 self.min_value = min_value
695 self.max_value = max_value
696 self.tz_strat = timezones_strat
697
698 def do_draw(self, data):
699 result = draw_capped_multipart(data, self.min_value, self.max_value, TIMENAMES)
700 tz = data.draw(self.tz_strat)
701 return dt.time(**result, tzinfo=tz)
702
703 def _invert(self, value: Any) -> tuple[ChoiceT, ...]:
704 if type(value) is not dt.time:
705 raise CannotInvert(f"{value!r} is not a time")
706 naive = value.replace(tzinfo=None)
707 if not (self.min_value <= naive <= self.max_value):
708 raise CannotInvert(
709 f"{value!r} outside [{self.min_value!r}, {self.max_value!r}]"
710 )
711 # unlike DatetimeStrategy, do_draw draws the naive parts first - with
712 # fold at the end, via draw_capped_multipart - and the timezone last.
713 return (
714 value.hour,
715 value.minute,
716 value.second,
717 value.microsecond,
718 value.fold,
719 *self.tz_strat._invert(value.tzinfo),
720 )
721
722 def filter(self, condition):
723 # We only rewrite naive times: ordering aware times works in terms of
724 # utcoffset(), which is None for e.g. ZoneInfo tzinfos on a time - so
725 # such values compare as naive anyway, and rewriting fixed-offset aware
726 # times isn't worth the extra complexity.
727 if (
728 (parsed := _comparator_bound(condition)) is not None
729 and isinstance(arg := parsed[1], dt.time)
730 and arg.tzinfo is None
731 and _timezones_kind(self.tz_strat) == "none"
732 ):
733 bounds = _narrowed_bounds(
734 parsed[0], arg, self.min_value, self.max_value, _shift_time
735 )
736 if bounds is None:
737 return nothing()
738 if bounds == (self.min_value, self.max_value):
739 return self
740 return times(*bounds, timezones=self.tz_strat)
741 return super().filter(condition)
742
743
744@defines_strategy(force_reusable_values=True)
745def times(
746 min_value: dt.time = dt.time.min,
747 max_value: dt.time = dt.time.max,
748 *,
749 timezones: SearchStrategy[dt.tzinfo | None] = none(),
750) -> SearchStrategy[dt.time]:
751 """times(min_value=datetime.time.min, max_value=datetime.time.max, *, timezones=none())
752
753 A strategy for times between ``min_value`` and ``max_value``.
754
755 The ``timezones`` argument is handled as for :py:func:`datetimes`.
756
757 Examples from this strategy shrink towards midnight, with the timezone
758 component shrinking as for the strategy that provided it.
759 """
760 check_type(dt.time, min_value, "min_value")
761 check_type(dt.time, max_value, "max_value")
762 if min_value.tzinfo is not None:
763 raise InvalidArgument(f"{min_value=} must not have tzinfo")
764 if max_value.tzinfo is not None:
765 raise InvalidArgument(f"{max_value=} must not have tzinfo")
766 check_valid_interval(min_value, max_value, "min_value", "max_value")
767 return TimeStrategy(min_value, max_value, timezones)
768
769
770def _shift_date(value, steps):
771 return value + steps * dt.timedelta(days=1)
772
773
774class DateStrategy(SearchStrategy):
775 def __init__(self, min_value, max_value):
776 super().__init__()
777 assert isinstance(min_value, dt.date)
778 assert isinstance(max_value, dt.date)
779 assert min_value < max_value
780 self.min_value = min_value
781 self.max_value = max_value
782
783 def do_draw(self, data):
784 return dt.date(
785 **draw_capped_multipart(data, self.min_value, self.max_value, DATENAMES)
786 )
787
788 def _invert(self, value: Any) -> tuple[ChoiceT, ...]:
789 if type(value) is not dt.date:
790 raise CannotInvert(f"{value!r} is not a date")
791 if not (self.min_value <= value <= self.max_value):
792 raise CannotInvert(
793 f"{value!r} outside [{self.min_value!r}, {self.max_value!r}]"
794 )
795 return (value.year, value.month, value.day)
796
797 def filter(self, condition):
798 if (
799 (parsed := _comparator_bound(condition)) is not None
800 # datetime is a date subclass, but not comparable with dates
801 and isinstance(arg := parsed[1], dt.date)
802 and not isinstance(arg, dt.datetime)
803 ):
804 bounds = _narrowed_bounds(
805 parsed[0], arg, self.min_value, self.max_value, _shift_date
806 )
807 if bounds is None:
808 return nothing()
809 if bounds == (self.min_value, self.max_value):
810 return self
811 return dates(*bounds)
812
813 return super().filter(condition)
814
815
816@defines_strategy(force_reusable_values=True)
817def dates(
818 min_value: dt.date = dt.date.min, max_value: dt.date = dt.date.max
819) -> SearchStrategy[dt.date]:
820 """dates(min_value=datetime.date.min, max_value=datetime.date.max)
821
822 A strategy for dates between ``min_value`` and ``max_value``.
823
824 Examples from this strategy shrink towards January 1st 2000.
825 """
826 check_type(dt.date, min_value, "min_value")
827 check_type(dt.date, max_value, "max_value")
828 # datetime is a subclass of date, so check_type() accepts it - but a datetime
829 # bound is almost certainly a mistake, and breaks our drawing logic downstream.
830 if isinstance(min_value, dt.datetime):
831 raise InvalidArgument(f"{min_value=} is a datetime, but expected a date")
832 if isinstance(max_value, dt.datetime):
833 raise InvalidArgument(f"{max_value=} is a datetime, but expected a date")
834 check_valid_interval(min_value, max_value, "min_value", "max_value")
835 if min_value == max_value:
836 return just(min_value)
837 return DateStrategy(min_value, max_value)
838
839
840class TimedeltaStrategy(SearchStrategy):
841 def __init__(self, min_value, max_value):
842 super().__init__()
843 assert isinstance(min_value, dt.timedelta)
844 assert isinstance(max_value, dt.timedelta)
845 assert min_value < max_value
846 self.min_value = min_value
847 self.max_value = max_value
848
849 def do_draw(self, data):
850 result = {}
851 low_bound = True
852 high_bound = True
853 for name in ("days", "seconds", "microseconds"):
854 low = getattr(self.min_value if low_bound else dt.timedelta.min, name)
855 high = getattr(self.max_value if high_bound else dt.timedelta.max, name)
856 val = data.draw_integer(low, high)
857 result[name] = val
858 low_bound = low_bound and val == low
859 high_bound = high_bound and val == high
860 return dt.timedelta(**result)
861
862 def _invert(self, value: Any) -> tuple[ChoiceT, ...]:
863 if type(value) is not dt.timedelta:
864 raise CannotInvert(f"{value!r} is not a timedelta")
865 if not (self.min_value <= value <= self.max_value):
866 raise CannotInvert(
867 f"{value!r} outside [{self.min_value!r}, {self.max_value!r}]"
868 )
869 return (value.days, value.seconds, value.microseconds)
870
871
872@defines_strategy(force_reusable_values=True)
873def timedeltas(
874 min_value: dt.timedelta = dt.timedelta.min,
875 max_value: dt.timedelta = dt.timedelta.max,
876) -> SearchStrategy[dt.timedelta]:
877 """timedeltas(min_value=datetime.timedelta.min, max_value=datetime.timedelta.max)
878
879 A strategy for timedeltas between ``min_value`` and ``max_value``.
880
881 Examples from this strategy shrink towards zero.
882 """
883 check_type(dt.timedelta, min_value, "min_value")
884 check_type(dt.timedelta, max_value, "max_value")
885 check_valid_interval(min_value, max_value, "min_value", "max_value")
886 if min_value == max_value:
887 return just(min_value)
888 return TimedeltaStrategy(min_value=min_value, max_value=max_value)
889
890
891@cache
892def _valid_key_cacheable(tzpath, key):
893 assert isinstance(tzpath, tuple) # zoneinfo changed, better update this function!
894 for root in tzpath:
895 if Path(root).joinpath(key).exists(): # pragma: no branch
896 # No branch because most systems only have one TZPATH component.
897 return True
898 else:
899 # Taken for names which are known to zoneinfo but not present on the
900 # filesystem, e.g. with the tzdata package installed.
901 *package_loc, resource_name = key.split("/")
902 package = "tzdata.zoneinfo." + ".".join(package_loc)
903 try:
904 return (resources.files(package) / resource_name).exists()
905 except ModuleNotFoundError:
906 return False
907
908
909def _timezone_key_strategies(*, allow_prefix):
910 """SampledFromStrategy branches for IANA keys: plain keys first, then one
911 branch per allowed prefix, with the prefix applied as a sampled_from
912 transformation. one_of's branch selector is therefore the prefix choice,
913 which shrinks towards - and can be re-encoded as - an unprefixed key."""
914 with warnings.catch_warnings():
915 try:
916 warnings.simplefilter("ignore", EncodingWarning)
917 except NameError: # pragma: no cover
918 pass
919 # On Python 3.12 (and others?), `available_timezones()` opens files
920 # without specifying an encoding - which our selftests make an error.
921 available_timezones = ("UTC", *sorted(zoneinfo.available_timezones()))
922
923 # TODO: filter out alias and deprecated names if disallowed
924
925 def valid_key(key):
926 return key == "UTC" or _valid_key_cacheable(zoneinfo.TZPATH, key)
927
928 # TODO: work out how to place a higher priority on "weird" timezones
929 # For details see https://github.com/HypothesisWorks/hypothesis/issues/2414
930 plain = [key for key in available_timezones if valid_key(key)]
931 branches = [sampled_from(plain)]
932 if allow_prefix:
933 for prefix in ("posix", "right"):
934 keys = [key for key in plain if valid_key(f"{prefix}/{key}")]
935 if keys:
936 branches.append(sampled_from(keys).map(f"{prefix}/{{}}".format))
937 return branches
938
939
940@defines_strategy(force_reusable_values=True)
941def timezone_keys(
942 *,
943 # allow_alias: bool = True,
944 # allow_deprecated: bool = True,
945 allow_prefix: bool = True,
946) -> SearchStrategy[str]:
947 """A strategy for :wikipedia:`IANA timezone names <List_of_tz_database_time_zones>`.
948
949 As well as timezone names like ``"UTC"``, ``"Australia/Sydney"``, or
950 ``"America/New_York"``, this strategy can generate:
951
952 - Aliases such as ``"Antarctica/McMurdo"``, which links to ``"Pacific/Auckland"``.
953 - Deprecated names such as ``"Antarctica/South_Pole"``, which *also* links to
954 ``"Pacific/Auckland"``. Note that most but
955 not all deprecated timezone names are also aliases.
956 - Timezone names with the ``"posix/"`` or ``"right/"`` prefixes, unless
957 ``allow_prefix=False``.
958
959 These strings are provided separately from Tzinfo objects - such as ZoneInfo
960 instances from the timezones() strategy - to facilitate testing of timezone
961 logic without needing workarounds to access non-canonical names.
962
963 .. note::
964
965 `The tzdata package is required on Windows
966 <https://docs.python.org/3/library/zoneinfo.html#data-sources>`__.
967 ``pip install hypothesis[zoneinfo]`` installs it, if and only if needed.
968
969 On Windows, you may need to access IANA timezone data via the :pypi:`tzdata`
970 package. For non-IANA timezones, such as Windows-native names or GNU TZ
971 strings, we recommend using :func:`~hypothesis.strategies.sampled_from` with
972 the :pypi:`dateutil <python-dateutil>` package, e.g.
973 :meth:`dateutil:dateutil.tz.tzwin.list`.
974 """
975 # check_type(bool, allow_alias, "allow_alias")
976 # check_type(bool, allow_deprecated, "allow_deprecated")
977 check_type(bool, allow_prefix, "allow_prefix")
978 return one_of(_timezone_key_strategies(allow_prefix=allow_prefix))
979
980
981@defines_strategy(force_reusable_values=True)
982def timezones(*, no_cache: bool = False) -> SearchStrategy["zoneinfo.ZoneInfo"]:
983 """A strategy for :class:`python:zoneinfo.ZoneInfo` objects.
984
985 If ``no_cache=True``, the generated instances are constructed using
986 :meth:`ZoneInfo.no_cache <python:zoneinfo.ZoneInfo.no_cache>` instead
987 of the usual constructor. This may change the semantics of your datetimes
988 in surprising ways, so only use it if you know that you need to!
989
990 .. note::
991
992 `The tzdata package is required on Windows
993 <https://docs.python.org/3/library/zoneinfo.html#data-sources>`__.
994 ``pip install hypothesis[zoneinfo]`` installs it, if and only if needed.
995 """
996 check_type(bool, no_cache, "no_cache")
997 ctor = zoneinfo.ZoneInfo.no_cache if no_cache else zoneinfo.ZoneInfo
998 # Mapping each sampled_from branch folds ctor into its transformations,
999 # keeping the whole strategy re-encodable (unlike mapping the one_of).
1000 return one_of(
1001 [keys.map(ctor) for keys in _timezone_key_strategies(allow_prefix=True)]
1002 )
1003
1004
1005# In datetimes() above, the ``timezones`` argument shadows this module's
1006# timezones() strategy, so we refer to it by this alias instead.
1007_timezones = timezones