Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/hypothesis/strategies/_internal/datetime.py: 22%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

214 statements  

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 warnings 

14import zoneinfo 

15from functools import cache, partial 

16from importlib import resources 

17from pathlib import Path 

18 

19from hypothesis.errors import InvalidArgument 

20from hypothesis.internal.validation import check_type, check_valid_interval 

21from hypothesis.strategies._internal.core import sampled_from 

22from hypothesis.strategies._internal.misc import just, none, nothing 

23from hypothesis.strategies._internal.strategies import SearchStrategy 

24from hypothesis.strategies._internal.utils import defines_strategy 

25 

26DATENAMES = ("year", "month", "day") 

27TIMENAMES = ("hour", "minute", "second", "microsecond") 

28 

29 

30def is_pytz_timezone(tz): 

31 if not isinstance(tz, dt.tzinfo): 

32 return False 

33 module = type(tz).__module__ 

34 return module == "pytz" or module.startswith("pytz.") 

35 

36 

37def replace_tzinfo(value, timezone): 

38 if is_pytz_timezone(timezone): 

39 # Pytz timezones are a little complicated, and using the .replace method 

40 # can cause some weird issues, so we use their special "localize" instead. 

41 # 

42 # We use the fold attribute as a convenient boolean for is_dst, even though 

43 # they're semantically distinct. For ambiguous or imaginary hours, fold says 

44 # whether you should use the offset that applies before the gap (fold=0) or 

45 # the offset that applies after the gap (fold=1). is_dst says whether you 

46 # should choose the side that is "DST" or "STD" (STD->STD or DST->DST 

47 # transitions are unclear as you might expect). 

48 # 

49 # WARNING: this is INCORRECT for timezones with negative DST offsets such as 

50 # "Europe/Dublin", but it's unclear what we could do instead beyond 

51 # documenting the problem and recommending use of `dateutil` instead. 

52 return timezone.localize(value, is_dst=not value.fold) 

53 return value.replace(tzinfo=timezone) 

54 

55 

56def datetime_does_not_exist(value): 

57 """This function tests whether the given datetime can be round-tripped to and 

58 from UTC. It is an exact inverse of (and very similar to) the dateutil method 

59 https://dateutil.readthedocs.io/en/stable/tz.html#dateutil.tz.datetime_exists 

60 """ 

61 # Naive datetimes cannot be imaginary, but we need this special case because 

62 # chaining .astimezone() ends with *the system local timezone*, not None. 

63 # See bug report in https://github.com/HypothesisWorks/hypothesis/issues/2662 

64 if value.tzinfo is None: 

65 return False 

66 try: 

67 # Does the naive portion of the datetime change when round-tripped to 

68 # UTC? If so, or if this overflows, we say that it does not exist. 

69 roundtrip = value.astimezone(dt.timezone.utc).astimezone(value.tzinfo) 

70 except OverflowError: 

71 # Overflows at datetime.min or datetime.max boundary condition. 

72 # Rejecting these is acceptable, because timezones are close to 

73 # meaningless before ~1900 and subject to a lot of change by 

74 # 9999, so it should be a very small fraction of possible values. 

75 return True 

76 

77 if ( 

78 value.tzinfo is not roundtrip.tzinfo 

79 and value.utcoffset() != roundtrip.utcoffset() 

80 ): 

81 # This only ever occurs during imaginary (i.e. nonexistent) datetimes, 

82 # and only for pytz timezones which do not follow PEP-495 semantics. 

83 # (may exclude a few other edge cases, but you should use zoneinfo anyway) 

84 return True 

85 

86 assert value.tzinfo is roundtrip.tzinfo, "so only the naive portions are compared" 

87 return value != roundtrip 

88 

89 

90def _num_days_in_month(year, month): 

91 """Branchless equivalent of ``monthrange(year, month)[1]`` for valid inputs. 

92 

93 Written using only arithmetic and (in)equality, with no branching or indexing. 

94 This avoids concretizing the input or adding more path constraints than necessary. 

95 """ 

96 leap = (year % 4 == 0) * (1 - (year % 100 == 0) * (year % 400 != 0)) 

97 is_feb = month == 2 

98 is_30_day = 1 - (month != 4) * (month != 6) * (month != 9) * (month != 11) 

99 return 31 - is_30_day - is_feb * (3 - leap) 

100 

101 

102def draw_capped_multipart( 

103 data, min_value, max_value, duration_names=DATENAMES + TIMENAMES 

104): 

105 assert isinstance(min_value, (dt.date, dt.time, dt.datetime)) 

106 assert type(min_value) == type(max_value) 

107 assert min_value <= max_value 

108 

109 # cap_{low, high} records whether every field drawn so far has equalled 

110 # ``min_value``'s / ``max_value``'s, i.e. whether that bound is still "active" and 

111 # constrains the next field. 

112 # 

113 # cap_{low, high} are conceptually booleans. We define them as integers and interpret 

114 # boolean operations on them as multiplication, so that we don't concretize or 

115 # branch under symbolic backends. See 

116 # https://github.com/HypothesisWorks/hypothesis/issues/4759. 

117 cap_low = 1 

118 cap_high = 1 

119 result = {} 

120 for name in duration_names: 

121 natural_low = getattr(dt.datetime.min, name) 

122 if name == "day": 

123 natural_high = _num_days_in_month(result["year"], result["month"]) 

124 else: 

125 natural_high = getattr(dt.datetime.max, name) 

126 # equivalent to: 

127 # low = min_value.<name> if cap_low else natural_low 

128 # high = max_value.<name> if cap_high else natural_high 

129 low = natural_low + cap_low * (getattr(min_value, name) - natural_low) 

130 high = natural_high + cap_high * (getattr(max_value, name) - natural_high) 

131 if name == "year": 

132 val = data.draw_integer(low, high, shrink_towards=2000) 

133 else: 

134 val = data.draw_integer(low, high) 

135 result[name] = val 

136 cap_low = cap_low * (val == low) 

137 cap_high = cap_high * (val == high) 

138 if hasattr(min_value, "fold"): 

139 # The `fold` attribute is ignored in comparison of naive datetimes. 

140 # In tz-aware datetimes it would require *very* invasive changes to 

141 # the logic above, and be very sensitive to the specific timezone 

142 # (at the cost of efficient shrinking and mutation), so at least for 

143 # now we stick with the status quo and generate it independently. 

144 result["fold"] = data.draw_integer(0, 1) 

145 return result 

146 

147 

148class DatetimeStrategy(SearchStrategy): 

149 def __init__(self, min_value, max_value, timezones_strat, allow_imaginary): 

150 super().__init__() 

151 assert isinstance(min_value, dt.datetime) 

152 assert isinstance(max_value, dt.datetime) 

153 assert min_value.tzinfo is None 

154 assert max_value.tzinfo is None 

155 assert min_value <= max_value 

156 assert isinstance(timezones_strat, SearchStrategy) 

157 assert isinstance(allow_imaginary, bool) 

158 self.min_value = min_value 

159 self.max_value = max_value 

160 self.tz_strat = timezones_strat 

161 self.allow_imaginary = allow_imaginary 

162 

163 def do_draw(self, data): 

164 # We start by drawing a timezone, and an initial datetime. 

165 tz = data.draw(self.tz_strat) 

166 result = self.draw_naive_datetime_and_combine(data, tz) 

167 

168 # TODO: with some probability, systematically search for one of 

169 # - an imaginary time (if allowed), 

170 # - a time within 24hrs of a leap second (if there any are within bounds), 

171 # - other subtle, little-known, or nasty issues as described in 

172 # https://github.com/HypothesisWorks/hypothesis/issues/69 

173 

174 # If we happened to end up with a disallowed imaginary time, reject it. 

175 if (not self.allow_imaginary) and datetime_does_not_exist(result): 

176 data.mark_invalid(f"{result} does not exist (usually a DST transition)") 

177 return result 

178 

179 def draw_naive_datetime_and_combine(self, data, tz): 

180 result = draw_capped_multipart(data, self.min_value, self.max_value) 

181 try: 

182 return replace_tzinfo(dt.datetime(**result), timezone=tz) 

183 except (ValueError, OverflowError): 

184 data.mark_invalid( 

185 f"Failed to draw a datetime between {self.min_value!r} and " 

186 f"{self.max_value!r} with timezone from {self.tz_strat!r}." 

187 ) 

188 

189 

190@defines_strategy(force_reusable_values=True) 

191def datetimes( 

192 min_value: dt.datetime = dt.datetime.min, 

193 max_value: dt.datetime = dt.datetime.max, 

194 *, 

195 timezones: SearchStrategy[dt.tzinfo | None] = none(), 

196 allow_imaginary: bool = True, 

197) -> SearchStrategy[dt.datetime]: 

198 """datetimes(min_value=datetime.datetime.min, max_value=datetime.datetime.max, *, timezones=none(), allow_imaginary=True) 

199 

200 A strategy for generating datetimes, which may be timezone-aware. 

201 

202 This strategy works by drawing a naive datetime between ``min_value`` 

203 and ``max_value``, which must both be naive (have no timezone). 

204 

205 ``timezones`` must be a strategy that generates either ``None``, for naive 

206 datetimes, or :class:`~python:datetime.tzinfo` objects for 'aware' datetimes. 

207 You can construct your own, though we recommend using one of these built-in 

208 strategies: 

209 

210 * with the standard library: :func:`hypothesis.strategies.timezones`; 

211 * with :pypi:`dateutil <python-dateutil>`: 

212 :func:`hypothesis.extra.dateutil.timezones`; or 

213 * with :pypi:`pytz`: :func:`hypothesis.extra.pytz.timezones`. 

214 

215 You may pass ``allow_imaginary=False`` to filter out "imaginary" datetimes 

216 which did not (or will not) occur due to daylight savings, leap seconds, 

217 timezone and calendar adjustments, etc. Imaginary datetimes are allowed 

218 by default, because malformed timestamps are a common source of bugs. 

219 

220 Examples from this strategy shrink towards midnight on January 1st 2000, 

221 local time. 

222 """ 

223 # Why must bounds be naive? In principle, we could also write a strategy 

224 # that took aware bounds, but the API and validation is much harder. 

225 # If you want to generate datetimes between two particular moments in 

226 # time I suggest (a) just filtering out-of-bounds values; (b) if bounds 

227 # are very close, draw a value and subtract its UTC offset, handling 

228 # overflows and nonexistent times; or (c) do something customised to 

229 # handle datetimes in e.g. a four-microsecond span which is not 

230 # representable in UTC. Handling (d), all of the above, leads to a much 

231 # more complex API for all users and a useful feature for very few. 

232 check_type(bool, allow_imaginary, "allow_imaginary") 

233 check_type(dt.datetime, min_value, "min_value") 

234 check_type(dt.datetime, max_value, "max_value") 

235 if min_value.tzinfo is not None: 

236 raise InvalidArgument(f"{min_value=} must not have tzinfo") 

237 if max_value.tzinfo is not None: 

238 raise InvalidArgument(f"{max_value=} must not have tzinfo") 

239 check_valid_interval(min_value, max_value, "min_value", "max_value") 

240 if not isinstance(timezones, SearchStrategy): 

241 raise InvalidArgument( 

242 f"{timezones=} must be a SearchStrategy that can " 

243 "provide tzinfo for datetimes (either None or dt.tzinfo objects)" 

244 ) 

245 return DatetimeStrategy(min_value, max_value, timezones, allow_imaginary) 

246 

247 

248class TimeStrategy(SearchStrategy): 

249 def __init__(self, min_value, max_value, timezones_strat): 

250 super().__init__() 

251 self.min_value = min_value 

252 self.max_value = max_value 

253 self.tz_strat = timezones_strat 

254 

255 def do_draw(self, data): 

256 result = draw_capped_multipart(data, self.min_value, self.max_value, TIMENAMES) 

257 tz = data.draw(self.tz_strat) 

258 return dt.time(**result, tzinfo=tz) 

259 

260 

261@defines_strategy(force_reusable_values=True) 

262def times( 

263 min_value: dt.time = dt.time.min, 

264 max_value: dt.time = dt.time.max, 

265 *, 

266 timezones: SearchStrategy[dt.tzinfo | None] = none(), 

267) -> SearchStrategy[dt.time]: 

268 """times(min_value=datetime.time.min, max_value=datetime.time.max, *, timezones=none()) 

269 

270 A strategy for times between ``min_value`` and ``max_value``. 

271 

272 The ``timezones`` argument is handled as for :py:func:`datetimes`. 

273 

274 Examples from this strategy shrink towards midnight, with the timezone 

275 component shrinking as for the strategy that provided it. 

276 """ 

277 check_type(dt.time, min_value, "min_value") 

278 check_type(dt.time, max_value, "max_value") 

279 if min_value.tzinfo is not None: 

280 raise InvalidArgument(f"{min_value=} must not have tzinfo") 

281 if max_value.tzinfo is not None: 

282 raise InvalidArgument(f"{max_value=} must not have tzinfo") 

283 check_valid_interval(min_value, max_value, "min_value", "max_value") 

284 return TimeStrategy(min_value, max_value, timezones) 

285 

286 

287class DateStrategy(SearchStrategy): 

288 def __init__(self, min_value, max_value): 

289 super().__init__() 

290 assert isinstance(min_value, dt.date) 

291 assert isinstance(max_value, dt.date) 

292 assert min_value < max_value 

293 self.min_value = min_value 

294 self.max_value = max_value 

295 

296 def do_draw(self, data): 

297 return dt.date( 

298 **draw_capped_multipart(data, self.min_value, self.max_value, DATENAMES) 

299 ) 

300 

301 def filter(self, condition): 

302 if ( 

303 isinstance(condition, partial) 

304 and len(args := condition.args) == 1 

305 and not condition.keywords 

306 and isinstance(arg := args[0], dt.date) 

307 and condition.func in (op.lt, op.le, op.eq, op.ge, op.gt) 

308 ): 

309 try: 

310 arg += dt.timedelta(days={op.lt: 1, op.gt: -1}.get(condition.func, 0)) 

311 except OverflowError: # gt date.max, or lt date.min 

312 return nothing() 

313 lo, hi = { 

314 # We're talking about op(arg, x) - the reverse of our usual intuition! 

315 op.lt: (arg, self.max_value), # lambda x: arg < x 

316 op.le: (arg, self.max_value), # lambda x: arg <= x 

317 op.eq: (arg, arg), # lambda x: arg == x 

318 op.ge: (self.min_value, arg), # lambda x: arg >= x 

319 op.gt: (self.min_value, arg), # lambda x: arg > x 

320 }[condition.func] 

321 lo = max(lo, self.min_value) 

322 hi = min(hi, self.max_value) 

323 if hi < lo: 

324 return nothing() 

325 if lo <= self.min_value and self.max_value <= hi: 

326 return self 

327 return dates(lo, hi) 

328 

329 return super().filter(condition) 

330 

331 

332@defines_strategy(force_reusable_values=True) 

333def dates( 

334 min_value: dt.date = dt.date.min, max_value: dt.date = dt.date.max 

335) -> SearchStrategy[dt.date]: 

336 """dates(min_value=datetime.date.min, max_value=datetime.date.max) 

337 

338 A strategy for dates between ``min_value`` and ``max_value``. 

339 

340 Examples from this strategy shrink towards January 1st 2000. 

341 """ 

342 check_type(dt.date, min_value, "min_value") 

343 check_type(dt.date, max_value, "max_value") 

344 # datetime is a subclass of date, so check_type() accepts it - but a datetime 

345 # bound is almost certainly a mistake, and breaks our drawing logic downstream. 

346 if isinstance(min_value, dt.datetime): 

347 raise InvalidArgument(f"{min_value=} is a datetime, but expected a date") 

348 if isinstance(max_value, dt.datetime): 

349 raise InvalidArgument(f"{max_value=} is a datetime, but expected a date") 

350 check_valid_interval(min_value, max_value, "min_value", "max_value") 

351 if min_value == max_value: 

352 return just(min_value) 

353 return DateStrategy(min_value, max_value) 

354 

355 

356class TimedeltaStrategy(SearchStrategy): 

357 def __init__(self, min_value, max_value): 

358 super().__init__() 

359 assert isinstance(min_value, dt.timedelta) 

360 assert isinstance(max_value, dt.timedelta) 

361 assert min_value < max_value 

362 self.min_value = min_value 

363 self.max_value = max_value 

364 

365 def do_draw(self, data): 

366 result = {} 

367 low_bound = True 

368 high_bound = True 

369 for name in ("days", "seconds", "microseconds"): 

370 low = getattr(self.min_value if low_bound else dt.timedelta.min, name) 

371 high = getattr(self.max_value if high_bound else dt.timedelta.max, name) 

372 val = data.draw_integer(low, high) 

373 result[name] = val 

374 low_bound = low_bound and val == low 

375 high_bound = high_bound and val == high 

376 return dt.timedelta(**result) 

377 

378 

379@defines_strategy(force_reusable_values=True) 

380def timedeltas( 

381 min_value: dt.timedelta = dt.timedelta.min, 

382 max_value: dt.timedelta = dt.timedelta.max, 

383) -> SearchStrategy[dt.timedelta]: 

384 """timedeltas(min_value=datetime.timedelta.min, max_value=datetime.timedelta.max) 

385 

386 A strategy for timedeltas between ``min_value`` and ``max_value``. 

387 

388 Examples from this strategy shrink towards zero. 

389 """ 

390 check_type(dt.timedelta, min_value, "min_value") 

391 check_type(dt.timedelta, max_value, "max_value") 

392 check_valid_interval(min_value, max_value, "min_value", "max_value") 

393 if min_value == max_value: 

394 return just(min_value) 

395 return TimedeltaStrategy(min_value=min_value, max_value=max_value) 

396 

397 

398@cache 

399def _valid_key_cacheable(tzpath, key): 

400 assert isinstance(tzpath, tuple) # zoneinfo changed, better update this function! 

401 for root in tzpath: 

402 if Path(root).joinpath(key).exists(): # pragma: no branch 

403 # No branch because most systems only have one TZPATH component. 

404 return True 

405 else: # pragma: no cover 

406 # This branch is only taken for names which are known to zoneinfo 

407 # but not present on the filesystem, i.e. on Windows with tzdata, 

408 # and so is never executed by our coverage tests. 

409 *package_loc, resource_name = key.split("/") 

410 package = "tzdata.zoneinfo." + ".".join(package_loc) 

411 try: 

412 return (resources.files(package) / resource_name).exists() 

413 except ModuleNotFoundError: 

414 return False 

415 

416 

417@defines_strategy(force_reusable_values=True) 

418def timezone_keys( 

419 *, 

420 # allow_alias: bool = True, 

421 # allow_deprecated: bool = True, 

422 allow_prefix: bool = True, 

423) -> SearchStrategy[str]: 

424 """A strategy for :wikipedia:`IANA timezone names <List_of_tz_database_time_zones>`. 

425 

426 As well as timezone names like ``"UTC"``, ``"Australia/Sydney"``, or 

427 ``"America/New_York"``, this strategy can generate: 

428 

429 - Aliases such as ``"Antarctica/McMurdo"``, which links to ``"Pacific/Auckland"``. 

430 - Deprecated names such as ``"Antarctica/South_Pole"``, which *also* links to 

431 ``"Pacific/Auckland"``. Note that most but 

432 not all deprecated timezone names are also aliases. 

433 - Timezone names with the ``"posix/"`` or ``"right/"`` prefixes, unless 

434 ``allow_prefix=False``. 

435 

436 These strings are provided separately from Tzinfo objects - such as ZoneInfo 

437 instances from the timezones() strategy - to facilitate testing of timezone 

438 logic without needing workarounds to access non-canonical names. 

439 

440 .. note:: 

441 

442 `The tzdata package is required on Windows 

443 <https://docs.python.org/3/library/zoneinfo.html#data-sources>`__. 

444 ``pip install hypothesis[zoneinfo]`` installs it, if and only if needed. 

445 

446 On Windows, you may need to access IANA timezone data via the :pypi:`tzdata` 

447 package. For non-IANA timezones, such as Windows-native names or GNU TZ 

448 strings, we recommend using :func:`~hypothesis.strategies.sampled_from` with 

449 the :pypi:`dateutil <python-dateutil>` package, e.g. 

450 :meth:`dateutil:dateutil.tz.tzwin.list`. 

451 """ 

452 # check_type(bool, allow_alias, "allow_alias") 

453 # check_type(bool, allow_deprecated, "allow_deprecated") 

454 check_type(bool, allow_prefix, "allow_prefix") 

455 

456 with warnings.catch_warnings(): 

457 try: 

458 warnings.simplefilter("ignore", EncodingWarning) 

459 except NameError: # pragma: no cover 

460 pass 

461 # On Python 3.12 (and others?), `available_timezones()` opens files 

462 # without specifying an encoding - which our selftests make an error. 

463 available_timezones = ("UTC", *sorted(zoneinfo.available_timezones())) 

464 

465 # TODO: filter out alias and deprecated names if disallowed 

466 

467 # When prefixes are allowed, we first choose a key and then flatmap to get our 

468 # choice with one of the available prefixes. That in turn means that we need 

469 # some logic to determine which prefixes are available for a given key: 

470 

471 def valid_key(key): 

472 return key == "UTC" or _valid_key_cacheable(zoneinfo.TZPATH, key) 

473 

474 # TODO: work out how to place a higher priority on "weird" timezones 

475 # For details see https://github.com/HypothesisWorks/hypothesis/issues/2414 

476 strategy = sampled_from([key for key in available_timezones if valid_key(key)]) 

477 

478 if not allow_prefix: 

479 return strategy 

480 

481 def sample_with_prefixes(zone): 

482 keys_with_prefixes = (zone, f"posix/{zone}", f"right/{zone}") 

483 return sampled_from([key for key in keys_with_prefixes if valid_key(key)]) 

484 

485 return strategy.flatmap(sample_with_prefixes) 

486 

487 

488@defines_strategy(force_reusable_values=True) 

489def timezones(*, no_cache: bool = False) -> SearchStrategy["zoneinfo.ZoneInfo"]: 

490 """A strategy for :class:`python:zoneinfo.ZoneInfo` objects. 

491 

492 If ``no_cache=True``, the generated instances are constructed using 

493 :meth:`ZoneInfo.no_cache <python:zoneinfo.ZoneInfo.no_cache>` instead 

494 of the usual constructor. This may change the semantics of your datetimes 

495 in surprising ways, so only use it if you know that you need to! 

496 

497 .. note:: 

498 

499 `The tzdata package is required on Windows 

500 <https://docs.python.org/3/library/zoneinfo.html#data-sources>`__. 

501 ``pip install hypothesis[zoneinfo]`` installs it, if and only if needed. 

502 """ 

503 check_type(bool, no_cache, "no_cache") 

504 return timezone_keys().map( 

505 zoneinfo.ZoneInfo.no_cache if no_cache else zoneinfo.ZoneInfo 

506 )