Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
63 changes: 62 additions & 1 deletion src/rune/runtime/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@
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',
'rune_filter', 'rune_all_elements', 'rune_contains', 'rune_disjoint',
'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'
]


Expand Down Expand Up @@ -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}
Expand Down
33 changes: 33 additions & 0 deletions test/test_zoned_date_time.py
Original file line number Diff line number Diff line change
@@ -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