Skip to content

Commit 0addb38

Browse files
committed
Support timezone-aware datetime dtypes in hypothesis.extra.pandas
Passing a DatetimeTZDtype - e.g. "datetime64[ns, UTC]" - as the dtype of series, indexes, or column now generates columns where every value shares that single timezone, at the resolution given by the dtype's unit. Values are generated as naive datetime64[unit] (the canonical UTC int64 storage, including NaT) and the timezone is attached at the array level with tz_localize("UTC").tz_convert(tz). Constructing from timezone-aware scalars would silently corrupt values near the int64 bounds, and Python datetime objects cannot express the coarser-unit range at all. For UTC and fixed-offset timezones the values cover the dtype's full representable range. Timezones whose UTC offset varies over time resolve offsets through stdlib datetimes - as does pandas when displaying values - so those are clamped to keep localized wall times within years 1-9999. Requires pandas >= 2.1: earlier versions silently coerce non-ns units to nanoseconds, and pandas 2.0.x raises OutOfBoundsDatetime when formatting values outside the ns-representable range. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpEzQ8tW58JE94fBuFWgw5
1 parent ab93776 commit 0addb38

5 files changed

Lines changed: 365 additions & 8 deletions

File tree

hypothesis/RELEASE.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
RELEASE_TYPE: minor
2+
3+
This release adds support for generating timezone-aware datetimes in
4+
:ref:`hypothesis.extra.pandas <hypothesis-pandas>`. You can now pass a
5+
:class:`~pandas.DatetimeTZDtype` - such as ``"datetime64[ns, UTC]"`` - as the
6+
``dtype`` of a :func:`~hypothesis.extra.pandas.series`,
7+
:func:`~hypothesis.extra.pandas.indexes`, or
8+
:func:`~hypothesis.extra.pandas.column`, and every value will share that single
9+
timezone (the only arrangement pandas supports outside of the ``object``
10+
dtype). The datetime resolution is taken from the dtype, so you can also
11+
generate e.g. ``"datetime64[us, UTC]"`` columns, and for UTC and other
12+
fixed-offset timezones the generated values cover the full range representable
13+
at that resolution - which for coarser units is far wider than the
14+
``datetime64[ns]`` bounds of roughly 1677-2262 (:issue:`4020`).
15+
16+
Timezone-aware generation requires pandas >= 2.1: earlier versions silently
17+
coerce other resolutions to nanoseconds, or crash when displaying values
18+
outside the nanosecond-representable range. Generated values include ``NaT``
19+
unless you pass an elements strategy which excludes it.

hypothesis/src/hypothesis/extra/pandas/impl.py

Lines changed: 135 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from collections.abc import Sequence
1313
from copy import copy
1414
from dataclasses import dataclass
15-
from datetime import datetime, timedelta
15+
from datetime import datetime, timedelta, timezone
1616
from typing import Any, Generic, Union
1717

1818
import numpy as np
@@ -39,6 +39,20 @@
3939
except ImportError:
4040
IntegerDtype = ()
4141

42+
try:
43+
from pandas._libs.tslibs.timezones import is_fixed_offset, is_utc
44+
45+
def has_fixed_offset(tz):
46+
return is_utc(tz) or is_fixed_offset(tz)
47+
48+
except ImportError:
49+
50+
def has_fixed_offset(tz):
51+
return isinstance(tz, timezone)
52+
53+
54+
PANDAS_GE_21 = tuple(int(x) for x in pandas.__version__.split(".")[:2]) >= (2, 1)
55+
4256

4357
def dtype_for_elements_strategy(s):
4458
return st.shared(
@@ -53,6 +67,74 @@ def infer_dtype_if_necessary(dtype, values, elements, draw):
5367
return dtype
5468

5569

70+
def datetime_tz_dtype(dtype):
71+
"""Return a normalised :class:`~pandas.DatetimeTZDtype` if ``dtype`` is a
72+
timezone-aware datetime dtype (e.g. ``"datetime64[ns, UTC]"``), else None.
73+
74+
Every value in such a column or index shares a single timezone - the only
75+
arrangement pandas supports outside of the ``object`` dtype.
76+
"""
77+
tz_dtype = None
78+
if isinstance(dtype, pandas.DatetimeTZDtype):
79+
tz_dtype = dtype
80+
elif isinstance(dtype, str):
81+
try:
82+
converted = pandas.api.types.pandas_dtype(dtype)
83+
except TypeError:
84+
return None
85+
if isinstance(converted, pandas.DatetimeTZDtype):
86+
tz_dtype = converted
87+
if tz_dtype is not None and not PANDAS_GE_21: # pragma: no cover
88+
# Before pandas 2.0, non-nanosecond units silently coerce to ns; on
89+
# pandas 2.0.x, formatting values outside the ns-representable range
90+
# raises OutOfBoundsDatetime, so failing examples could not be shown.
91+
raise InvalidArgument(
92+
"Generating timezone-aware datetimes requires pandas >= 2.1, but "
93+
f"you have pandas {pandas.__version__}"
94+
)
95+
return tz_dtype
96+
97+
98+
def naive_datetime64_dtype(tz_dtype):
99+
"""The underlying timezone-naive ``datetime64[unit]`` dtype that backs a
100+
:class:`~pandas.DatetimeTZDtype`."""
101+
return np.dtype(f"datetime64[{tz_dtype.unit}]")
102+
103+
104+
def tz_default_elements(tz_dtype):
105+
"""Default element strategy for a :class:`~pandas.DatetimeTZDtype`: naive
106+
``datetime64[unit]`` values, interpreted as UTC instants.
107+
108+
With a fixed-offset timezone (including UTC) these cover the dtype's full
109+
representable range. Other timezones resolve their UTC offsets through
110+
stdlib datetimes - as does pandas when displaying values - so we keep each
111+
instant a day inside Python's representable range, ensuring that the
112+
localized wall times stay valid too.
113+
"""
114+
naive = naive_datetime64_dtype(tz_dtype)
115+
if has_fixed_offset(tz_dtype.tz):
116+
return npst.from_dtype(naive)
117+
unit = tz_dtype.unit
118+
lo = int(np.datetime64(datetime.min + timedelta(days=1), unit).astype(np.int64))
119+
hi = int(np.datetime64(datetime.max - timedelta(days=1), unit).astype(np.int64))
120+
values = st.integers(lo, hi).map(lambda v: np.datetime64(v, unit))
121+
return values | st.just(np.datetime64("NaT", unit))
122+
123+
124+
def attach_timezone(obj, tz):
125+
"""Interpret a naive datetime64 :class:`~pandas.Series` or
126+
:class:`~pandas.Index` as UTC instants and convert it to ``tz``.
127+
128+
We build timezone-aware columns from naive ``datetime64[unit]`` values and
129+
only attach the timezone at the array level, because constructing them from
130+
timezone-aware scalars silently corrupts values near the bounds of the
131+
representable range.
132+
"""
133+
if isinstance(obj, pandas.Series):
134+
return obj.dt.tz_localize("UTC").dt.tz_convert(tz)
135+
return obj.tz_localize("UTC").tz_convert(tz)
136+
137+
56138
@check_function
57139
def elements_and_dtype(elements, dtype, source=None):
58140
if source is None:
@@ -103,6 +185,16 @@ def elements_and_dtype(elements, dtype, source=None):
103185
"here. See https://stackoverflow.com/q/74355937 for workaround patterns."
104186
)
105187

188+
tz_dtype = datetime_tz_dtype(dtype)
189+
if tz_dtype is not None:
190+
# With the default elements strategy we build the column from the
191+
# underlying naive datetime64[unit] dtype and the caller attaches the
192+
# timezone afterwards. Custom elements are passed through and
193+
# constructed with the tz dtype.
194+
if elements is None:
195+
return tz_default_elements(tz_dtype), naive_datetime64_dtype(tz_dtype)
196+
return elements, tz_dtype
197+
106198
_get_subclasses = getattr(IntegerDtype, "__subclasses__", list)
107199
dtype = {t.name: t() for t in _get_subclasses()}.get(dtype, dtype)
108200

@@ -246,11 +338,17 @@ def indexes(
246338
check_valid_interval(min_size, max_size, "min_size", "max_size")
247339
check_type(bool, unique, "unique")
248340

341+
tz_dtype = datetime_tz_dtype(dtype)
342+
localize_tz = tz_dtype is not None and elements is None
343+
249344
elements, dtype = elements_and_dtype(elements, dtype)
250345

251346
if max_size is None:
252347
max_size = min_size + DEFAULT_MAX_SIZE
253-
return ValueIndexStrategy(elements, dtype, min_size, max_size, unique, name)
348+
strategy = ValueIndexStrategy(elements, dtype, min_size, max_size, unique, name)
349+
if localize_tz:
350+
return strategy.map(lambda ix: attach_timezone(ix, tz_dtype.tz))
351+
return strategy
254352

255353

256354
@defines_strategy()
@@ -283,6 +381,15 @@ def series(
283381
elements strategy varies, then so will the resulting dtype of the
284382
series.
285383
384+
With pandas >= 2.1, you may also pass a timezone-aware
385+
:class:`~pandas.DatetimeTZDtype` (e.g. ``"datetime64[ns, UTC]"``), in
386+
which case every value in the series will share that single timezone.
387+
For UTC and other fixed-offset timezones the generated values cover the
388+
full range representable at the dtype's resolution - which for coarser
389+
units is much wider than ``datetime64[ns]`` - while timezones with a
390+
UTC offset that varies over time are limited to years 1-9999. Values
391+
include ``NaT`` unless you pass an elements strategy which excludes it.
392+
286393
* index: If not None, a strategy for generating indexes for the
287394
resulting Series. This can generate either :class:`pandas.Index`
288395
objects or any sequence of values (which will be passed to the
@@ -309,11 +416,18 @@ def series(
309416
else:
310417
check_strategy(index, "index")
311418

419+
tz_dtype = datetime_tz_dtype(dtype)
420+
localize_tz = tz_dtype is not None and elements is None
421+
312422
elements, np_dtype = elements_and_dtype(elements, dtype)
313423
index_strategy = index
314424

425+
if localize_tz:
426+
# Build the column with the underlying naive dtype, then attach the
427+
# timezone at the array level once it has been assembled.
428+
dtype = np_dtype
315429
# if it is converted to an object, use object for series type
316-
if (
430+
elif (
317431
np_dtype is not None
318432
and np_dtype.kind == "O"
319433
and not isinstance(dtype, IntegerDtype)
@@ -360,6 +474,8 @@ def result(draw):
360474
name=draw(name),
361475
)
362476

477+
if localize_tz:
478+
return result().map(lambda s: attach_timezone(s, tz_dtype.tz))
363479
return result()
364480

365481

@@ -561,6 +677,10 @@ def row():
561677

562678
rewritten_columns = []
563679
column_names: set[str] = set()
680+
# Maps the name of each timezone-aware datetime column to its timezone. We
681+
# build such columns with the underlying naive dtype and attach the
682+
# timezone once the frame has been assembled (see attach_timezone).
683+
tz_columns: dict = {}
564684

565685
for i, c in enumerate(cols):
566686
check_type(column, c, f"columns[{i}]")
@@ -583,7 +703,12 @@ def row():
583703
raise InvalidArgument(f"duplicate definition of column name {c.name!r}")
584704

585705
column_names.add(c.name)
586-
c.elements, _ = elements_and_dtype(c.elements, c.dtype, label)
706+
column_tz = datetime_tz_dtype(c.dtype)
707+
localize_tz = column_tz is not None and c.elements is None
708+
c.elements, np_dtype = elements_and_dtype(c.elements, c.dtype, label)
709+
if localize_tz:
710+
c.dtype = np_dtype
711+
tz_columns[c.name] = column_tz.tz
587712

588713
if c.dtype is None and rows is not None:
589714
raise InvalidArgument(
@@ -668,7 +793,10 @@ def just_draw_columns(draw):
668793
)
669794
)
670795

671-
return pandas.DataFrame(data, index=index)
796+
result = pandas.DataFrame(data, index=index)
797+
for name, tz in tz_columns.items():
798+
result[name] = attach_timezone(result[name], tz)
799+
return result
672800

673801
return just_draw_columns()
674802
else:
@@ -756,6 +884,8 @@ def assign_rows(draw):
756884
break
757885
else:
758886
reject()
887+
for name, tz in tz_columns.items():
888+
result[name] = attach_timezone(result[name], tz)
759889
return result
760890

761891
return assign_rows()

hypothesis/tests/pandas/test_data_frame.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from hypothesis import HealthCheck, given, reject, settings, strategies as st
1616
from hypothesis.errors import InvalidArgument
1717
from hypothesis.extra import numpy as npst, pandas as pdst
18-
from hypothesis.extra.pandas.impl import IntegerDtype
18+
from hypothesis.extra.pandas.impl import PANDAS_GE_21, IntegerDtype
1919

2020
from tests.common.debug import (
2121
assert_all_examples,
@@ -31,6 +31,48 @@ def test_can_have_columns_of_distinct_types(df):
3131
assert df["b"].dtype == np.dtype(float)
3232

3333

34+
requires_pandas21 = pytest.mark.skipif(
35+
not PANDAS_GE_21, reason="timezone-aware dtypes require pandas >= 2.1"
36+
)
37+
38+
39+
@requires_pandas21
40+
def test_can_have_tz_aware_datetime_columns():
41+
dtype = pd.DatetimeTZDtype(unit="us", tz="UTC")
42+
assert_all_examples(
43+
pdst.data_frames(
44+
[pdst.column("a", dtype=dtype), pdst.column("b", dtype=dtype, unique=True)],
45+
index=pdst.range_indexes(min_size=1),
46+
),
47+
lambda df: df["a"].dtype == dtype
48+
and df["b"].dtype == dtype
49+
and df["b"].dropna().is_unique,
50+
)
51+
52+
53+
@requires_pandas21
54+
def test_can_have_tz_aware_datetime_index():
55+
dtype = pd.DatetimeTZDtype(unit="ns", tz="UTC")
56+
assert_all_examples(
57+
pdst.data_frames(
58+
[pdst.column("a", dtype=int)], index=pdst.indexes(dtype=dtype, min_size=1)
59+
),
60+
lambda df: df.index.dtype == dtype,
61+
)
62+
63+
64+
@requires_pandas21
65+
def test_can_have_tz_aware_datetime_columns_with_rows():
66+
dtype = pd.DatetimeTZDtype(unit="us", tz="UTC")
67+
assert_all_examples(
68+
pdst.data_frames(
69+
[pdst.column("a", dtype=dtype)],
70+
rows=st.tuples(st.datetimes()),
71+
),
72+
lambda df: df["a"].dtype == dtype,
73+
)
74+
75+
3476
@given(
3577
pdst.data_frames(
3678
[pdst.column(dtype=int)], index=pdst.range_indexes(min_size=1, max_size=5)

hypothesis/tests/pandas/test_indexes.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
from hypothesis import HealthCheck, given, reject, settings, strategies as st
1818
from hypothesis.errors import Unsatisfiable
1919
from hypothesis.extra import numpy as npst, pandas as pdst
20+
from hypothesis.extra.pandas.impl import PANDAS_GE_21
2021

21-
from tests.common.debug import check_can_generate_examples
22+
from tests.common.debug import assert_all_examples, check_can_generate_examples
2223
from tests.pandas.helpers import supported_by_pandas
2324

2425

@@ -63,6 +64,19 @@ def test_name_passed_on_indexes(s):
6364
assert s.name == "test_name"
6465

6566

67+
@pytest.mark.skipif(
68+
not PANDAS_GE_21, reason="timezone-aware dtypes require pandas >= 2.1"
69+
)
70+
@pytest.mark.parametrize("unit", ["s", "ms", "us", "ns"])
71+
def test_tz_aware_datetime_indexes(unit):
72+
dtype = pandas.DatetimeTZDtype(unit=unit, tz="UTC")
73+
check_can_generate_examples(pdst.indexes(dtype=dtype, min_size=1))
74+
assert_all_examples(
75+
pdst.indexes(dtype=dtype, min_size=1, unique=True),
76+
lambda ix: ix.dtype == dtype and ix.dropna().is_unique,
77+
)
78+
79+
6680
# Sizes that fit into an int64 without overflow
6781
range_sizes = st.integers(0, 2**63 - 1)
6882

0 commit comments

Comments
 (0)