1+ from datetime import date , datetime
2+
13import narwhals as nw
2- import pandas as pd
3- from pandas .api .types import is_object_dtype , is_string_dtype
4- from pandas .core .dtypes .common import is_datetime64_any_dtype as is_datetime
5- from pandas .core .dtypes .common import is_numeric_dtype as is_numeric
4+ from dateutil .parser import parse as _dateutil_parse
65
76# ---------------------------------------------------------------------------
8- # pandas-only implementation.
7+ # narwhals implementation, used for every backend (pandas, polars, etc.)
98#
10- # These functions rely on pandas' flexible, dateutil-backed `pd.to_datetime`
11- # string guessing and on pandas' `object` dtype (which, unlike any narwhals
12- # dtype, can hold arbitrary non-string Python objects). Neither has a
13- # polars/narwhals equivalent, so they are kept exactly as they were before the
14- # narwhals migration and are only ever called on pandas input. See the `_nw_*`
15- # functions below for the polars/narwhals-backend equivalents.
9+ # Flexible date-string recognition (e.g. "01-Jan-2010", "10/11/12", not just
10+ # ISO-8601) is implemented directly on top of `dateutil` - the same library
11+ # pandas.to_datetime delegates to internally for this - so it works
12+ # identically regardless of the underlying dataframe library.
1613# ---------------------------------------------------------------------------
1714
1815
19- def is_object (s ) -> bool :
20- return is_object_dtype (s ) or is_string_dtype (s )
21-
22-
23- def _is_categorical_and_is_not_datetime (column : pd .Series ) -> bool :
24- # check for datetime only if the type of the categories is not numeric
25- # because pd.to_datetime throws an error when it is an integer
26- if isinstance (column .dtype , pd .CategoricalDtype ):
27- is_cat = _is_categories_num (column ) or not _is_convertible_to_dt (column )
28-
29- # check for datetime only if object cannot be cast as numeric because
30- # if it could pd.to_datetime would convert it to datetime regardless
31- elif is_object (column ):
32- is_cat = _is_convertible_to_num (column ) or not _is_convertible_to_dt (column )
33-
34- else :
35- is_cat = False
36-
37- return is_cat
16+ def _nw_is_date_or_datetime (dtype ) -> bool :
17+ # nw.selectors.datetime() only matches Datetime, not Date, so this needs
18+ # its own explicit check.
19+ return isinstance (dtype , (nw .Date , nw .Datetime ))
3820
3921
40- def _is_categories_num (column : pd .Series ) -> bool :
41- return is_numeric (column .dtype .categories )
22+ _DATE_PARSE_DEFAULT_1 = datetime (1 , 1 , 1 , 1 , 1 , 1 )
23+ _DATE_PARSE_DEFAULT_2 = datetime (2 , 2 , 2 , 2 , 2 , 2 )
24+ _DATETIME_FIELDS = ("year" , "month" , "day" , "hour" , "minute" , "second" )
4225
4326
44- def _is_convertible_to_dt (column : pd .Series ) -> bool :
27+ def _looks_like_date_string (value : str ) -> bool :
28+ # dateutil.parser.parse() fills in any date/time component that isn't
29+ # present in the string from a `default` datetime, so a bare number like
30+ # "20" "parses" successfully as day=20 - it would wrongly be treated as a
31+ # date. Parsing twice, with two defaults that differ in every field,
32+ # reveals which fields were actually present in the string: those are the
33+ # fields that agree between the two parses. Requiring at least 2 fields to
34+ # be corroborated this way rejects bare numbers while still accepting real
35+ # dates (including non-ISO formats like "01-Jan-2010") and bare times
36+ # (like "21:45:23").
4537 try :
46- var = pd . to_datetime ( column , utc = True )
47- return is_datetime ( var )
48- except Exception :
38+ first = _dateutil_parse ( value , default = _DATE_PARSE_DEFAULT_1 )
39+ second = _dateutil_parse ( value , default = _DATE_PARSE_DEFAULT_2 )
40+ except ( ValueError , OverflowError , TypeError ) :
4941 return False
5042
51-
52- def _is_convertible_to_num (column : pd .Series ) -> bool :
53- try :
54- ser = pd .to_numeric (column )
55- except (ValueError , TypeError ):
56- ser = column
57- return is_numeric (ser )
58-
59-
60- def _is_categorical_and_is_datetime (column : pd .Series ) -> bool :
61- # check for datetime only if the type of the categories is not numeric
62- # because pd.to_datetime throws an error when it is an integer
63- if isinstance (column .dtype , pd .CategoricalDtype ):
64- is_dt = not _is_categories_num (column ) and _is_convertible_to_dt (column )
65-
66- # check for datetime only if object cannot be cast as numeric because
67- # if it could pd.to_datetime would convert it to datetime regardless
68- elif is_object (column ):
69- is_dt = not _is_convertible_to_num (column ) and _is_convertible_to_dt (column )
70-
71- else :
72- is_dt = False
73-
74- return is_dt
75-
76-
77- # ---------------------------------------------------------------------------
78- # narwhals implementation, used for every backend other than pandas (polars,
79- # in practice).
80- #
81- # narwhals has no lenient/"try" cast (no `strict=False`, unlike raw polars)
82- # and its `str.to_datetime()` requires ISO-8601 or an explicit `format=` - it
83- # cannot reproduce pandas' dateutil-based guessing. So a string column such as
84- # "01-Jan-2010" or "10/11/12" is not auto-detected as datetime for polars,
85- # even though it is for pandas. ISO-8601 strings and native Date/Datetime
86- # columns are detected correctly. Users can always pass `variables` explicitly
87- # to sidestep this.
88- # ---------------------------------------------------------------------------
89-
90-
91- def _nw_is_date_or_datetime (dtype ) -> bool :
92- # nw.selectors.datetime() only matches Datetime, not Date, so this needs
93- # its own explicit check.
94- return isinstance (dtype , (nw .Date , nw .Datetime ))
43+ corroborated = sum (
44+ 1 for attr in _DATETIME_FIELDS if getattr (first , attr ) == getattr (second , attr )
45+ )
46+ return corroborated >= 2
9547
9648
9749def _nw_is_convertible_to_num (s : "nw.Series" ) -> bool :
50+ values = s .drop_nulls ().to_list ()
51+ if not values :
52+ return False
9853 try :
99- s .cast (nw .String ()).cast (nw .Float64 ())
100- except Exception :
54+ for value in values :
55+ float (value )
56+ except (ValueError , TypeError ):
10157 return False
10258 return True
10359
10460
10561def _nw_is_convertible_to_dt (s : "nw.Series" ) -> bool :
106- try :
107- s .cast (nw .String ()).str .to_datetime ()
108- except Exception :
62+ values = s .drop_nulls ().to_list ()
63+ if not values :
10964 return False
65+ for value in values :
66+ if isinstance (value , (date , datetime )):
67+ continue
68+ if not _looks_like_date_string (str (value )):
69+ return False
11070 return True
11171
11272
73+ def _nw_categories_are_numeric (s : "nw.Series" ) -> bool :
74+ return s .cat .get_categories ().dtype .is_numeric ()
75+
76+
11377def _nw_is_categorical_and_is_not_datetime (s : "nw.Series" ) -> bool :
11478 if isinstance (s .dtype , nw .Enum ):
11579 # an explicit, user-defined category set is an unambiguous categorical
11680 # signal, unlike a generic string column, so skip the datetime check
11781 return True
11882
11983 if isinstance (s .dtype , nw .Categorical ):
120- # polars categorical categories are always string-backed, unlike
121- # pandas' pd.Categorical, which can have numeric categories
122- return not _nw_is_convertible_to_dt (s )
123-
124- if isinstance (s .dtype , nw .String ):
84+ # check for datetime only if the categories are not numeric, because
85+ # a numeric-backed categorical (pandas-only - polars categories are
86+ # always string-backed) can never hold dates
87+ return _nw_categories_are_numeric (s ) or not _nw_is_convertible_to_dt (s )
88+
89+ if isinstance (s .dtype , (nw .String , nw .Object )):
90+ # check for datetime only if the column cannot be cast as numeric,
91+ # because if it could, it would be a numeric column, not a date
12592 return _nw_is_convertible_to_num (s ) or not _nw_is_convertible_to_dt (s )
12693
12794 return False
@@ -132,9 +99,9 @@ def _nw_is_categorical_and_is_datetime(s: "nw.Series") -> bool:
13299 return False
133100
134101 if isinstance (s .dtype , nw .Categorical ):
135- return _nw_is_convertible_to_dt (s )
102+ return not _nw_categories_are_numeric ( s ) and _nw_is_convertible_to_dt (s )
136103
137- if isinstance (s .dtype , nw .String ):
104+ if isinstance (s .dtype , ( nw .String , nw . Object ) ):
138105 return not _nw_is_convertible_to_num (s ) and _nw_is_convertible_to_dt (s )
139106
140107 return False
0 commit comments