1212from collections .abc import Sequence
1313from copy import copy
1414from dataclasses import dataclass
15- from datetime import datetime , timedelta
15+ from datetime import datetime , timedelta , timezone
1616from typing import Any , Generic , Union
1717
1818import numpy as np
3939except 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
4357def 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
57139def 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 ()
0 commit comments