diff --git a/pyproject.toml b/pyproject.toml index 2212ce5..27d54ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,12 +7,15 @@ name = "rune.runtime" dynamic = ["version"] requires-python = ">=3.11" dependencies = [ - "pydantic>=2.10.3" + "pydantic>=2.10.3", + "python-dateutil>=2.9.0.post0", + "tzdata>=2025.2" ] optional-dependencies.dev = [ "pytest>=8.4.1", "pytest-cov>=6.2.1", - "pytest-mock>=3.14.1" + "pytest-mock>=3.14.1", + "types-python-dateutil>=2.9.0.20250809" ] description = "rune-runtime: the Rune DSL runtime for Python" readme = "README.md" diff --git a/src/rune/runtime/utils.py b/src/rune/runtime/utils.py index fd335bb..dd405dd 100644 --- a/src/rune/runtime/utils.py +++ b/src/rune/runtime/utils.py @@ -3,8 +3,11 @@ import logging import keyword import inspect +import datetime from enum import Enum from typing import Callable, Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from dateutil import parser __all__ = [ 'if_cond_fn', 'Multiprop', 'rune_any_elements', 'rune_get_only_element', @@ -12,7 +15,8 @@ 'rune_join', 'rune_flatten_list', 'rune_resolve_attr', 'rune_resolve_deep_attr', 'rune_count', 'rune_attr_exists', '_get_rune_object', 'rune_set_attr', 'rune_add_attr', - 'rune_check_cardinality', 'rune_str', 'rune_check_one_of' + 'rune_check_cardinality', 'rune_str', 'rune_check_one_of', + 'rune_zoned_date_time' ] @@ -133,6 +137,63 @@ def rune_str(x: Any) -> str: return str(x) +def rune_zoned_date_time(dt_str: str) -> datetime.datetime: + """ + Return a `datetime` parsed from *dt_str*. + + The input may contain: + • a bare date-time, + • a numeric UTC offset (e.g. “+02:00”), + • an IANA time-zone name (e.g. “Europe/Paris”), or + • both offset **and** zone (the offset is validated against the zone). + + Parameters + ---------- + dt_str : str + Date/time string such as + “2024-06-01 12:34:56 +05:30 Asia/Kolkata”. + + Returns + ------- + datetime.datetime + Time-zone-aware when an offset or zone is supplied, otherwise naive. + + Raises + ------ + ValueError + If the supplied offset contradicts the IANA zone. + + Notes + ----- + Parsing is delegated to `dateutil.parser.parse`; any of its + `ParserError`s propagate unchanged for malformed date fragments. + """ + dt: datetime.datetime + extras: list[Any] + dt, extras = parser.parse(dt_str, fuzzy_with_tokens=True) # type: ignore + + # Try every leftover token (strip commas/periods) for a ZoneInfo name + zone = None + for tok in extras: + tok = tok.strip(' ,.;') + if not tok: + continue + try: + if tok: + zone = ZoneInfo(tok) + break + except ZoneInfoNotFoundError: + continue + + if zone: + # Same offset-matching logic as before … + if dt.tzinfo and dt.utcoffset() != dt.astimezone(zone).utcoffset(): + raise ValueError("Offset mismatch …") + dt = dt.astimezone(zone) if dt.tzinfo else dt.replace(tzinfo=zone) + + return dt + + def _get_rune_object(base_model: str, attribute: str, value: Any) -> Any: model_class = globals()[base_model] instance_kwargs = {attribute: value} diff --git a/test/test_zoned_date_time.py b/test/test_zoned_date_time.py new file mode 100644 index 0000000..904b926 --- /dev/null +++ b/test/test_zoned_date_time.py @@ -0,0 +1,33 @@ +'''tests related to the zoned datetime conversion''' +import pytest +from rune.runtime.utils import rune_zoned_date_time as rdt + + +def test_naive(): + '''no tz''' + assert rdt("2025-01-02 03:04:05").tzinfo is None + + +def test_offset_only(): + '''standard offset, no tz''' + d = rdt("2025-01-02 03:04:05 +02:00") + assert str(d.utcoffset()) == "2:00:00" + + +def test_zone_only(): + '''tz present''' + d = rdt("2025-01-02 03:04:05 Europe/Paris") + assert d.tzinfo.key == "Europe/Paris" # type: ignore + + +def test_offset_and_zone_match(): + '''tz and offset present''' + rdt("2025-01-02 03:04:05 +01:00 Europe/Paris") # should not raise + + +def test_offset_and_zone_mismatch(): + '''tz and offset present but do not match''' + with pytest.raises(ValueError): + rdt("2025-01-02 03:04:05 +03:00 Europe/Paris") + +# EOF