1"""DATE-TIME property type from :rfc:`5545`."""
2
3from datetime import datetime
4from typing import Any, ClassVar
5from xml.etree.ElementTree import Element, SubElement
6
7from icalendar.compatibility import Self
8from icalendar.error import JCalParsingError, XCalParsingError
9from icalendar.parser import Parameters
10from icalendar.parser.xcal.adapter import ElementAdapter
11from icalendar.parser.xcal.base import XCalParser
12from icalendar.parser.xcal.match import XCalRegexMatcher
13from icalendar.parser.xcal.wrapper import from_xcal_wrapper
14from icalendar.parser_tools import to_unicode
15from icalendar.timezone import tzp
16from icalendar.timezone.tzid import is_utc
17
18from .base import TimeBase
19
20XCAL_DATETIME_REGEX = XCalRegexMatcher(
21 r"(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(Z?)",
22 "Expected date-time format YYYY-MM-DDTHH:MM:SS or YYYY-MM-DDTHH:MM:SSZ",
23)
24
25
26class vDatetime(TimeBase):
27 """Date-Time
28
29 Value Name:
30 DATE-TIME
31
32 Purpose:
33 This value type is used to identify values that specify a
34 precise calendar date and time of day. The format is based on
35 the ISO.8601.2004 complete representation.
36
37 Format Definition:
38 This value type is defined by the following notation:
39
40 .. code-block:: text
41
42 date-time = date "T" time
43
44 date = date-value
45 date-value = date-fullyear date-month date-mday
46 date-fullyear = 4DIGIT
47 date-month = 2DIGIT ;01-12
48 date-mday = 2DIGIT ;01-28, 01-29, 01-30, 01-31
49 ;based on month/year
50 time = time-hour time-minute time-second [time-utc]
51 time-hour = 2DIGIT ;00-23
52 time-minute = 2DIGIT ;00-59
53 time-second = 2DIGIT ;00-60
54 time-utc = "Z"
55
56 The following is the representation of the date-time format.
57
58 .. code-block:: text
59
60 YYYYMMDDTHHMMSS
61
62 Description:
63 vDatetime is timezone aware and uses a timezone library.
64 When a vDatetime object is created from an
65 ical string, you can pass a valid timezone identifier. When a
66 vDatetime object is created from a Python :mod:`datetime` object, it uses the
67 tzinfo component, if present. Otherwise a timezone-naive object is
68 created. Be aware that there are certain limitations with timezone naive
69 DATE-TIME components in the icalendar standard.
70
71 Example:
72 The following represents March 2, 2021 at 10:15 AM with local time:
73
74 .. code-block:: pycon
75
76 >>> from icalendar import vDatetime
77 >>> datetime = vDatetime.from_ical("20210302T101500")
78 >>> datetime.tzname()
79 >>> datetime.year
80 2021
81 >>> datetime.minute
82 15
83
84 The following represents March 2, 2021 at 10:15 AM in New York:
85
86 .. code-block:: pycon
87
88 >>> datetime = vDatetime.from_ical("20210302T101500", 'America/New_York')
89 >>> datetime.tzname()
90 'EST'
91
92 The following represents March 2, 2021 at 10:15 AM in Berlin:
93
94 .. code-block:: pycon
95
96 >>> from zoneinfo import ZoneInfo
97 >>> timezone = ZoneInfo("Europe/Berlin")
98 >>> vDatetime.from_ical("20210302T101500", timezone)
99 datetime.datetime(2021, 3, 2, 10, 15, tzinfo=ZoneInfo(key='Europe/Berlin'))
100 """
101
102 default_value: ClassVar[str] = "DATE-TIME"
103 params: Parameters
104
105 def __init__(self, dt: datetime, /, params: dict[str, Any] | None = None) -> None:
106 self.dt = dt
107 self.params = Parameters(params)
108 self.params.update_tzid_from(dt)
109
110 def to_ical(self):
111 dt = self.dt
112
113 s = (
114 f"{dt.year:04}{dt.month:02}{dt.day:02}"
115 f"T{dt.hour:02}{dt.minute:02}{dt.second:02}"
116 )
117 if self.is_utc():
118 s += "Z"
119 return s.encode("utf-8")
120
121 @staticmethod
122 def from_ical(ical, timezone=None):
123 """Create a datetime from the RFC string."""
124 ical = to_unicode(ical)
125 tzinfo = None
126 if isinstance(timezone, str):
127 tzinfo = tzp.timezone(timezone)
128 elif timezone is not None:
129 tzinfo = timezone
130
131 # Extract the value part if parameters are present per
132 # https://datatracker.ietf.org/doc/html/rfc5545.html#section-3.3.5
133 # Form #3: TZID=America/New_York:19980119T020000
134 ical_value = ical.rpartition(":")[2]
135
136 if len(ical_value) < 15 or ical_value[8] != "T":
137 raise ValueError(f"Wrong datetime format: {ical}")
138
139 try:
140 timetuple = (
141 int(ical_value[:4]), # year
142 int(ical_value[4:6]), # month
143 int(ical_value[6:8]), # day
144 int(ical_value[9:11]), # hour
145 int(ical_value[11:13]), # minute
146 int(ical_value[13:15]), # second
147 )
148 if tzinfo:
149 return tzp.localize(datetime(*timetuple), tzinfo)
150 if not ical_value[15:]:
151 return datetime(*timetuple)
152 if ical_value[15:] == "Z":
153 return tzp.localize_utc(datetime(*timetuple))
154 except Exception as e:
155 raise ValueError(f"Wrong datetime format: {ical}") from e
156 raise ValueError(f"Wrong datetime format: {ical}")
157
158 @classmethod
159 def examples(cls) -> list[Self]:
160 """Examples of vDatetime."""
161 return [
162 cls(tzp.localize_utc(datetime(2025, 11, 10, 16, 52))),
163 cls(tzp.localize(datetime(2025, 11, 10, 16, 52), "America/New_York")),
164 cls(datetime(2025, 11, 10, 16, 52)),
165 ]
166
167 from icalendar.param import VALUE
168
169 def to_jcal(self, name: str) -> list:
170 """The jCal representation of this property according to :rfc:`7265`."""
171 value = self.dt.strftime("%Y-%m-%dT%H:%M:%S")
172 if self.is_utc():
173 value += "Z"
174 return [name, self.params.to_jcal(exclude_utc=True), self.VALUE.lower(), value]
175
176 def is_utc(self) -> bool:
177 """Whether this datetime is UTC."""
178 return self.params.is_utc() or is_utc(self.dt)
179
180 @classmethod
181 def parse_jcal_value(cls, jcal: str) -> datetime:
182 """Parse a jCal string to a :class:`datetime.datetime`.
183
184 Raises:
185 ~error.JCalParsingError: If it can't parse a date-time value.
186 """
187 JCalParsingError.validate_value_type(jcal, str, cls)
188 utc = jcal.endswith("Z")
189 if utc:
190 jcal = jcal[:-1]
191 try:
192 dt = datetime.strptime(jcal, "%Y-%m-%dT%H:%M:%S")
193 except ValueError as e:
194 raise JCalParsingError("Cannot parse date-time.", cls, value=jcal) from e
195 if utc:
196 return tzp.localize_utc(dt)
197 return dt
198
199 @classmethod
200 def from_jcal(cls, jcal_property: list) -> Self:
201 """Parse jCal from :rfc:`7265`.
202
203 Parameters:
204 jcal_property: The jCal property to parse.
205
206 Raises:
207 ~error.JCalParsingError: If the provided jCal is invalid.
208 """
209 JCalParsingError.validate_property(jcal_property, cls)
210 params = Parameters.from_jcal_property(jcal_property)
211 with JCalParsingError.reraise_with_path_added(3):
212 dt = cls.parse_jcal_value(jcal_property[3])
213 if params.tzid:
214 dt = tzp.localize(dt, params.tzid)
215 return cls(
216 dt,
217 params=params,
218 )
219
220 def to_xcal(self, element: Element) -> None:
221 """The xCal representation of this property according to :rfc:`6321`.
222
223 Parameters:
224 element: The element to add the property to.
225 """
226 self.params.to_xcal(element)
227 self.to_xcal_value_only(element, self.default_value)
228
229 def to_xcal_value_only(self, element: Element, tag: str):
230 """The xCal representation of this property according to :rfc:`6321`.
231
232 In contrast to :meth:`to_xcal`, this method does not add the parameters.
233
234 Parameters:
235 element: The element to add the property to.
236 tag: The tag to use.
237 """
238 element = SubElement(element, tag.lower())
239 text = self.dt.strftime("%Y-%m-%dT%H:%M:%S")
240 if is_utc(self.dt):
241 text += "Z"
242 element.text = text
243
244 @classmethod
245 def from_xcal_element(cls, element: ElementAdapter) -> datetime:
246 """Parse xCal from :rfc:`6321`.
247
248 Parameters:
249 element: The xCal element to parse.
250
251 Returns:
252 The datetime object, possibly with a UTC timezone.
253
254 Raises:
255 ~error.XCalParsingError: If the provided xCal is invalid.
256 """
257 year, month, day, hour, minute, second, utc = XCAL_DATETIME_REGEX.groups(
258 element
259 )
260 if second == "60":
261 # This is wrong but we have no way to represent this
262 second = 59
263 try:
264 dt = datetime(
265 int(year), int(month), int(day), int(hour), int(minute), int(second)
266 )
267 except ValueError as e:
268 raise XCalParsingError(
269 "Date-time is out of range", element.get_xsd_token(), element
270 ) from e
271 if utc:
272 dt = tzp.localize_utc(dt)
273 return dt
274
275 @classmethod
276 @from_xcal_wrapper
277 def from_xcal(cls, parser: XCalParser, params: Parameters) -> Self:
278 """Parse xCal from :rfc:`6321`.
279
280 Parameters:
281 xml: The XML to parse or a parser.
282
283 Raises:
284 ~error.XCalParsingError: If the provided xCal is invalid.
285 """
286 element = parser.parse_tag(cls.default_value)
287 dt = cls.from_xcal_element(element)
288 tzid = params.tzid
289 if tzid:
290 if is_utc(dt):
291 raise XCalParsingError(f"Cannot mix {tzid} with UTC", None, element)
292 dt = tzp.localize(dt, tzid)
293 return cls(dt, params=params)
294
295
296__all__ = ["vDatetime"]