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
+20 -4
View File
@@ -32,7 +32,7 @@ Usage Examples:
>>> to_time("15:30:00", in_timezone="Europe/Berlin") >>> to_time("15:30:00", in_timezone="Europe/Berlin")
Time(17, 30, 0, tzinfo=Timezone('Europe/Berlin')) Time(17, 30, 0, tzinfo=Timezone('Europe/Berlin'))
>>> to_datetime("2024-10-13T15:30:00", in_timezone="Europe/Berlin") >>> to_datetime("2024-10-13T15:30:00Z", in_timezone="Europe/Berlin")
DateTime(2024, 10, 13, 17, 30, 0, tzinfo=Timezone('Europe/Berlin')) DateTime(2024, 10, 13, 17, 30, 0, tzinfo=Timezone('Europe/Berlin'))
>>> to_duration("2 days 5 hours") >>> to_duration("2 days 5 hours")
@@ -875,7 +875,16 @@ def to_datetime(
) -> Union[DateTime, str]: ) -> Union[DateTime, str]:
"""Convert a date input into a Pendulum DateTime object or a formatted string, with optional timezone handling. """Convert a date input into a Pendulum DateTime object or a formatted string, with optional timezone handling.
This function handles various date input formats, adjusts for timezones, and provides flexibility for formatting and time adjustments. For date strings without explicit timezone information, the local timezone is assumed. Be aware that Pendulum DateTime objects created without a timezone default to UTC. This function handles various date input formats, adjusts for timezones, and provides
flexibility for formatting and time adjustments. Date strings without explicit timezone
information represent wall-clock time in `in_timezone`, or in the local timezone if it is
omitted, regardless of whether they include minutes, seconds, or fractional seconds. Pass the
configured location's timezone as `in_timezone` to interpret its local date and time. For
example, `2026-01-15 23:45` in `Europe/Berlin` means `2026-01-15T23:45:00+01:00`.
Supplying this timezone during parsing preserves the local date and clock components instead
of first assuming UTC and shifting them during conversion. Explicit offsets and Unix
timestamps retain their instant when converted to the target timezone. Be aware that Pendulum
DateTime objects created without a timezone default to UTC.
Args: Args:
date_input (Optional[Any]): The date input to convert. Supported types include: date_input (Optional[Any]): The date input to convert. Supported types include:
@@ -896,6 +905,7 @@ def to_datetime(
in_timezone (Optional[Union[str, Timezone]]): Specifies the target timezone for the result. in_timezone (Optional[Union[str, Timezone]]): Specifies the target timezone for the result.
- Can be a timezone string (e.g., "UTC", "Europe/Berlin") or a `pendulum.Timezone` object. - Can be a timezone string (e.g., "UTC", "Europe/Berlin") or a `pendulum.Timezone` object.
- Defaults to the local timezone if not provided. - Defaults to the local timezone if not provided.
- Also defines the assumed timezone for strings without timezone information.
to_naiv (Optional[bool]): If `True`, removes timezone information from the resulting datetime object. to_naiv (Optional[bool]): If `True`, removes timezone information from the resulting datetime object.
- Defaults to `False`. - Defaults to `False`.
@@ -918,6 +928,9 @@ def to_datetime(
'2024-10-13T00:00:00+00:00' '2024-10-13T00:00:00+00:00'
>>> to_datetime("2024-10-13T15:30:00", in_timezone="Europe/Berlin") >>> to_datetime("2024-10-13T15:30:00", in_timezone="Europe/Berlin")
DateTime(2024, 10, 13, 15, 30, 0, tzinfo=Timezone('Europe/Berlin'))
>>> to_datetime("2024-10-13T15:30:00Z", in_timezone="Europe/Berlin")
DateTime(2024, 10, 13, 17, 30, 0, tzinfo=Timezone('Europe/Berlin')) DateTime(2024, 10, 13, 17, 30, 0, tzinfo=Timezone('Europe/Berlin'))
>>> to_datetime(date(2024, 10, 13), to_maxtime=True) >>> to_datetime(date(2024, 10, 13), to_maxtime=True)
@@ -974,9 +987,12 @@ def to_datetime(
logger.trace(f"{date_input}, {fmt}, {e}") logger.trace(f"{date_input}, {fmt}, {e}")
dt = None dt = None
else: else:
# DateTime input with timezone info # The fallback also handles naive strings with omitted or fractional seconds.
# Interpret their clock components directly in the resolved location/local timezone.
# Parsing as UTC first and then converting would shift the clock and possibly the date.
# Explicit offsets take precedence over tz in Pendulum, preserving the input instant.
try: try:
dt = cast(pendulum.DateTime, pendulum.parse(date_input)) dt = cast(pendulum.DateTime, pendulum.parse(date_input, tz=timezone))
logger.trace( logger.trace(
f"Pendulum Fmt converted: {dt}, tz={dt.tz} from {date_input}, tz={timezone}" f"Pendulum Fmt converted: {dt}, tz={dt.tz} from {date_input}, tz={timezone}"
) )
+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 # Test cases for valid pendulum.duration inputs
@pytest.mark.parametrize( @pytest.mark.parametrize(
"test_case, local_timezone, date_input, as_string, in_timezone, to_naiv, to_maxtime, expected_output, expected_approximately", "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 # - no timezone
( (
"TC001", "date_only_local_utc",
"Etc/UTC", "Etc/UTC",
"2024-01-01", "2024-01-01",
None, None,
@@ -862,7 +965,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC002", "date_only_local_berlin",
"Europe/Berlin", "Europe/Berlin",
"2024-01-01", "2024-01-01",
None, None,
@@ -873,7 +976,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC003", "date_only_berlin_matches_utc_instant",
"Europe/Berlin", "Europe/Berlin",
"2024-01-01", "2024-01-01",
None, None,
@@ -884,7 +987,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC004", "seconds_space_local_paris",
"Europe/Paris", "Europe/Paris",
"2024-01-01 00:00:00", "2024-01-01 00:00:00",
None, None,
@@ -895,7 +998,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC005", "seconds_space_utc_matches_berlin_instant",
"Etc/UTC", "Etc/UTC",
"2024-01-01 00:00:00", "2024-01-01 00:00:00",
None, None,
@@ -906,7 +1009,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC006", "seconds_space_berlin_matches_utc_instant",
"Europe/Berlin", "Europe/Berlin",
"2024-01-01 00:00:00", "2024-01-01 00:00:00",
None, None,
@@ -917,7 +1020,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC007", "seconds_space_local_canary",
"Atlantic/Canary", "Atlantic/Canary",
"2024-01-01 12:00:00", "2024-01-01 12:00:00",
None, None,
@@ -936,7 +1039,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC008", "seconds_space_default_utc_matches_berlin_instant",
"Etc/UTC", "Etc/UTC",
"2024-01-01 12:00:00", "2024-01-01 12:00:00",
None, None,
@@ -947,7 +1050,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC009", "seconds_space_default_berlin_matches_utc_instant",
"Europe/Berlin", "Europe/Berlin",
"2024-01-01 12:00:00", "2024-01-01 12:00:00",
None, None,
@@ -959,7 +1062,7 @@ class TestPendulumTypes:
), ),
# - with timezone # - with timezone
( (
"TC010", "slash_date_target_berlin",
"Etc/UTC", "Etc/UTC",
"02/02/24", "02/02/24",
None, None,
@@ -970,7 +1073,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC011", "offset_datetime_target_berlin_winter",
"Etc/UTC", "Etc/UTC",
"2024-03-03T10:20:30.000+01:00", # No dalight saving time at this date "2024-03-03T10:20:30.000+01:00", # No dalight saving time at this date
None, None,
@@ -981,7 +1084,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC012", "offset_datetime_target_berlin_summer",
"Etc/UTC", "Etc/UTC",
"2024-04-04T10:20:30.000+02:00", "2024-04-04T10:20:30.000+02:00",
None, None,
@@ -992,7 +1095,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC013", "offset_datetime_to_naive",
"Etc/UTC", "Etc/UTC",
"2024-05-05T10:20:30.000+02:00", "2024-05-05T10:20:30.000+02:00",
None, None,
@@ -1004,7 +1107,7 @@ class TestPendulumTypes:
), ),
# - without local timezone as UTC # - without local timezone as UTC
( (
"TC014", "date_only_target_utc",
"UTC", "UTC",
"2024-01-03", "2024-01-03",
None, None,
@@ -1015,7 +1118,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC015", "slash_date_target_utc",
"Atlantic/Canary", "Atlantic/Canary",
"02/02/24", "02/02/24",
None, None,
@@ -1026,7 +1129,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC016", "z_datetime_preserves_utc_instant",
"Atlantic/Canary", "Atlantic/Canary",
"2024-03-03T10:20:30.000Z", # No dalight saving time at this date "2024-03-03T10:20:30.000Z", # No dalight saving time at this date
None, None,
@@ -1040,7 +1143,7 @@ class TestPendulumTypes:
# from pendulum.datetime to pendulum.datetime object # from pendulum.datetime to pendulum.datetime object
# --------------------------------------- # ---------------------------------------
( (
"TC017", "pendulum_default_utc_preserves_instant",
"Atlantic/Canary", "Atlantic/Canary",
pendulum.datetime(2024, 4, 4, 0, 0, 0), pendulum.datetime(2024, 4, 4, 0, 0, 0),
None, None,
@@ -1051,7 +1154,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC018", "pendulum_default_utc_to_berlin",
"Atlantic/Canary", "Atlantic/Canary",
pendulum.datetime(2024, 4, 4, 1, 0, 0), pendulum.datetime(2024, 4, 4, 1, 0, 0),
None, None,
@@ -1062,7 +1165,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC019", "pendulum_explicit_utc_to_berlin",
"Atlantic/Canary", "Atlantic/Canary",
pendulum.datetime(2024, 4, 4, 1, 0, 0, tz="Etc/UTC"), pendulum.datetime(2024, 4, 4, 1, 0, 0, tz="Etc/UTC"),
None, None,
@@ -1073,7 +1176,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC020", "pendulum_berlin_to_utc",
"Atlantic/Canary", "Atlantic/Canary",
pendulum.datetime(2024, 4, 4, 2, 0, 0, tz="Europe/Berlin"), pendulum.datetime(2024, 4, 4, 2, 0, 0, tz="Europe/Berlin"),
None, None,
@@ -1089,7 +1192,7 @@ class TestPendulumTypes:
# - no timezone # - no timezone
# local timezone UTC # local timezone UTC
( (
"TC021", "naive_seconds_as_utc_string_from_utc",
"Etc/UTC", "Etc/UTC",
"2023-11-06T00:00:00", "2023-11-06T00:00:00",
"UTC", "UTC",
@@ -1101,7 +1204,7 @@ class TestPendulumTypes:
), ),
# local timezone "Europe/Berlin" # local timezone "Europe/Berlin"
( (
"TC022", "naive_seconds_as_utc_string_from_berlin",
"Europe/Berlin", "Europe/Berlin",
"2023-11-06T00:00:00", "2023-11-06T00:00:00",
"UTC", "UTC",
@@ -1113,7 +1216,7 @@ class TestPendulumTypes:
), ),
# - no microseconds # - no microseconds
( (
"TC023", "offset_seconds_as_utc_string",
"Atlantic/Canary", "Atlantic/Canary",
"2024-10-30T00:00:00+01:00", "2024-10-30T00:00:00+01:00",
"UTC", "UTC",
@@ -1124,7 +1227,7 @@ class TestPendulumTypes:
False, False,
), ),
( (
"TC024", "offset_seconds_as_lowercase_utc_string",
"Atlantic/Canary", "Atlantic/Canary",
"2024-10-30T01:00:00+01:00", "2024-10-30T01:00:00+01:00",
"utc", "utc",
@@ -1136,7 +1239,7 @@ class TestPendulumTypes:
), ),
# - with microseconds # - with microseconds
( (
"TC025", "offset_fractional_seconds_as_utc_string",
"Atlantic/Canary", "Atlantic/Canary",
"2024-10-07T10:20:30.000+02:00", "2024-10-07T10:20:30.000+02:00",
"UTC", "UTC",
@@ -1152,7 +1255,7 @@ class TestPendulumTypes:
# - no timezone # - no timezone
# local timezone # local timezone
( (
"TC026", "none_returns_current_datetime",
None, None,
None, None,
None, None,
+24 -11
View File
@@ -1,5 +1,6 @@
import json import json
from pathlib import Path from pathlib import Path
from typing import Any, Callable
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import pandas as pd import pandas as pd
@@ -136,9 +137,18 @@ def test_request_forecast(mock_get, provider, sample_openmeteo_1_json):
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("host_timezone", ["UTC", "Europe/Berlin"])
@patch("requests.get") @patch("requests.get")
async def test_update_data(mock_get, provider, sample_openmeteo_1_json, cache_store): async def test_update_data(
"""Test fetching and processing forecast from Open-Meteo.""" 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 object
mock_response = Mock() mock_response = Mock()
mock_response.status_code = 200 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() mock_get.assert_called_once()
assert len(provider) > 0 assert len(provider) > 0
# Verify that direct radiation values were properly mapped # Open-Meteo returns local wall times without offsets or seconds. Build the
# Get the first record and check for irradiance values # expected instants independently of to_datetime, including midnight boundaries.
value_datetime = to_datetime("2026-03-04 09:00:00+01:00", in_timezone="Europe/Berlin") hourly = sample_openmeteo_1_json["hourly"]
weather_ghi = await provider.key_to_value("weather_ghi", target_datetime=start_datetime) expected_datetimes = pd.DatetimeIndex(hourly["time"], tz="Europe/Berlin").to_pydatetime()
weather_dni = await provider.key_to_value("weather_dni", target_datetime=start_datetime) for source_key, record_key in (
weather_dhi = await provider.key_to_value("weather_dhi", target_datetime=start_datetime) ("shortwave_radiation", "weather_ghi"),
assert weather_ghi == 21.8 ("direct_radiation", "weather_dni"),
assert weather_dni == 1.2 ("diffuse_radiation", "weather_dhi"),
assert weather_dhi == 20.5 ):
datetimes, values = await provider.key_to_lists(record_key)
assert list(datetimes) == list(expected_datetimes)
assert values == hourly[source_key]
# ------------------------------------------------ # ------------------------------------------------