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 ICalParsingError, 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) -> bytes:
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
125 Parameters:
126 ical (str | bytes): Required. The iCalendar DATE-TIME value to parse.
127 timezone (str | datetime.tzinfo | None): The time zone name or object
128 used to localize the result. If ``None``, a trailing ``Z`` selects
129 UTC and other values remain floating.
130
131 Raises:
132 ~icalendar.error.ICalParsingError: If the value is not a valid DATE-TIME.
133 """
134 ical = to_unicode(ical)
135 tzinfo = None
136 if isinstance(timezone, str):
137 tzinfo = tzp.timezone(timezone)
138 elif timezone is not None:
139 tzinfo = timezone
140
141 # Extract the value part if parameters are present per
142 # https://datatracker.ietf.org/doc/html/rfc5545.html#section-3.3.5
143 # Form #3: TZID=America/New_York:19980119T020000
144 ical_value = ical.rpartition(":")[2]
145
146 if len(ical_value) < 15 or ical_value[8] != "T":
147 raise ICalParsingError(
148 "Wrong datetime format",
149 value=ical,
150 )
151
152 try:
153 timetuple = (
154 int(ical_value[:4]), # year
155 int(ical_value[4:6]), # month
156 int(ical_value[6:8]), # day
157 int(ical_value[9:11]), # hour
158 int(ical_value[11:13]), # minute
159 int(ical_value[13:15]), # second
160 )
161 if tzinfo:
162 return tzp.localize(datetime(*timetuple), tzinfo)
163 if not ical_value[15:]:
164 return datetime(*timetuple)
165 if ical_value[15:] == "Z":
166 return tzp.localize_utc(datetime(*timetuple))
167 except Exception as e:
168 raise ICalParsingError(
169 "Wrong datetime format",
170 value=ical,
171 ) from e
172 raise ICalParsingError(
173 "Wrong datetime format",
174 value=ical,
175 )
176
177 @classmethod
178 def examples(cls) -> list[Self]:
179 """Examples of vDatetime."""
180 return [
181 cls(tzp.localize_utc(datetime(2025, 11, 10, 16, 52))),
182 cls(tzp.localize(datetime(2025, 11, 10, 16, 52), "America/New_York")),
183 cls(datetime(2025, 11, 10, 16, 52)),
184 ]
185
186 from icalendar.param import VALUE
187
188 def to_jcal(self, name: str) -> list:
189 """The jCal representation of this property according to :rfc:`7265`."""
190 value = self.dt.strftime("%Y-%m-%dT%H:%M:%S")
191 if self.is_utc():
192 value += "Z"
193 return [name, self.params.to_jcal(exclude_utc=True), self.VALUE.lower(), value]
194
195 def is_utc(self) -> bool:
196 """Whether this datetime is UTC."""
197 return self.params.is_utc() or is_utc(self.dt)
198
199 @classmethod
200 def parse_jcal_value(cls, jcal: str) -> datetime:
201 """Parse a jCal string to a :class:`datetime.datetime`.
202
203 Raises:
204 ~icalendar.error.JCalParsingError: If it can't parse a date-time value.
205 """
206 JCalParsingError.validate_value_type(jcal, str, cls)
207 utc = jcal.endswith("Z")
208 if utc:
209 jcal = jcal[:-1]
210 try:
211 dt = datetime.strptime(jcal, "%Y-%m-%dT%H:%M:%S")
212 except ValueError as e:
213 raise JCalParsingError("Cannot parse date-time.", cls, value=jcal) from e
214 if utc:
215 return tzp.localize_utc(dt)
216 return dt
217
218 @classmethod
219 def from_jcal(cls, jcal_property: list) -> Self:
220 """Parse jCal from :rfc:`7265`.
221
222 Parameters:
223 jcal_property: The jCal property to parse.
224
225 Raises:
226 ~icalendar.error.JCalParsingError: If the provided jCal is invalid.
227 """
228 JCalParsingError.validate_property(jcal_property, cls)
229 params = Parameters.from_jcal_property(jcal_property)
230 with JCalParsingError.reraise_with_path_added(3):
231 dt = cls.parse_jcal_value(jcal_property[3])
232 if params.tzid:
233 dt = tzp.localize(dt, params.tzid)
234 return cls(
235 dt,
236 params=params,
237 )
238
239 def to_xcal(self, element: Element) -> None:
240 """The xCal representation of this property according to :rfc:`6321`.
241
242 Parameters:
243 element: The element to add the property to.
244 """
245 self.params.to_xcal(element)
246 self.to_xcal_value_only(element, self.default_value)
247
248 def to_xcal_value_only(self, element: Element, tag: str):
249 """The xCal representation of this property according to :rfc:`6321`.
250
251 In contrast to :meth:`to_xcal`, this method does not add the parameters.
252
253 Parameters:
254 element: The element to add the property to.
255 tag: The tag to use.
256 """
257 element = SubElement(element, tag.lower())
258 text = self.dt.strftime("%Y-%m-%dT%H:%M:%S")
259 if is_utc(self.dt):
260 text += "Z"
261 element.text = text
262
263 @classmethod
264 def from_xcal_element(cls, element: ElementAdapter) -> datetime:
265 """Parse xCal from :rfc:`6321`.
266
267 Parameters:
268 element: The xCal element to parse.
269
270 Returns:
271 The datetime object, possibly with a UTC timezone.
272
273 Raises:
274 ~error.XCalParsingError: If the provided xCal is invalid.
275 """
276 year, month, day, hour, minute, second, utc = XCAL_DATETIME_REGEX.groups(
277 element
278 )
279 if second == "60":
280 # This is wrong but we have no way to represent this
281 second = 59
282 try:
283 dt = datetime(
284 int(year), int(month), int(day), int(hour), int(minute), int(second)
285 )
286 except ValueError as e:
287 raise XCalParsingError(
288 "Date-time is out of range", element.get_xsd_token(), element
289 ) from e
290 if utc:
291 dt = tzp.localize_utc(dt)
292 return dt
293
294 @classmethod
295 @from_xcal_wrapper
296 def from_xcal(cls, parser: XCalParser, params: Parameters) -> Self:
297 """Parse xCal from :rfc:`6321`.
298
299 Parameters:
300 xml: The XML to parse or a parser.
301
302 Raises:
303 ~error.XCalParsingError: If the provided xCal is invalid.
304 """
305 element = parser.parse_tag(cls.default_value)
306 dt = cls.from_xcal_element(element)
307 tzid = params.tzid
308 if tzid:
309 if is_utc(dt):
310 raise XCalParsingError(f"Cannot mix {tzid} with UTC", None, element)
311 dt = tzp.localize(dt, tzid)
312 return cls(dt, params=params)
313
314
315__all__ = ["vDatetime"]