fix: preserve local time when parsing naive datetime strings (#1294)

to_datetime() previously interpreted naive strings differently depending on their precision. For example, 2026-01-15 23:45 in Europe/Berlin became 2026-01-16 00:45+01:00, while the equivalent string with seconds kept the intended date and time. Minutes, seconds, and fractional seconds now consistently represent 2026-01-15 23:45+01:00.

Pass the resolved target/local timezone to Pendulum's fallback parser. Explicit Z and numeric offsets continue to determine the input instant, and Unix timestamps retain their UTC semantics. Update the affected docstring examples to distinguish local wall time from UTC conversion.

Regression coverage includes space and T separators, explicit and default Europe/Berlin timezones, winter/summer dates near midnight, fractional-second precision, explicit offsets, and Unix timestamps. The Open-Meteo integration test now independently checks the timestamps and all three irradiance series for all 72 fixture hours, with both UTC and Europe/Berlin as the host timezone. This replaces an expectation that incorrectly associated the 08:00 values with 09:00. The previous parser misplaces all 72 source hours; the corrected parser maps all 72 correctly.
This commit is contained in:
dr-dimitri
2026-09-14 08:24:55 +02:00
committed by GitHub
parent 3b9eecc52b
commit 446ea3da9d
3 changed files with 173 additions and 41 deletions
+129 -26
View File
@@ -842,6 +842,109 @@ class TestPendulumTypes:
# -----------------------------
@pytest.mark.parametrize("month", [1, 7], ids=["winter", "summer"])
@pytest.mark.parametrize("separator", [" ", "T"], ids=["space", "T"])
@pytest.mark.parametrize(
"in_timezone",
[None, "Europe/Berlin", Timezone("Europe/Berlin")],
ids=["local-berlin", "target-berlin-name", "target-berlin-object"],
)
@pytest.mark.parametrize(
"suffix", ["", ":00", ":00.000"], ids=["minutes", "seconds", "fractional-seconds"]
)
def test_to_datetime_naive_precision_uses_target_timezone(
monkeypatch: pytest.MonkeyPatch,
month: int,
separator: str,
in_timezone: str | Timezone | None,
suffix: str,
) -> None:
"""Equivalent naive strings keep their wall time and date in winter and summer."""
local_zone = Timezone("Europe/Berlin" if in_timezone is None else "UTC")
monkeypatch.setattr(pendulum, "local_timezone", lambda: local_zone)
expected = pendulum.datetime(2026, month, 15, 23, 45, tz="Europe/Berlin")
value = f"2026-{month:02d}-15{separator}23:45{suffix}"
actual = to_datetime(value, in_timezone=in_timezone)
assert actual == expected
assert actual.to_iso8601_string() == expected.to_iso8601_string()
# Check the location and clock components as well as the represented instant.
assert actual.timezone_name == "Europe/Berlin"
assert actual.year == 2026
assert actual.month == month
assert actual.day == 15
assert actual.hour == 23
assert actual.minute == 45
assert actual.second == 0
assert actual.microsecond == 0
@pytest.mark.parametrize("month", [1, 7], ids=["winter", "summer"])
@pytest.mark.parametrize("separator", [" ", "T"], ids=["space", "T"])
@pytest.mark.parametrize(
"fraction, microsecond",
[("1", 100000), ("000001", 1), ("123456", 123456)],
ids=["tenths", "one-microsecond", "six-decimals"],
)
@pytest.mark.parametrize(
"in_timezone", [None, "Europe/Berlin"], ids=["local-berlin", "target-berlin"]
)
def test_to_datetime_naive_fractional_seconds_are_preserved(
monkeypatch: pytest.MonkeyPatch,
month: int,
separator: str,
fraction: str,
microsecond: int,
in_timezone: str | None,
) -> None:
"""Fractional seconds do not alter the local date, wall time, or precision."""
monkeypatch.setattr(pendulum, "local_timezone", lambda: Timezone("Europe/Berlin"))
value = f"2026-{month:02d}-15{separator}23:45:00.{fraction}"
expected = pendulum.datetime(2026, month, 15, 23, 45, 0, microsecond, tz="Europe/Berlin")
actual = to_datetime(value, in_timezone=in_timezone)
assert actual.to_iso8601_string() == expected.to_iso8601_string()
assert actual.timezone_name == "Europe/Berlin"
assert actual.year == 2026
assert actual.month == month
assert actual.day == 15
assert actual.hour == 23
assert actual.minute == 45
assert actual.second == 0
assert actual.microsecond == microsecond
@pytest.mark.parametrize("month", [1, 7], ids=["winter", "summer"])
@pytest.mark.parametrize("separator", [" ", "T"], ids=["space", "T"])
@pytest.mark.parametrize(
"precision", ["", ":00", ":00.123456"], ids=["minutes", "seconds", "fractional-seconds"]
)
@pytest.mark.parametrize(
"offset", ["Z", "+00:00", "+02:00", "-05:30", "+02", "-05", "+0200", "-0530"]
)
def test_to_datetime_explicit_offset_preserves_instant(
month: int, separator: str, precision: str, offset: str
) -> None:
"""An explicit offset takes precedence over the timezone for naive strings."""
value = f"2026-{month:02d}-15{separator}23:45{precision}{offset}"
expected = datetime.datetime.fromisoformat(value)
actual = to_datetime(value, in_timezone="Europe/Berlin")
assert actual.timestamp() == expected.timestamp()
assert actual.microsecond == expected.microsecond
assert actual.timezone_name == "Europe/Berlin"
@pytest.mark.parametrize(
"value",
[1768517100, 1768517100.123456, "1768517100.123456"],
ids=["integer", "fractional-float", "fractional-string"],
)
def test_to_datetime_unix_timestamp_keeps_utc_instant(value: int | float | str) -> None:
"""The default timezone for naive strings does not apply to Unix timestamps."""
actual = to_datetime(value, in_timezone="Europe/Berlin")
assert actual.timestamp() == float(value)
assert actual.timezone_name == "Europe/Berlin"
# Test cases for valid pendulum.duration inputs
@pytest.mark.parametrize(
"test_case, local_timezone, date_input, as_string, in_timezone, to_naiv, to_maxtime, expected_output, expected_approximately",
@@ -851,7 +954,7 @@ class TestPendulumTypes:
# ---------------------------------------
# - no timezone
(
"TC001",
"date_only_local_utc",
"Etc/UTC",
"2024-01-01",
None,
@@ -862,7 +965,7 @@ class TestPendulumTypes:
False,
),
(
"TC002",
"date_only_local_berlin",
"Europe/Berlin",
"2024-01-01",
None,
@@ -873,7 +976,7 @@ class TestPendulumTypes:
False,
),
(
"TC003",
"date_only_berlin_matches_utc_instant",
"Europe/Berlin",
"2024-01-01",
None,
@@ -884,7 +987,7 @@ class TestPendulumTypes:
False,
),
(
"TC004",
"seconds_space_local_paris",
"Europe/Paris",
"2024-01-01 00:00:00",
None,
@@ -895,7 +998,7 @@ class TestPendulumTypes:
False,
),
(
"TC005",
"seconds_space_utc_matches_berlin_instant",
"Etc/UTC",
"2024-01-01 00:00:00",
None,
@@ -906,7 +1009,7 @@ class TestPendulumTypes:
False,
),
(
"TC006",
"seconds_space_berlin_matches_utc_instant",
"Europe/Berlin",
"2024-01-01 00:00:00",
None,
@@ -917,7 +1020,7 @@ class TestPendulumTypes:
False,
),
(
"TC007",
"seconds_space_local_canary",
"Atlantic/Canary",
"2024-01-01 12:00:00",
None,
@@ -936,7 +1039,7 @@ class TestPendulumTypes:
False,
),
(
"TC008",
"seconds_space_default_utc_matches_berlin_instant",
"Etc/UTC",
"2024-01-01 12:00:00",
None,
@@ -947,7 +1050,7 @@ class TestPendulumTypes:
False,
),
(
"TC009",
"seconds_space_default_berlin_matches_utc_instant",
"Europe/Berlin",
"2024-01-01 12:00:00",
None,
@@ -959,7 +1062,7 @@ class TestPendulumTypes:
),
# - with timezone
(
"TC010",
"slash_date_target_berlin",
"Etc/UTC",
"02/02/24",
None,
@@ -970,7 +1073,7 @@ class TestPendulumTypes:
False,
),
(
"TC011",
"offset_datetime_target_berlin_winter",
"Etc/UTC",
"2024-03-03T10:20:30.000+01:00", # No dalight saving time at this date
None,
@@ -981,7 +1084,7 @@ class TestPendulumTypes:
False,
),
(
"TC012",
"offset_datetime_target_berlin_summer",
"Etc/UTC",
"2024-04-04T10:20:30.000+02:00",
None,
@@ -992,7 +1095,7 @@ class TestPendulumTypes:
False,
),
(
"TC013",
"offset_datetime_to_naive",
"Etc/UTC",
"2024-05-05T10:20:30.000+02:00",
None,
@@ -1004,7 +1107,7 @@ class TestPendulumTypes:
),
# - without local timezone as UTC
(
"TC014",
"date_only_target_utc",
"UTC",
"2024-01-03",
None,
@@ -1015,7 +1118,7 @@ class TestPendulumTypes:
False,
),
(
"TC015",
"slash_date_target_utc",
"Atlantic/Canary",
"02/02/24",
None,
@@ -1026,7 +1129,7 @@ class TestPendulumTypes:
False,
),
(
"TC016",
"z_datetime_preserves_utc_instant",
"Atlantic/Canary",
"2024-03-03T10:20:30.000Z", # No dalight saving time at this date
None,
@@ -1040,7 +1143,7 @@ class TestPendulumTypes:
# from pendulum.datetime to pendulum.datetime object
# ---------------------------------------
(
"TC017",
"pendulum_default_utc_preserves_instant",
"Atlantic/Canary",
pendulum.datetime(2024, 4, 4, 0, 0, 0),
None,
@@ -1051,7 +1154,7 @@ class TestPendulumTypes:
False,
),
(
"TC018",
"pendulum_default_utc_to_berlin",
"Atlantic/Canary",
pendulum.datetime(2024, 4, 4, 1, 0, 0),
None,
@@ -1062,7 +1165,7 @@ class TestPendulumTypes:
False,
),
(
"TC019",
"pendulum_explicit_utc_to_berlin",
"Atlantic/Canary",
pendulum.datetime(2024, 4, 4, 1, 0, 0, tz="Etc/UTC"),
None,
@@ -1073,7 +1176,7 @@ class TestPendulumTypes:
False,
),
(
"TC020",
"pendulum_berlin_to_utc",
"Atlantic/Canary",
pendulum.datetime(2024, 4, 4, 2, 0, 0, tz="Europe/Berlin"),
None,
@@ -1089,7 +1192,7 @@ class TestPendulumTypes:
# - no timezone
# local timezone UTC
(
"TC021",
"naive_seconds_as_utc_string_from_utc",
"Etc/UTC",
"2023-11-06T00:00:00",
"UTC",
@@ -1101,7 +1204,7 @@ class TestPendulumTypes:
),
# local timezone "Europe/Berlin"
(
"TC022",
"naive_seconds_as_utc_string_from_berlin",
"Europe/Berlin",
"2023-11-06T00:00:00",
"UTC",
@@ -1113,7 +1216,7 @@ class TestPendulumTypes:
),
# - no microseconds
(
"TC023",
"offset_seconds_as_utc_string",
"Atlantic/Canary",
"2024-10-30T00:00:00+01:00",
"UTC",
@@ -1124,7 +1227,7 @@ class TestPendulumTypes:
False,
),
(
"TC024",
"offset_seconds_as_lowercase_utc_string",
"Atlantic/Canary",
"2024-10-30T01:00:00+01:00",
"utc",
@@ -1136,7 +1239,7 @@ class TestPendulumTypes:
),
# - with microseconds
(
"TC025",
"offset_fractional_seconds_as_utc_string",
"Atlantic/Canary",
"2024-10-07T10:20:30.000+02:00",
"UTC",
@@ -1152,7 +1255,7 @@ class TestPendulumTypes:
# - no timezone
# local timezone
(
"TC026",
"none_returns_current_datetime",
None,
None,
None,
+24 -11
View File
@@ -1,5 +1,6 @@
import json
from pathlib import Path
from typing import Any, Callable
from unittest.mock import Mock, patch
import pandas as pd
@@ -136,9 +137,18 @@ def test_request_forecast(mock_get, provider, sample_openmeteo_1_json):
@pytest.mark.asyncio
@pytest.mark.parametrize("host_timezone", ["UTC", "Europe/Berlin"])
@patch("requests.get")
async def test_update_data(mock_get, provider, sample_openmeteo_1_json, cache_store):
"""Test fetching and processing forecast from Open-Meteo."""
async def test_update_data(
mock_get: Mock,
provider: WeatherOpenMeteo,
sample_openmeteo_1_json: dict[str, Any],
cache_store: CacheFileStore,
set_other_timezone: Callable[[str], str],
host_timezone: str,
) -> None:
"""Map each forecast hour in the provider timezone, regardless of the host timezone."""
set_other_timezone(host_timezone)
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
@@ -158,15 +168,18 @@ async def test_update_data(mock_get, provider, sample_openmeteo_1_json, cache_st
mock_get.assert_called_once()
assert len(provider) > 0
# Verify that direct radiation values were properly mapped
# Get the first record and check for irradiance values
value_datetime = to_datetime("2026-03-04 09:00:00+01:00", in_timezone="Europe/Berlin")
weather_ghi = await provider.key_to_value("weather_ghi", target_datetime=start_datetime)
weather_dni = await provider.key_to_value("weather_dni", target_datetime=start_datetime)
weather_dhi = await provider.key_to_value("weather_dhi", target_datetime=start_datetime)
assert weather_ghi == 21.8
assert weather_dni == 1.2
assert weather_dhi == 20.5
# Open-Meteo returns local wall times without offsets or seconds. Build the
# expected instants independently of to_datetime, including midnight boundaries.
hourly = sample_openmeteo_1_json["hourly"]
expected_datetimes = pd.DatetimeIndex(hourly["time"], tz="Europe/Berlin").to_pydatetime()
for source_key, record_key in (
("shortwave_radiation", "weather_ghi"),
("direct_radiation", "weather_dni"),
("diffuse_radiation", "weather_dhi"),
):
datetimes, values = await provider.key_to_lists(record_key)
assert list(datetimes) == list(expected_datetimes)
assert values == hourly[source_key]
# ------------------------------------------------