From 10cf45af6fbc4a8d90e5a29dfe2ac48df719f945 Mon Sep 17 00:00:00 2001 From: Kyle Goodrick <1909532+kjgoodrick@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:37:15 -0700 Subject: [PATCH] fix: preserve polars datetime timezones during serialization --- altair/utils/core.py | 7 ++++++- tests/__init__.py | 12 ++++++++++++ tests/utils/test_utils.py | 26 +++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/altair/utils/core.py b/altair/utils/core.py index 16e19514b..de20ce239 100644 --- a/altair/utils/core.py +++ b/altair/utils/core.py @@ -481,7 +481,12 @@ def sanitize_narwhals_dataframe( elif dtype == nw.Date: columns.append(nw.col(name).dt.to_string(local_iso_fmt_string)) elif dtype == nw.Datetime: - columns.append(nw.col(name).dt.to_string(f"{local_iso_fmt_string}%.f")) + # Preserve timezone information when present so Vega-Lite can disambiguate + # repeated local times during DST transitions. + fmt = f"{local_iso_fmt_string}%.f" + if getattr(dtype, "time_zone", None) is not None: + fmt = f"{fmt}%z" + columns.append(nw.col(name).dt.to_string(fmt)) elif dtype == nw.Duration: msg = ( f'Field "{name}" has type "{dtype}" which is ' diff --git a/tests/__init__.py b/tests/__init__.py index 4c32dfa1f..7b007f678 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -136,6 +136,18 @@ def windows_has_tzdata() -> bool: https://duckdb.org/ """ +skip_requires_polars: pytest.MarkDecorator = pytest.mark.skipif( + find_spec("polars") is None, reason="`polars` not installed." +) +""" +``pytest.mark.skipif`` decorator. + +Applies when `polars`_ import would fail. + +.. _polars: + https://pola.rs/ +""" + @overload def skip_requires_pyarrow( diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index e6b75f4f2..bcb8a6150 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -11,7 +11,7 @@ sanitize_narwhals_dataframe, sanitize_pandas_dataframe, ) -from tests import skip_requires_pyarrow +from tests import skip_requires_polars, skip_requires_pyarrow def test_infer_vegalite_type(): @@ -168,6 +168,30 @@ def test_sanitize_pyarrow_table_columns() -> None: json.dumps(values) +@skip_requires_polars +def test_sanitize_polars_datetime_timezone_preserved() -> None: + import polars as pl + + start = pl.datetime(2023, 11, 5, time_zone="US/Mountain") + df = pl.DataFrame( + { + "datetime": pl.datetime_range( + start, start.dt.offset_by("3h"), "1h", closed="both", eager=True + ), + "value": [10, 20, 30, 40], + } + ) + + sanitized = sanitize_narwhals_dataframe(nw.from_native(df, eager_only=True)) + + assert sanitized.rows(named=True) == [ + {"datetime": "2023-11-05T00:00:00-0600", "value": 10}, + {"datetime": "2023-11-05T01:00:00-0600", "value": 20}, + {"datetime": "2023-11-05T01:00:00-0700", "value": 30}, + {"datetime": "2023-11-05T02:00:00-0700", "value": 40}, + ] + + def test_sanitize_dataframe_colnames(): df = pd.DataFrame(np.arange(12).reshape(4, 3))