Skip to content

Commit 8f35f5d

Browse files
committed
added time related datatypes
1 parent 1d796de commit 8f35f5d

8 files changed

Lines changed: 213 additions & 152 deletions

File tree

tests/test_icalendar.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@
99

1010
from vobjectx import base
1111
from vobjectx.behavior import new_from_behavior
12+
from vobjectx.datatypes import Period
1213
from vobjectx.icalendar import (
1314
RecurringComponent,
1415
TimezoneComponent,
1516
VCalendar2_0,
1617
delta_to_offset,
1718
parse_dtstart,
18-
string_to_period,
1919
string_to_text_values,
2020
timedelta_to_string,
2121
)
@@ -56,12 +56,12 @@ def test_string_to_text_values():
5656

5757
def test_string_to_period():
5858
"""Test datetime strings"""
59-
assert string_to_period("19970101T180000Z/19970102T070000Z") == (
59+
assert Period("19970101T180000Z/19970102T070000Z").value == (
6060
dt.datetime(1997, 1, 1, 18, 0, tzinfo=UTC_TZ),
6161
dt.datetime(1997, 1, 2, 7, 0, tzinfo=UTC_TZ),
6262
)
6363

64-
assert string_to_period("19970101T180000Z/PT1H") == (
64+
assert Period("19970101T180000Z/PT1H").value == (
6565
dt.datetime(1997, 1, 1, 18, 0, tzinfo=UTC_TZ),
6666
dt.timedelta(0, 3600),
6767
)

vobjectx/datatypes/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from typing import Any, Protocol
2+
3+
from .alias import duration_to_timedelta, string_to_date, string_to_date_time, string_to_period
4+
from .time_types import Date, DateTime, Duration, Period, Time
5+
6+
7+
# pylint: disable=r0903
8+
class DataType(Protocol):
9+
text: str
10+
value: Any
11+
12+
def _parse(self):
13+
pass

vobjectx/datatypes/alias.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from .time_types import Date, DateTime, Duration, Period
2+
3+
4+
def duration_to_timedelta(duration: str):
5+
return Duration(duration).value
6+
7+
8+
def string_to_date(s: str):
9+
return Date(s).value
10+
11+
12+
def string_to_date_time(s: str, tzinfo=None, strict: bool = False):
13+
return DateTime(s, tzinfo, strict=strict).value
14+
15+
16+
def string_to_period(s: str, tzinfo=None):
17+
return Period(s, tzinfo=tzinfo).value

vobjectx/datatypes/time_types.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# pylint: disable=r0903
2+
import datetime as dt
3+
import re
4+
5+
from vobjectx.exceptions import ParseError
6+
from vobjectx.registry import TzidRegistry
7+
8+
9+
def _is_duration(s: str) -> bool:
10+
return "P" in s[:2].upper()
11+
12+
13+
class Date:
14+
def __init__(self, date_: str):
15+
self.text = date_
16+
self._parse()
17+
18+
def _parse(self):
19+
self.value: dt.date = dt.datetime.strptime(self.text, "%Y%m%d").date()
20+
21+
22+
class DateTime:
23+
def __init__(self, date_time_: str, tzinfo: dt.tzinfo = None, strict: bool = False):
24+
if not strict:
25+
date_time_ = date_time_.strip()
26+
self.text = date_time_
27+
self.tzinfo = tzinfo
28+
self._parse()
29+
30+
def _parse(self):
31+
try:
32+
_datetime = dt.datetime.strptime(self.text[:15], "%Y%m%dT%H%M%S")
33+
except ValueError as e:
34+
raise ParseError(f"'{self.text}' is not a valid DATE-TIME") from e
35+
36+
if len(self.text) > 15 and self.text[15] == "Z":
37+
self.tzinfo = TzidRegistry.get("UTC")
38+
self.value = _datetime.replace(tzinfo=self.tzinfo)
39+
40+
41+
class Duration:
42+
def __init__(self, duration: str):
43+
self.text = duration.strip()
44+
self.value: dt.timedelta = dt.timedelta()
45+
self._parse()
46+
47+
def _parse(self):
48+
if "," in self.text:
49+
raise ParseError("DURATION must have a single value.")
50+
51+
interval_map = {"W": "weeks", "D": "days", "H": "hours", "M": "minutes", "S": "seconds"}
52+
53+
_sign = -1 if self.text[0] == "-" else 1
54+
params = {}
55+
for part in re.findall(r"\d{0,2}[PTWDHMS]{0,2}", self.text):
56+
if part and part[-1] in interval_map:
57+
params[interval_map[part[-1]]] = int(part[:-1])
58+
if not params:
59+
raise ParseError(f"Invalid duration string : {self.text}")
60+
self.value = _sign * dt.timedelta(**params)
61+
62+
63+
class Period:
64+
def __init__(self, period: str, tzinfo: dt.tzinfo = None):
65+
self.text = period
66+
self.tzinfo = tzinfo
67+
68+
self.is_explicit = False
69+
self.start_dt = None
70+
self.end_dt = None
71+
self.delta = None
72+
self._parse()
73+
74+
def _parse(self):
75+
start_dt, end_dt = self.text.split("/")
76+
self.start_dt = DateTime(start_dt, self.tzinfo).value
77+
if _is_duration(end_dt):
78+
# period-start = date-time "/" dur-value
79+
self.is_explicit = False
80+
self.delta = Duration(end_dt).value
81+
else:
82+
# period-explicit = date-time "/" date-time
83+
self.is_explicit = True
84+
self.end_dt = DateTime(end_dt, self.tzinfo).value
85+
86+
@property
87+
def value(self) -> tuple[dt.datetime, dt.datetime | dt.timedelta]:
88+
return self.start_dt, self.delta or self.end_dt
89+
90+
91+
class Time:
92+
def __init__(self, time: str, tzinfo: dt.tzinfo = None):
93+
self.text = time
94+
self.tzinfo = tzinfo
95+
self._parse()
96+
97+
def _parse(self):
98+
try:
99+
_time = dt.datetime.strptime(self.text[:6], "%H%M%S").time()
100+
except ValueError as e:
101+
raise ParseError(f"'{self.text}' is not a valid TIME") from e
102+
103+
if len(self.text) > 6 and self.text[6] == "Z":
104+
self.tzinfo = TzidRegistry.get("UTC")
105+
self.value = _time.replace(tzinfo=self.tzinfo)

vobjectx/ical/__init__.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1 @@
1-
from .ical_parser import (
2-
parse_dtstart,
3-
string_to_date,
4-
string_to_date_time,
5-
string_to_durations,
6-
string_to_period,
7-
string_to_text_values,
8-
)
1+
from .ical_helper import date_to_datetime_, from_last_week_, parse_dtstart, string_to_text_values

vobjectx/ical/ical_helper.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import datetime as dt
22
import math
33

4-
# -------------------- Helper subclasses ---------------------------------------
4+
from vobjectx import datatypes as vtypes
5+
from vobjectx.exceptions import ParseError
6+
from vobjectx.registry import TzidRegistry
7+
8+
# -------------------- Helper funcs ---------------------------------------
59

610

711
def date_to_datetime_(dt_obj: dt.datetime | dt.date) -> dt.datetime:
@@ -18,3 +22,63 @@ def from_last_week_(dt_: dt.datetime) -> int:
1822
time_diff = next_month - dt_
1923
days_gap = time_diff.days + bool(time_diff.seconds)
2024
return math.ceil(days_gap / 7)
25+
26+
27+
# -------------------- Parser funcs ---------------------------------------
28+
29+
# DQUOTE included to work around iCal's penchant for backslash escaping it,
30+
# although it isn't actually supposed to be escaped according to rfc2445 TEXT
31+
ESCAPABLE_CHAR_LIST = '\\;,Nn"'
32+
33+
34+
def string_to_text_values(s: str, list_separator: str = ",", char_list: str = ESCAPABLE_CHAR_LIST) -> list[str]:
35+
def escaped_char(ch: str) -> str:
36+
if ch not in char_list:
37+
# leave unrecognized escaped characters for later passes
38+
return "\\" + ch
39+
return "\n" if ch in "nN" else ch
40+
41+
current = []
42+
results = []
43+
to_escape = False
44+
for char in s:
45+
if to_escape:
46+
current.append(escaped_char(char))
47+
to_escape = False
48+
continue
49+
50+
if char == "\\":
51+
to_escape = True
52+
elif char == list_separator:
53+
current = "".join(current)
54+
results.append(current)
55+
current = []
56+
else:
57+
current.append(char)
58+
59+
if current or not results:
60+
current = "".join(current)
61+
results.append(current)
62+
return results
63+
64+
65+
def parse_dtstart(contentline, allow_signature_mismatch: bool = False) -> dt.datetime | dt.date | None:
66+
"""
67+
Convert a contentline's value into a date or date-time.
68+
69+
A variety of clients don't serialize dates with the appropriate VALUE parameter, so rather than failing on these
70+
(technically invalid) lines, if allow_signature_mismatch is True, try to parse both varieties.
71+
"""
72+
tzinfo = TzidRegistry.get(getattr(contentline, "tzid_param", None))
73+
value_param = getattr(contentline, "value_param", "DATE-TIME").upper()
74+
parsed_dtstart = None
75+
if value_param == "DATE":
76+
parsed_dtstart = vtypes.Date(contentline.value).value
77+
elif value_param == "DATE-TIME":
78+
try:
79+
parsed_dtstart = vtypes.DateTime(contentline.value, tzinfo).value
80+
except ParseError as e:
81+
if not allow_signature_mismatch:
82+
raise e
83+
parsed_dtstart = vtypes.Date(contentline.value).value
84+
return parsed_dtstart

vobjectx/ical/ical_parser.py

Lines changed: 0 additions & 117 deletions
This file was deleted.

0 commit comments

Comments
 (0)