Skip to main content

polars_time/chunkedarray/string/
infer.rs

1use arrow::array::PrimitiveArray;
2use chrono::format::ParseErrorKind;
3use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime};
4use polars_core::prelude::*;
5
6use super::patterns::{self, Pattern};
7#[cfg(feature = "dtype-date")]
8use crate::chunkedarray::date::naive_date_to_date;
9use crate::prelude::string::strptime::StrpTimeState;
10
11polars_utils::regex_cache::cached_regex! {
12    static DATETIME_DMY_RE = r#"(?x)
13        ^
14        ['"]?                        # optional quotes
15        (?:\d{1,2})                  # day
16        [-/\.]                       # separator
17        (?P<month>[01]?\d{1})        # month
18        [-/\.]                       # separator
19        (?:\d{4,})                   # year
20        (?:
21            [T\ ]                    # separator
22            (?:\d{1,2})              # hour
23            :?                       # separator
24            (?:\d{1,2})              # minute
25            (?:
26                :?                   # separator
27                (?:\d{1,2})          # second
28                (?:
29                    \.(?:\d{1,9})    # subsecond
30                )?
31            )?
32        )?
33        ['"]?                        # optional quotes
34        $
35        "#;
36
37    static DATETIME_YMD_RE = r#"(?x)
38            ^
39            ['"]?                      # optional quotes
40            (?:\d{4,})                 # year
41            [-/\.]?                    # separator
42            (?P<month>[01]?\d{1})      # month
43            [-/\.]?                    # separator
44            (?:\d{1,2})                # day
45            (?:
46                [T\ ]                  # separator
47                (?:\d{1,2})            # hour
48                :?                     # separator
49                (?:\d{1,2})            # minute
50                (?:
51                    :?                 # separator
52                    (?:\d{1,2})        # seconds
53                    (?:
54                        \.(?:\d{1,9})  # subsecond
55                    )?
56                )?
57            )?
58            ['"]?                      # optional quotes
59            $
60            "#;
61
62    static DATETIME_YMDZ_RE = r#"(?x)
63            ^
64            ['"]?                  # optional quotes
65            (?:\d{4,})             # year
66            [-/\.]?                # separator
67            (?P<month>[01]?\d{1})  # month
68            [-/\.]?                # separator
69            (?:\d{1,2})            # year
70            [T\ ]                  # separator
71            (?:\d{2})              # hour
72            :?                     # separator
73            (?:\d{2})              # minute
74            (?:
75                :?                 # separator
76                (?:\d{2})          # second
77                (?:
78                    \.(?:\d{1,9})  # subsecond
79                )?
80            )?
81            (?:
82                # offset (e.g. +01:00, +0100, or +01)
83                [+-](?:\d{2})
84                (?::?\d{2})?
85                # or Zulu suffix
86                |Z
87            )
88            ['"]?                  # optional quotes
89            $
90            "#;
91}
92
93impl Pattern {
94    pub fn is_inferable(&self, val: &str) -> bool {
95        match self {
96            Pattern::DateDMY => true, // there are very few Date patterns, so it's cheaper
97            Pattern::DateYMD => true, // to just try them
98            Pattern::Time => true,
99            Pattern::DatetimeDMY => match DATETIME_DMY_RE.captures(val) {
100                Some(search) => (1..=12).contains(
101                    &search
102                        .name("month")
103                        .unwrap()
104                        .as_str()
105                        .parse::<u8>()
106                        .unwrap(),
107                ),
108                None => false,
109            },
110            Pattern::DatetimeYMD => match DATETIME_YMD_RE.captures(val) {
111                Some(search) => (1..=12).contains(
112                    &search
113                        .name("month")
114                        .unwrap()
115                        .as_str()
116                        .parse::<u8>()
117                        .unwrap(),
118                ),
119                None => false,
120            },
121            Pattern::DatetimeYMDZ => match DATETIME_YMDZ_RE.captures(val) {
122                Some(search) => (1..=12).contains(
123                    &search
124                        .name("month")
125                        .unwrap()
126                        .as_str()
127                        .parse::<u8>()
128                        .unwrap(),
129                ),
130                None => false,
131            },
132        }
133    }
134}
135
136pub trait StrpTimeParser<T> {
137    fn parse_bytes(&mut self, val: &[u8], time_unit: Option<TimeUnit>) -> Option<T>;
138}
139
140#[cfg(feature = "dtype-datetime")]
141impl StrpTimeParser<i64> for DatetimeInfer<Int64Type> {
142    fn parse_bytes(&mut self, val: &[u8], time_unit: Option<TimeUnit>) -> Option<i64> {
143        let transform = match time_unit {
144            Some(TimeUnit::Nanoseconds) => datetime_to_timestamp_ns,
145            Some(TimeUnit::Microseconds) => datetime_to_timestamp_us,
146            Some(TimeUnit::Milliseconds) => datetime_to_timestamp_ms,
147            _ => unreachable!(), // time_unit has to be provided for datetime
148        };
149        self.transform_bytes
150            .parse(val, self.latest_fmt.as_bytes())
151            .map(transform)
152            .or_else(|| {
153                // TODO! this will try all patterns.
154                // Somehow we must early escape if value is invalid.
155                for fmt in self.patterns {
156                    if let Some(parsed) = self
157                        .transform_bytes
158                        .parse(val, fmt.as_bytes())
159                        .map(datetime_to_timestamp_us)
160                    {
161                        self.latest_fmt = fmt;
162                        return Some(parsed);
163                    }
164                }
165                None
166            })
167    }
168}
169
170#[cfg(feature = "dtype-date")]
171impl StrpTimeParser<i32> for DatetimeInfer<Int32Type> {
172    fn parse_bytes(&mut self, val: &[u8], _time_unit: Option<TimeUnit>) -> Option<i32> {
173        self.transform_bytes
174            .parse(val, self.latest_fmt.as_bytes())
175            .map(|ndt| naive_date_to_date(ndt.date()))
176            .or_else(|| {
177                // TODO! this will try all patterns.
178                // somehow we must early escape if value is invalid
179                for fmt in self.patterns {
180                    if let Some(parsed) = self
181                        .transform_bytes
182                        .parse(val, fmt.as_bytes())
183                        .map(|ndt| naive_date_to_date(ndt.date()))
184                    {
185                        self.latest_fmt = fmt;
186                        return Some(parsed);
187                    }
188                }
189                None
190            })
191    }
192}
193
194#[derive(Clone)]
195pub struct DatetimeInfer<T: PolarsNumericType> {
196    pub pattern: Pattern,
197    patterns: &'static [&'static str],
198    latest_fmt: &'static str,
199    transform: fn(&str, &str) -> Option<T::Native>,
200    transform_bytes: StrpTimeState,
201    pub logical_type: DataType,
202}
203
204pub trait TryFromWithUnit<T>: Sized {
205    type Error;
206    fn try_from_with_unit(pattern: T, time_unit: Option<TimeUnit>) -> PolarsResult<Self>;
207}
208
209#[cfg(feature = "dtype-datetime")]
210impl TryFromWithUnit<Pattern> for DatetimeInfer<Int64Type> {
211    type Error = PolarsError;
212
213    fn try_from_with_unit(value: Pattern, time_unit: Option<TimeUnit>) -> PolarsResult<Self> {
214        let time_unit = time_unit.expect("time_unit must be provided for datetime");
215
216        let transform = match (time_unit, value) {
217            (TimeUnit::Milliseconds, Pattern::DatetimeYMDZ) => transform_tzaware_datetime_ms,
218            (TimeUnit::Milliseconds, _) => transform_datetime_ms,
219            (TimeUnit::Microseconds, Pattern::DatetimeYMDZ) => transform_tzaware_datetime_us,
220            (TimeUnit::Microseconds, _) => transform_datetime_us,
221            (TimeUnit::Nanoseconds, Pattern::DatetimeYMDZ) => transform_tzaware_datetime_ns,
222            (TimeUnit::Nanoseconds, _) => transform_datetime_ns,
223        };
224        let (pattern, patterns) = match value {
225            Pattern::DatetimeDMY | Pattern::DateDMY => {
226                (Pattern::DatetimeDMY, patterns::DATETIME_D_M_Y)
227            },
228            Pattern::DatetimeYMD | Pattern::DateYMD => {
229                (Pattern::DatetimeYMD, patterns::DATETIME_Y_M_D)
230            },
231            Pattern::DatetimeYMDZ => (Pattern::DatetimeYMDZ, patterns::DATETIME_Y_M_D_Z),
232            Pattern::Time => (Pattern::Time, patterns::TIME_H_M_S),
233        };
234
235        Ok(DatetimeInfer {
236            pattern,
237            patterns,
238            latest_fmt: patterns[0],
239            transform,
240            transform_bytes: StrpTimeState::default(),
241            logical_type: DataType::Datetime(time_unit, None),
242        })
243    }
244}
245
246#[cfg(feature = "dtype-date")]
247impl TryFromWithUnit<Pattern> for DatetimeInfer<Int32Type> {
248    type Error = PolarsError;
249
250    fn try_from_with_unit(value: Pattern, _time_unit: Option<TimeUnit>) -> PolarsResult<Self> {
251        match value {
252            Pattern::DateDMY => Ok(DatetimeInfer {
253                pattern: Pattern::DateDMY,
254                patterns: patterns::DATE_D_M_Y,
255                latest_fmt: patterns::DATE_D_M_Y[0],
256                transform: transform_date,
257                transform_bytes: StrpTimeState::default(),
258                logical_type: DataType::Date,
259            }),
260            Pattern::DateYMD => Ok(DatetimeInfer {
261                pattern: Pattern::DateYMD,
262                patterns: patterns::DATE_Y_M_D,
263                latest_fmt: patterns::DATE_Y_M_D[0],
264                transform: transform_date,
265                transform_bytes: StrpTimeState::default(),
266                logical_type: DataType::Date,
267            }),
268            _ => polars_bail!(ComputeError: "could not convert pattern"),
269        }
270    }
271}
272
273impl<T: PolarsNumericType> DatetimeInfer<T> {
274    pub fn parse(&mut self, val: &str) -> Option<T::Native> {
275        match (self.transform)(val, self.latest_fmt) {
276            Some(parsed) => Some(parsed),
277            // try other patterns
278            None => {
279                if !self.pattern.is_inferable(val) {
280                    return None;
281                }
282                for fmt in self.patterns {
283                    if let Some(parsed) = (self.transform)(val, fmt) {
284                        self.latest_fmt = fmt;
285                        return Some(parsed);
286                    }
287                }
288                None
289            },
290        }
291    }
292}
293
294impl<T: PolarsNumericType> DatetimeInfer<T> {
295    pub fn coerce_string(&mut self, ca: &StringChunked) -> Series {
296        let chunks = ca.downcast_iter().map(|array| {
297            let iter = array
298                .into_iter()
299                .map(|opt_val| opt_val.and_then(|val| self.parse(val)));
300            PrimitiveArray::from_trusted_len_iter(iter)
301        });
302        ChunkedArray::<T>::from_chunk_iter(ca.name().clone(), chunks)
303            .into_series()
304            .cast(&self.logical_type)
305            .unwrap()
306            .with_name(ca.name().clone())
307    }
308}
309
310#[cfg(feature = "dtype-date")]
311fn transform_date(val: &str, fmt: &str) -> Option<i32> {
312    NaiveDate::parse_from_str(val, fmt)
313        .ok()
314        .map(naive_date_to_date)
315}
316
317pub(crate) fn parse_datetime(val: &str, fmt: &str) -> Option<NaiveDateTime> {
318    NaiveDateTime::parse_from_str(val, fmt)
319        .or_else(|parse_error| match parse_error.kind() {
320            ParseErrorKind::NotEnough => {
321                NaiveDate::parse_from_str(val, fmt).map(|nd| nd.and_hms_opt(0, 0, 0).unwrap())
322            },
323            _ => Err(parse_error),
324        })
325        .ok()
326}
327
328pub(crate) fn parse_datetime_and_remainder<'a>(
329    val: &'a str,
330    fmt: &str,
331) -> Option<(NaiveDateTime, &'a str)> {
332    NaiveDateTime::parse_and_remainder(val, fmt)
333        .or_else(|parse_error| match parse_error.kind() {
334            ParseErrorKind::NotEnough => NaiveDate::parse_and_remainder(val, fmt)
335                .map(|(nd, r)| (nd.and_hms_opt(0, 0, 0).unwrap(), r)),
336            _ => Err(parse_error),
337        })
338        .ok()
339}
340
341#[cfg(feature = "dtype-datetime")]
342pub(crate) fn transform_datetime_ns(val: &str, fmt: &str) -> Option<i64> {
343    parse_datetime(val, fmt).map(datetime_to_timestamp_ns)
344}
345
346#[cfg(feature = "dtype-datetime")]
347pub(crate) fn transform_datetime_us(val: &str, fmt: &str) -> Option<i64> {
348    parse_datetime(val, fmt).map(datetime_to_timestamp_us)
349}
350
351#[cfg(feature = "dtype-datetime")]
352pub(crate) fn transform_datetime_ms(val: &str, fmt: &str) -> Option<i64> {
353    parse_datetime(val, fmt).map(datetime_to_timestamp_ms)
354}
355
356fn transform_tzaware_datetime_ns(val: &str, fmt: &str) -> Option<i64> {
357    let dt = DateTime::parse_from_str(val, fmt);
358    dt.ok().map(|dt| datetime_to_timestamp_ns(dt.naive_utc()))
359}
360
361fn transform_tzaware_datetime_us(val: &str, fmt: &str) -> Option<i64> {
362    let dt = DateTime::parse_from_str(val, fmt);
363    dt.ok().map(|dt| datetime_to_timestamp_us(dt.naive_utc()))
364}
365
366fn transform_tzaware_datetime_ms(val: &str, fmt: &str) -> Option<i64> {
367    let dt = DateTime::parse_from_str(val, fmt);
368    dt.ok().map(|dt| datetime_to_timestamp_ms(dt.naive_utc()))
369}
370
371pub fn infer_pattern_single(val: &str) -> Option<Pattern> {
372    // Dates come first, because we see datetimes as superset of dates
373    infer_pattern_date_single(val)
374        .or_else(|| infer_pattern_time_single(val))
375        .or_else(|| infer_pattern_datetime_single(val))
376}
377
378pub fn infer_pattern_datetime_single(val: &str) -> Option<Pattern> {
379    if patterns::DATETIME_D_M_Y.iter().any(|fmt| {
380        NaiveDateTime::parse_from_str(val, fmt).is_ok()
381            || NaiveDate::parse_from_str(val, fmt).is_ok()
382    }) {
383        Some(Pattern::DatetimeDMY)
384    } else if patterns::DATETIME_Y_M_D.iter().any(|fmt| {
385        NaiveDateTime::parse_from_str(val, fmt).is_ok()
386            || NaiveDate::parse_from_str(val, fmt).is_ok()
387    }) {
388        Some(Pattern::DatetimeYMD)
389    } else if patterns::DATETIME_Y_M_D_Z
390        .iter()
391        .any(|fmt| NaiveDateTime::parse_from_str(val, fmt).is_ok())
392    {
393        Some(Pattern::DatetimeYMDZ)
394    } else {
395        None
396    }
397}
398
399pub fn infer_pattern_date_single(val: &str) -> Option<Pattern> {
400    if patterns::DATE_D_M_Y
401        .iter()
402        .any(|fmt| NaiveDate::parse_from_str(val, fmt).is_ok())
403    {
404        Some(Pattern::DateDMY)
405    } else if patterns::DATE_Y_M_D
406        .iter()
407        .any(|fmt| NaiveDate::parse_from_str(val, fmt).is_ok())
408    {
409        Some(Pattern::DateYMD)
410    } else {
411        None
412    }
413}
414
415pub fn infer_pattern_time_single(val: &str) -> Option<Pattern> {
416    sniff_time_fmt(val).is_some().then_some(Pattern::Time)
417}
418
419/// Return the first format string from `TIME_H_M_S` that parses `val`, or `None`.
420pub fn sniff_time_fmt(val: &str) -> Option<&'static str> {
421    patterns::TIME_H_M_S
422        .iter()
423        .copied()
424        .find(|fmt| NaiveTime::parse_from_str(val, fmt).is_ok())
425}
426
427#[cfg(feature = "dtype-datetime")]
428pub fn to_datetime_with_inferred_tz(
429    ca: &StringChunked,
430    tu: TimeUnit,
431    strict: bool,
432    exact: bool,
433    ambiguous: &StringChunked,
434) -> PolarsResult<DatetimeChunked> {
435    use super::StringMethods;
436
437    let out = if exact {
438        to_datetime(ca, tu, None, ambiguous, false)
439    } else {
440        ca.as_datetime_not_exact(None, tu, false, None, ambiguous, false)
441    }?;
442
443    if strict && ca.null_count() != out.null_count() {
444        polars_core::utils::handle_casting_failures(
445            &ca.clone().into_series(),
446            &out.clone().into_series(),
447        )?;
448    }
449
450    Ok(out)
451}
452
453#[cfg(feature = "dtype-datetime")]
454pub fn to_datetime(
455    ca: &StringChunked,
456    tu: TimeUnit,
457    tz: Option<&TimeZone>,
458    ambiguous: &StringChunked,
459    // Ensure that the inferred time_zone matches the given time_zone.
460    ensure_matching_time_zone: bool,
461) -> PolarsResult<DatetimeChunked> {
462    match ca.first_non_null() {
463        None => {
464            Ok(Int64Chunked::full_null(ca.name().clone(), ca.len()).into_datetime(tu, tz.cloned()))
465        },
466        Some(idx) => {
467            let subset = ca.slice(idx as i64, ca.len());
468            let pattern = subset
469                .iter()
470                .find_map(|opt_val| opt_val.and_then(infer_pattern_datetime_single))
471                .ok_or_else(|| polars_err!(parse_fmt_idk = "date"))?;
472            let mut infer = DatetimeInfer::<Int64Type>::try_from_with_unit(pattern, Some(tu))?;
473            #[cfg(feature = "timezones")]
474            if matches!(pattern, Pattern::DatetimeYMDZ) {
475                polars_ensure!(
476                    !ensure_matching_time_zone || tz.is_some(),
477                    to_datetime_tz_mismatch
478                );
479            }
480            coerce_string_to_datetime(&mut infer, ca, tz, ambiguous)
481        },
482    }
483}
484/// Apply a pre-built `DatetimeInfer<Int32Type>` to a `StringChunked`, returning a `DateChunked`.
485#[cfg(feature = "dtype-date")]
486pub fn coerce_string_to_date(
487    infer: &mut DatetimeInfer<Int32Type>,
488    ca: &StringChunked,
489) -> PolarsResult<DateChunked> {
490    infer.coerce_string(ca).date().cloned()
491}
492
493/// Apply a pre-built `DatetimeInfer<Int64Type>` to a `StringChunked`, applying tz handling,
494/// returning a `DatetimeChunked`. Mirrors the post-`coerce_string` logic in `to_datetime`.
495#[cfg(feature = "dtype-datetime")]
496pub fn coerce_string_to_datetime(
497    infer: &mut DatetimeInfer<Int64Type>,
498    ca: &StringChunked,
499    tz: Option<&TimeZone>,
500    ambiguous: &StringChunked,
501) -> PolarsResult<DatetimeChunked> {
502    let DataType::Datetime(tu, _) = &infer.logical_type else {
503        unreachable!()
504    };
505    let tu = *tu;
506    match infer.pattern {
507        #[cfg(feature = "timezones")]
508        Pattern::DatetimeYMDZ => infer.coerce_string(ca).datetime().map(|ca| {
509            let mut ca = ca.clone();
510            ca.set_time_unit_and_time_zone(tu, tz.cloned().unwrap_or(TimeZone::UTC))?;
511            Ok(ca)
512        })?,
513        _ => infer.coerce_string(ca).datetime().map(|ca| {
514            let mut ca = ca.clone();
515            ca.set_time_unit(tu);
516            match tz {
517                #[cfg(feature = "timezones")]
518                Some(tz) => polars_ops::prelude::replace_time_zone(
519                    &ca,
520                    Some(tz),
521                    ambiguous,
522                    NonExistent::Raise,
523                ),
524                _ => Ok(ca),
525            }
526        })?,
527    }
528}
529
530#[cfg(feature = "dtype-date")]
531pub(crate) fn to_date(ca: &StringChunked) -> PolarsResult<DateChunked> {
532    match ca.first_non_null() {
533        None => Ok(Int32Chunked::full_null(ca.name().clone(), ca.len()).into_date()),
534        Some(idx) => {
535            let subset = ca.slice(idx as i64, ca.len());
536            let pattern = subset
537                .iter()
538                .find_map(|opt_val| opt_val.and_then(infer_pattern_date_single))
539                .ok_or_else(|| polars_err!(parse_fmt_idk = "date"))?;
540            let mut infer = DatetimeInfer::<Int32Type>::try_from_with_unit(pattern, None).unwrap();
541            coerce_string_to_date(&mut infer, ca)
542        },
543    }
544}