|
3 | 3 | import logging |
4 | 4 | import keyword |
5 | 5 | import inspect |
6 | | -import re |
| 6 | +import datetime |
7 | 7 | from enum import Enum |
8 | 8 | from typing import Callable, Any |
9 | | -from zoneinfo import ZoneInfo |
10 | | -from datetime import datetime |
| 9 | +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError |
| 10 | +from dateutil import parser |
11 | 11 |
|
12 | 12 | __all__ = [ |
13 | 13 | 'if_cond_fn', 'Multiprop', 'rune_any_elements', 'rune_get_only_element', |
14 | 14 | 'rune_filter', 'rune_all_elements', 'rune_contains', 'rune_disjoint', |
15 | 15 | 'rune_join', 'rune_flatten_list', 'rune_resolve_attr', |
16 | 16 | 'rune_resolve_deep_attr', 'rune_count', 'rune_attr_exists', |
17 | 17 | '_get_rune_object', 'rune_set_attr', 'rune_add_attr', |
18 | | - 'rune_check_cardinality', 'rune_str', 'rune_check_one_of','rune_zoned_date_time' |
| 18 | + 'rune_check_cardinality', 'rune_str', 'rune_check_one_of', |
| 19 | + 'rune_zoned_date_time' |
19 | 20 | ] |
20 | 21 |
|
21 | 22 |
|
@@ -136,47 +137,63 @@ def rune_str(x: Any) -> str: |
136 | 137 | return str(x) |
137 | 138 |
|
138 | 139 |
|
139 | | -def rune_zoned_date_time(x: str): |
| 140 | +def rune_zoned_date_time(dt_str: str) -> datetime.datetime: |
140 | 141 | """ |
141 | | - Parse a datetime string with optional offset and/or named time zone |
| 142 | + Return a `datetime` parsed from *dt_str*. |
| 143 | +
|
| 144 | + The input may contain: |
| 145 | + • a bare date-time, |
| 146 | + • a numeric UTC offset (e.g. “+02:00”), |
| 147 | + • an IANA time-zone name (e.g. “Europe/Paris”), or |
| 148 | + • both offset **and** zone (the offset is validated against the zone). |
| 149 | +
|
| 150 | + Parameters |
| 151 | + ---------- |
| 152 | + dt_str : str |
| 153 | + Date/time string such as |
| 154 | + “2024-06-01 12:34:56 +05:30 Asia/Kolkata”. |
| 155 | +
|
| 156 | + Returns |
| 157 | + ------- |
| 158 | + datetime.datetime |
| 159 | + Time-zone-aware when an offset or zone is supplied, otherwise naive. |
| 160 | +
|
| 161 | + Raises |
| 162 | + ------ |
| 163 | + ValueError |
| 164 | + If the supplied offset contradicts the IANA zone. |
| 165 | +
|
| 166 | + Notes |
| 167 | + ----- |
| 168 | + Parsing is delegated to `dateutil.parser.parse`; any of its |
| 169 | + `ParserError`s propagate unchanged for malformed date fragments. |
142 | 170 | """ |
143 | | - #Separate str in parts (date, time , offset, zone) |
144 | | - parts = x.strip().split() |
| 171 | + dt: datetime.datetime |
| 172 | + extras: list[Any] |
| 173 | + dt, extras = parser.parse(dt_str, fuzzy_with_tokens=True) # type: ignore |
| 174 | + |
| 175 | + # Try every leftover token (strip commas/periods) for a ZoneInfo name |
| 176 | + zone = None |
| 177 | + for tok in extras: |
| 178 | + tok = tok.strip(' ,.;') |
| 179 | + if not tok: |
| 180 | + continue |
| 181 | + try: |
| 182 | + if tok: |
| 183 | + zone = ZoneInfo(tok) |
| 184 | + break |
| 185 | + except ZoneInfoNotFoundError: |
| 186 | + continue |
145 | 187 |
|
146 | | - # Try parsing last part as a zone name |
147 | | - possible_tz = parts[-1] |
148 | | - try: |
149 | | - zone = ZoneInfo(possible_tz) |
150 | | - parts = parts[:-1] # Remove the zone name from the string |
151 | | - except: |
152 | | - zone = None |
153 | | - |
154 | | - # Datetime object with date, time and offset |
155 | | - cleaned_input = " ".join(parts) |
156 | | - |
157 | | - # Parse datetime |
158 | | - try: |
159 | | - dt = datetime.strptime(cleaned_input, "%Y-%m-%d %H:%M:%S %z") |
160 | | - has_offset = True |
161 | | - except ValueError: |
162 | | - dt = datetime.strptime(cleaned_input, "%Y-%m-%d %H:%M:%S") |
163 | | - has_offset = False |
164 | | - |
165 | | - # Apply zone if valid |
166 | 188 | if zone: |
167 | | - if has_offset: |
168 | | - input_offset = dt.utcoffset() |
169 | | - zone_offset = dt.replace(tzinfo=zone).utcoffset() |
170 | | - if input_offset != zone_offset: |
171 | | - raise ValueError( |
172 | | - f"Offset {input_offset} does not match zone '{zone.key}' ({zone_offset})" |
173 | | - ) |
174 | | - dt = dt.replace(tzinfo=zone) |
| 189 | + # Same offset-matching logic as before … |
| 190 | + if dt.tzinfo and dt.utcoffset() != dt.astimezone(zone).utcoffset(): |
| 191 | + raise ValueError("Offset mismatch …") |
| 192 | + dt = dt.astimezone(zone) if dt.tzinfo else dt.replace(tzinfo=zone) |
175 | 193 |
|
176 | 194 | return dt |
177 | 195 |
|
178 | 196 |
|
179 | | - |
180 | 197 | def _get_rune_object(base_model: str, attribute: str, value: Any) -> Any: |
181 | 198 | model_class = globals()[base_model] |
182 | 199 | instance_kwargs = {attribute: value} |
|
0 commit comments