|
| 1 | +"""Helpers for reading DashAI ``Date`` columns. |
| 2 | +
|
| 3 | +A ``Date`` column is stored as text plus a strptime format, so anything that |
| 4 | +needs real datetimes has to parse first. This module is the only place that |
| 5 | +does, because text ordering agrees with chronological ordering for ISO layouts |
| 6 | +alone: ``"31-01-2020" < "01-02-2020"`` holds as text and fails as dates. |
| 7 | +""" |
| 8 | + |
| 9 | +from typing import TYPE_CHECKING, Any, Final, List, Optional, Union |
| 10 | + |
| 11 | +if TYPE_CHECKING: |
| 12 | + import pandas as pd |
| 13 | + |
| 14 | +# The strptime format a Date column falls back to when nothing better is known. |
| 15 | +DEFAULT_DATE_FORMAT: Final[str] = "%Y-%m-%d" |
| 16 | + |
| 17 | +# Layouts ``pandas.guess_datetime_format`` does not produce. Two digit years |
| 18 | +# are the ones that matter: it returns None for "1/31/20". They are grouped by |
| 19 | +# component ordering so a day first hint can put the day first candidates ahead |
| 20 | +# of the month first ones, which is the only thing separating "01/02/20" from |
| 21 | +# itself read the other way round. |
| 22 | +_DAY_FIRST_EXTRA: Final[List[str]] = ["%d/%m/%y", "%d-%m-%y", "%d.%m.%y"] |
| 23 | +_MONTH_FIRST_EXTRA: Final[List[str]] = ["%m/%d/%y", "%m-%d-%y"] |
| 24 | +_YEAR_FIRST_EXTRA: Final[List[str]] = ["%y-%m-%d", "%y/%m/%d"] |
| 25 | + |
| 26 | +EXTRA_DATE_FORMATS: Final[List[str]] = ( |
| 27 | + _MONTH_FIRST_EXTRA + _DAY_FIRST_EXTRA + _YEAR_FIRST_EXTRA |
| 28 | +) |
| 29 | + |
| 30 | +# How many distinct values are handed to the format guesser. A single value can |
| 31 | +# be ambiguous ("01/02/2020"); a handful rarely all are. |
| 32 | +_SAMPLE_SIZE: Final[int] = 5 |
| 33 | + |
| 34 | + |
| 35 | +def _as_clean_text(values: Any) -> "pd.Series": |
| 36 | + """Normalise a column to stripped text with blanks treated as missing. |
| 37 | +
|
| 38 | + Parameters |
| 39 | + ---------- |
| 40 | + values : Any |
| 41 | + The column values. Anything ``pandas.Series`` accepts. |
| 42 | +
|
| 43 | + Returns |
| 44 | + ------- |
| 45 | + pandas.Series |
| 46 | + A string dtype series where empty and whitespace only entries are NA. |
| 47 | + """ |
| 48 | + import pandas as pd # local import |
| 49 | + |
| 50 | + series = values if isinstance(values, pd.Series) else pd.Series(list(values)) |
| 51 | + text = series.reset_index(drop=True).astype("string").str.strip() |
| 52 | + return text.mask(text == "") |
| 53 | + |
| 54 | + |
| 55 | +def _names_a_full_date(date_format: str) -> bool: |
| 56 | + """Check that a strptime format pins down a specific day. |
| 57 | +
|
| 58 | + A format naming only a year and a month, such as ``"%Y-%m"``, parses |
| 59 | + cleanly but silently invents a day of the month. Treating such a column as |
| 60 | + a Date would hand every consumer a date the data never stated, so those |
| 61 | + columns are better left as text. |
| 62 | +
|
| 63 | + Parameters |
| 64 | + ---------- |
| 65 | + date_format : str |
| 66 | + The strptime format to check. |
| 67 | +
|
| 68 | + Returns |
| 69 | + ------- |
| 70 | + bool |
| 71 | + ``True`` when the format names a day, a month and a year. |
| 72 | + """ |
| 73 | + has_day = "%d" in date_format |
| 74 | + has_month = any(token in date_format for token in ("%m", "%b", "%B")) |
| 75 | + has_year = "%Y" in date_format or "%y" in date_format |
| 76 | + return has_day and has_month and has_year |
| 77 | + |
| 78 | + |
| 79 | +def parse_date_column(values: Any, format: str = DEFAULT_DATE_FORMAT) -> "pd.Series": |
| 80 | + """Parse a column of date strings into datetimes. |
| 81 | +
|
| 82 | + Parameters |
| 83 | + ---------- |
| 84 | + values : Any |
| 85 | + The column values to parse. |
| 86 | + format : str, optional |
| 87 | + A strptime format such as ``"%d/%m/%Y"``. Defaults to |
| 88 | + ``DEFAULT_DATE_FORMAT``. |
| 89 | +
|
| 90 | + Returns |
| 91 | + ------- |
| 92 | + pandas.Series |
| 93 | + The parsed datetimes. Missing and blank entries stay ``NaT``. |
| 94 | +
|
| 95 | + Raises |
| 96 | + ------ |
| 97 | + ValueError |
| 98 | + If any non-missing value does not match ``format``. The message names |
| 99 | + up to three of the offending values. |
| 100 | + """ |
| 101 | + import pandas as pd # local import |
| 102 | + |
| 103 | + text = _as_clean_text(values) |
| 104 | + parsed = pd.to_datetime(text, format=format, errors="coerce") |
| 105 | + |
| 106 | + failed = text.notna() & parsed.isna() |
| 107 | + if failed.any(): |
| 108 | + sample = ", ".join(repr(value) for value in text[failed].unique()[:3]) |
| 109 | + raise ValueError( |
| 110 | + f"{int(failed.sum())} value(s) do not match the date format " |
| 111 | + f"'{format}': {sample}" |
| 112 | + ) |
| 113 | + |
| 114 | + return parsed |
| 115 | + |
| 116 | + |
| 117 | +def detect_date_format(values: Any, hint: Optional[str] = None) -> Optional[str]: |
| 118 | + """Find a strptime format that reads every value in a column. |
| 119 | +
|
| 120 | + Candidates come from ``pandas.guess_datetime_format`` applied to a sample |
| 121 | + of the values under both day first and month first readings, followed by |
| 122 | + ``EXTRA_DATE_FORMATS``. Each candidate is then checked against the whole |
| 123 | + column, which is what catches a guess that only fits the first few rows. |
| 124 | +
|
| 125 | + Parameters |
| 126 | + ---------- |
| 127 | + values : Any |
| 128 | + The column values to inspect. |
| 129 | + hint : str, optional |
| 130 | + The ptype label for this column. ``"date-eu"`` means day first, which |
| 131 | + is the only thing that can disambiguate a value like ``"01/02/2020"``. |
| 132 | +
|
| 133 | + Returns |
| 134 | + ------- |
| 135 | + str or None |
| 136 | + A strptime format that parses every non-missing value, or ``None`` |
| 137 | + when no candidate does. |
| 138 | + """ |
| 139 | + import warnings # local import |
| 140 | + |
| 141 | + from pandas.tseries.api import guess_datetime_format # local import |
| 142 | + |
| 143 | + text = _as_clean_text(values).dropna() |
| 144 | + if text.empty: |
| 145 | + return None |
| 146 | + |
| 147 | + day_first = hint == "date-eu" |
| 148 | + sample = text.drop_duplicates().head(_SAMPLE_SIZE) |
| 149 | + |
| 150 | + candidates: List[str] = [] |
| 151 | + # Trying both readings is the point, so pandas warning that a value looks |
| 152 | + # day first while asked month first says nothing new. Left unsuppressed it |
| 153 | + # fires for most columns of every upload. |
| 154 | + with warnings.catch_warnings(): |
| 155 | + warnings.simplefilter("ignore", UserWarning) |
| 156 | + for first in (day_first, not day_first): |
| 157 | + for value in sample: |
| 158 | + try: |
| 159 | + guess = guess_datetime_format(value, dayfirst=first) |
| 160 | + except (ValueError, TypeError): |
| 161 | + guess = None |
| 162 | + if guess and guess not in candidates: |
| 163 | + candidates.append(guess) |
| 164 | + |
| 165 | + extras = ( |
| 166 | + _DAY_FIRST_EXTRA + _MONTH_FIRST_EXTRA + _YEAR_FIRST_EXTRA |
| 167 | + if day_first |
| 168 | + else EXTRA_DATE_FORMATS |
| 169 | + ) |
| 170 | + candidates.extend(fmt for fmt in extras if fmt not in candidates) |
| 171 | + |
| 172 | + for candidate in candidates: |
| 173 | + if not _names_a_full_date(candidate): |
| 174 | + continue |
| 175 | + try: |
| 176 | + parse_date_column(text, candidate) |
| 177 | + except ValueError: |
| 178 | + continue |
| 179 | + return candidate |
| 180 | + |
| 181 | + return None |
| 182 | + |
| 183 | + |
| 184 | +def infer_frequency(dates: Any) -> Union[str, "pd.Timedelta", None]: |
| 185 | + """Describe the calendar spacing of a parsed date series. |
| 186 | +
|
| 187 | + Parameters |
| 188 | + ---------- |
| 189 | + dates : Any |
| 190 | + Already parsed datetimes, in ascending order. |
| 191 | +
|
| 192 | + Returns |
| 193 | + ------- |
| 194 | + str or pandas.Timedelta or None |
| 195 | + A pandas frequency alias when the dates sit on a regular grid, the |
| 196 | + most common gap between consecutive dates when they do not, and |
| 197 | + ``None`` when there is too little data to say anything. |
| 198 | + """ |
| 199 | + import pandas as pd # local import |
| 200 | + |
| 201 | + parsed = pd.to_datetime(pd.Series(list(dates)), errors="coerce").dropna() |
| 202 | + if len(parsed) < 3: |
| 203 | + return None |
| 204 | + |
| 205 | + index = pd.DatetimeIndex(parsed) |
| 206 | + try: |
| 207 | + alias = pd.infer_freq(index) |
| 208 | + except ValueError: |
| 209 | + alias = None |
| 210 | + if alias is not None: |
| 211 | + return alias |
| 212 | + |
| 213 | + gaps = index.to_series().diff().dropna() |
| 214 | + modes = gaps.mode() |
| 215 | + return modes.iloc[0] if not modes.empty else None |
0 commit comments