Skip to content

Commit 3de901c

Browse files
authored
fix(rust, python): write tz-aware datetimes to csv (pola-rs#6135)
1 parent 01bd20e commit 3de901c

File tree

4 files changed

+63
-43
lines changed

4 files changed

+63
-43
lines changed

polars/polars-core/src/fmt.rs

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use std::fmt::{Debug, Display, Formatter};
99
feature = "dtype-time"
1010
))]
1111
use arrow::temporal_conversions::*;
12+
use chrono::NaiveDateTime;
1213
#[cfg(feature = "timezones")]
1314
use chrono::TimeZone;
1415
#[cfg(any(feature = "fmt", feature = "fmt_no_tty"))]
@@ -714,28 +715,8 @@ impl Display for AnyValue<'_> {
714715
};
715716
match tz {
716717
None => write!(f, "{ndt}"),
717-
Some(_tz) => {
718-
#[cfg(feature = "timezones")]
719-
{
720-
match _tz.parse::<chrono_tz::Tz>() {
721-
Ok(tz) => {
722-
let dt_utc = chrono::Utc.from_local_datetime(&ndt).unwrap();
723-
let dt_tz_aware = dt_utc.with_timezone(&tz);
724-
write!(f, "{dt_tz_aware}")
725-
}
726-
Err(_) => match parse_offset(_tz) {
727-
Ok(offset) => {
728-
let dt_tz_aware = offset.from_utc_datetime(&ndt);
729-
write!(f, "{dt_tz_aware}")
730-
}
731-
Err(_) => write!(f, "invalid timezone"),
732-
},
733-
}
734-
}
735-
#[cfg(not(feature = "timezones"))]
736-
{
737-
panic!("activate 'timezones' feature")
738-
}
718+
Some(tz) => {
719+
write!(f, "{}", PlTzAware::new(ndt, tz))
739720
}
740721
}
741722
}
@@ -776,6 +757,43 @@ impl Display for AnyValue<'_> {
776757
}
777758
}
778759

760+
/// Utility struct to format a timezone aware datetime.
761+
#[allow(dead_code)]
762+
pub struct PlTzAware<'a> {
763+
ndt: NaiveDateTime,
764+
tz: &'a str,
765+
}
766+
impl<'a> PlTzAware<'a> {
767+
pub fn new(ndt: NaiveDateTime, tz: &'a str) -> Self {
768+
Self { ndt, tz }
769+
}
770+
}
771+
772+
impl Display for PlTzAware<'_> {
773+
#[allow(unused_variables)]
774+
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
775+
#[cfg(feature = "timezones")]
776+
match self.tz.parse::<chrono_tz::Tz>() {
777+
Ok(tz) => {
778+
let dt_utc = chrono::Utc.from_local_datetime(&self.ndt).unwrap();
779+
let dt_tz_aware = dt_utc.with_timezone(&tz);
780+
write!(f, "{dt_tz_aware}")
781+
}
782+
Err(_) => match parse_offset(self.tz) {
783+
Ok(offset) => {
784+
let dt_tz_aware = offset.from_utc_datetime(&self.ndt);
785+
write!(f, "{dt_tz_aware}")
786+
}
787+
Err(_) => write!(f, "invalid timezone"),
788+
},
789+
}
790+
#[cfg(not(feature = "timezones"))]
791+
{
792+
panic!("activate 'timezones' feature")
793+
}
794+
}
795+
}
796+
779797
#[cfg(feature = "dtype-struct")]
780798
fn fmt_struct(f: &mut Formatter<'_>, vals: &[AnyValue]) -> fmt::Result {
781799
write!(f, "{{")?;

polars/polars-core/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ pub mod datatypes;
1010
pub mod doc;
1111
pub mod error;
1212
pub mod export;
13-
mod fmt;
13+
pub mod fmt;
1414
pub mod frame;
1515
pub mod functions;
1616
mod named_from;

polars/polars-io/src/csv/write_impl.rs

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use arrow::temporal_conversions;
44
use lexical_core::{FormattedSize, ToLexical};
55
use memchr::{memchr, memchr2};
66
use polars_core::error::PolarsError::ComputeError;
7+
use polars_core::fmt::PlTzAware;
78
use polars_core::prelude::*;
89
use polars_core::series::SeriesIter;
910
use polars_core::POOL;
@@ -83,28 +84,22 @@ fn write_anyvalue(f: &mut Vec<u8>, value: AnyValue, options: &SerializeOptions)
8384
}
8485
}
8586
#[cfg(feature = "dtype-datetime")]
86-
AnyValue::Datetime(v, tu, tz) => match tz {
87-
None => {
88-
let dt = match tu {
89-
TimeUnit::Nanoseconds => temporal_conversions::timestamp_ns_to_datetime(v),
90-
TimeUnit::Microseconds => temporal_conversions::timestamp_us_to_datetime(v),
91-
TimeUnit::Milliseconds => temporal_conversions::timestamp_ms_to_datetime(v),
92-
};
93-
match &options.datetime_format {
94-
None => write!(f, "{dt}"),
95-
Some(fmt) => write!(f, "{}", dt.format(fmt)),
96-
}
97-
}
98-
Some(tz) => {
99-
let tz = temporal_conversions::parse_offset(tz).unwrap();
100-
101-
let dt = temporal_conversions::timestamp_to_datetime(v, tu.to_arrow(), &tz);
102-
match &options.datetime_format {
103-
None => write!(f, "{dt}"),
104-
Some(fmt) => write!(f, "{}", dt.format(fmt)),
87+
AnyValue::Datetime(v, tu, tz) => {
88+
let ndt = match tu {
89+
TimeUnit::Nanoseconds => temporal_conversions::timestamp_ns_to_datetime(v),
90+
TimeUnit::Microseconds => temporal_conversions::timestamp_us_to_datetime(v),
91+
TimeUnit::Milliseconds => temporal_conversions::timestamp_ms_to_datetime(v),
92+
};
93+
match tz {
94+
None => match &options.datetime_format {
95+
None => write!(f, "{ndt}"),
96+
Some(fmt) => write!(f, "{}", ndt.format(fmt)),
97+
},
98+
Some(tz) => {
99+
write!(f, "{}", PlTzAware::new(ndt, tz))
105100
}
106101
}
107-
},
102+
}
108103
#[cfg(feature = "dtype-time")]
109104
AnyValue::Time(v) => {
110105
let date = temporal_conversions::time64ns_to_time(v);

py-polars/tests/unit/io/test_csv.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,3 +984,10 @@ def test_csv_quoted_missing() -> None:
984984
"col3": [123, None, 101112],
985985
"col4": [456, 789, 131415],
986986
}
987+
988+
989+
def test_csv_write_tz_aware() -> None:
990+
df = pl.DataFrame({"times": datetime(2021, 1, 1)}).with_columns(
991+
pl.col("times").dt.with_time_zone("Europe/Zurich")
992+
)
993+
assert df.write_csv() == "times\n2021-01-01 01:00:00 CET\n"

0 commit comments

Comments
 (0)