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