@@ -210,34 +210,44 @@ def _get_or_add(self, prop_name: str):
210210 return element
211211
212212 @classmethod
213- def _offset_dt (cls , datetime : dt .datetime , offset_str : str ):
214- """Return |datetime| instance offset from `datetime` by offset specified in `offset_str`.
215-
216- `offset_str` is a string like `'-07:00'`.
217- """
213+ def _tzinfo_from_offset_str (cls , offset_str : str ) -> dt .timezone :
214+ """Return a :class:`datetime.timezone` parsed from a W3CDTF offset like '-08:00'."""
218215 match = cls ._offset_pattern .match (offset_str )
219216 if match is None :
220217 raise ValueError (f"{ repr (offset_str )} is not a valid offset string" )
221218 sign , hours_str , minutes_str = match .groups ()
222- sign_factor = - 1 if sign == "+" else 1
219+ sign_factor = 1 if sign == "+" else - 1
223220 hours = int (hours_str ) * sign_factor
224221 minutes = int (minutes_str ) * sign_factor
225- td = dt .timedelta (hours = hours , minutes = minutes )
226- return datetime + td
222+ return dt .timezone (dt .timedelta (hours = hours , minutes = minutes ))
227223
228224 _offset_pattern = re .compile (r"([+-])(\d\d):(\d\d)" )
229225
230226 @classmethod
231227 def _parse_W3CDTF_to_datetime (cls , w3cdtf_str : str ) -> dt .datetime :
232- # valid W3CDTF date cases:
233- # yyyy e.g. '2003'
234- # yyyy-mm e.g. '2003-12'
235- # yyyy-mm-dd e.g. '2003-12-31'
236- # UTC timezone e.g. '2003-12-31T10:14:55Z'
237- # numeric timezone e.g. '2003-12-31T10:14:55-08:00'
228+ """Parse a W3CDTF string into a :class:`datetime.datetime`.
229+
230+ Returns a tz-aware datetime when the input string carries timezone
231+ information — ``Z`` suffix maps to UTC; numeric offsets like
232+ ``-08:00`` map to a fixed-offset :class:`datetime.timezone`. When the
233+ input has no timezone marker (year-only, year-month, year-month-day,
234+ or a bare timestamp), the returned datetime is naive — callers
235+ should not assume any specific timezone for naive inputs.
236+
237+ Closes scanny/python-pptx#957 — the prior implementation always
238+ returned a naive datetime, even for strings that explicitly carried
239+ timezone information.
240+
241+ valid W3CDTF date cases:
242+ - yyyy e.g. '2003'
243+ - yyyy-mm e.g. '2003-12'
244+ - yyyy-mm-dd e.g. '2003-12-31'
245+ - UTC timezone e.g. '2003-12-31T10:14:55Z'
246+ - numeric timezone e.g. '2003-12-31T10:14:55-08:00'
247+ """
238248 templates = ("%Y-%m-%dT%H:%M:%S" , "%Y-%m-%d" , "%Y-%m" , "%Y" )
239- # strptime isn 't smart enough to parse literal timezone offsets like
240- # '-07:30', so we have to do it ourselves
249+ # --- strptime can 't parse literal timezone offsets like '-07:30', so
250+ # ---we strip the offset and add tzinfo ourselves below.
241251 parseable_part = w3cdtf_str [:19 ]
242252 offset_str = w3cdtf_str [19 :]
243253 timestamp = None
@@ -249,15 +259,31 @@ def _parse_W3CDTF_to_datetime(cls, w3cdtf_str: str) -> dt.datetime:
249259 if timestamp is None :
250260 tmpl = "could not parse W3CDTF datetime string '%s'"
251261 raise ValueError (tmpl % w3cdtf_str )
262+ # ---'Z' means UTC---
263+ if offset_str == "Z" :
264+ return timestamp .replace (tzinfo = dt .timezone .utc )
265+ # ---numeric offset like '-08:00'---
252266 if len (offset_str ) == 6 :
253- return cls ._offset_dt (timestamp , offset_str )
267+ return timestamp .replace (tzinfo = cls ._tzinfo_from_offset_str (offset_str ))
268+ # ---no timezone marker; return naive (don't assume UTC)---
254269 return timestamp
255270
256271 def _set_element_datetime (self , prop_name : str , value : dt .datetime ) -> None :
257- """Set date/time value of child element having `prop_name` to `value`."""
272+ """Set date/time value of child element having `prop_name` to `value`.
273+
274+ Accepts both naive and tz-aware datetimes. Tz-aware inputs are
275+ converted to UTC before serialization so the on-disk W3CDTF form
276+ always uses the canonical ``YYYY-MM-DDTHH:MM:SSZ`` shape. Naive
277+ inputs are written as-is (with the trailing ``Z`` suffix indicating
278+ UTC) — callers passing naive datetimes are responsible for the
279+ timezone interpretation.
280+ """
258281 if not isinstance (value , dt .datetime ): # pyright: ignore[reportUnnecessaryIsInstance]
259282 tmpl = "property requires <type 'datetime.datetime'> object, got %s"
260283 raise ValueError (tmpl % type (value ))
284+ # ---tz-aware -> normalize to UTC before serializing---
285+ if value .tzinfo is not None :
286+ value = value .astimezone (dt .timezone .utc ).replace (tzinfo = None )
261287 element = self ._get_or_add (prop_name )
262288 dt_str = value .strftime ("%Y-%m-%dT%H:%M:%SZ" )
263289 element .text = dt_str
0 commit comments