Skip to content

Commit b4b2cc1

Browse files
authored
gh-53502: add a new option aware_datetime in plistlib to loads or dumps aware datetime. (#113363)
* add options to loads and dumps aware datetime in plistlib
1 parent d0b0e3d commit b4b2cc1

File tree

4 files changed

+134
-23
lines changed

4 files changed

+134
-23
lines changed

Doc/library/plistlib.rst

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ or :class:`datetime.datetime` objects.
5252

5353
This module defines the following functions:
5454

55-
.. function:: load(fp, *, fmt=None, dict_type=dict)
55+
.. function:: load(fp, *, fmt=None, dict_type=dict, aware_datetime=False)
5656

5757
Read a plist file. *fp* should be a readable and binary file object.
5858
Return the unpacked root object (which usually is a
@@ -69,6 +69,10 @@ This module defines the following functions:
6969
The *dict_type* is the type used for dictionaries that are read from the
7070
plist file.
7171

72+
When *aware_datetime* is true, fields with type ``datetime.datetime`` will
73+
be created as :ref:`aware object <datetime-naive-aware>`, with
74+
:attr:`!tzinfo` as :attr:`datetime.UTC`.
75+
7276
XML data for the :data:`FMT_XML` format is parsed using the Expat parser
7377
from :mod:`xml.parsers.expat` -- see its documentation for possible
7478
exceptions on ill-formed XML. Unknown elements will simply be ignored
@@ -79,16 +83,19 @@ This module defines the following functions:
7983

8084
.. versionadded:: 3.4
8185

86+
.. versionchanged:: 3.13
87+
The keyword-only parameter *aware_datetime* has been added.
88+
8289

83-
.. function:: loads(data, *, fmt=None, dict_type=dict)
90+
.. function:: loads(data, *, fmt=None, dict_type=dict, aware_datetime=False)
8491

8592
Load a plist from a bytes object. See :func:`load` for an explanation of
8693
the keyword arguments.
8794

8895
.. versionadded:: 3.4
8996

9097

91-
.. function:: dump(value, fp, *, fmt=FMT_XML, sort_keys=True, skipkeys=False)
98+
.. function:: dump(value, fp, *, fmt=FMT_XML, sort_keys=True, skipkeys=False, aware_datetime=False)
9299

93100
Write *value* to a plist file. *Fp* should be a writable, binary
94101
file object.
@@ -107,6 +114,10 @@ This module defines the following functions:
107114
When *skipkeys* is false (the default) the function raises :exc:`TypeError`
108115
when a key of a dictionary is not a string, otherwise such keys are skipped.
109116

117+
When *aware_datetime* is true and any field with type ``datetime.datetime``
118+
is set as a :ref:`aware object <datetime-naive-aware>`, it will convert to
119+
UTC timezone before writing it.
120+
110121
A :exc:`TypeError` will be raised if the object is of an unsupported type or
111122
a container that contains objects of unsupported types.
112123

@@ -115,8 +126,11 @@ This module defines the following functions:
115126

116127
.. versionadded:: 3.4
117128

129+
.. versionchanged:: 3.13
130+
The keyword-only parameter *aware_datetime* has been added.
131+
118132

119-
.. function:: dumps(value, *, fmt=FMT_XML, sort_keys=True, skipkeys=False)
133+
.. function:: dumps(value, *, fmt=FMT_XML, sort_keys=True, skipkeys=False, aware_datetime=False)
120134

121135
Return *value* as a plist-formatted bytes object. See
122136
the documentation for :func:`dump` for an explanation of the keyword

Lib/plistlib.py

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ def _decode_base64(s):
140140
_dateParser = re.compile(r"(?P<year>\d\d\d\d)(?:-(?P<month>\d\d)(?:-(?P<day>\d\d)(?:T(?P<hour>\d\d)(?::(?P<minute>\d\d)(?::(?P<second>\d\d))?)?)?)?)?Z", re.ASCII)
141141

142142

143-
def _date_from_string(s):
143+
def _date_from_string(s, aware_datetime):
144144
order = ('year', 'month', 'day', 'hour', 'minute', 'second')
145145
gd = _dateParser.match(s).groupdict()
146146
lst = []
@@ -149,10 +149,14 @@ def _date_from_string(s):
149149
if val is None:
150150
break
151151
lst.append(int(val))
152+
if aware_datetime:
153+
return datetime.datetime(*lst, tzinfo=datetime.UTC)
152154
return datetime.datetime(*lst)
153155

154156

155-
def _date_to_string(d):
157+
def _date_to_string(d, aware_datetime):
158+
if aware_datetime:
159+
d = d.astimezone(datetime.UTC)
156160
return '%04d-%02d-%02dT%02d:%02d:%02dZ' % (
157161
d.year, d.month, d.day,
158162
d.hour, d.minute, d.second
@@ -171,11 +175,12 @@ def _escape(text):
171175
return text
172176

173177
class _PlistParser:
174-
def __init__(self, dict_type):
178+
def __init__(self, dict_type, aware_datetime=False):
175179
self.stack = []
176180
self.current_key = None
177181
self.root = None
178182
self._dict_type = dict_type
183+
self._aware_datetime = aware_datetime
179184

180185
def parse(self, fileobj):
181186
self.parser = ParserCreate()
@@ -277,7 +282,8 @@ def end_data(self):
277282
self.add_object(_decode_base64(self.get_data()))
278283

279284
def end_date(self):
280-
self.add_object(_date_from_string(self.get_data()))
285+
self.add_object(_date_from_string(self.get_data(),
286+
aware_datetime=self._aware_datetime))
281287

282288

283289
class _DumbXMLWriter:
@@ -321,13 +327,14 @@ def writeln(self, line):
321327
class _PlistWriter(_DumbXMLWriter):
322328
def __init__(
323329
self, file, indent_level=0, indent=b"\t", writeHeader=1,
324-
sort_keys=True, skipkeys=False):
330+
sort_keys=True, skipkeys=False, aware_datetime=False):
325331

326332
if writeHeader:
327333
file.write(PLISTHEADER)
328334
_DumbXMLWriter.__init__(self, file, indent_level, indent)
329335
self._sort_keys = sort_keys
330336
self._skipkeys = skipkeys
337+
self._aware_datetime = aware_datetime
331338

332339
def write(self, value):
333340
self.writeln("<plist version=\"1.0\">")
@@ -360,7 +367,8 @@ def write_value(self, value):
360367
self.write_bytes(value)
361368

362369
elif isinstance(value, datetime.datetime):
363-
self.simple_element("date", _date_to_string(value))
370+
self.simple_element("date",
371+
_date_to_string(value, self._aware_datetime))
364372

365373
elif isinstance(value, (tuple, list)):
366374
self.write_array(value)
@@ -461,8 +469,9 @@ class _BinaryPlistParser:
461469
462470
see also: http://opensource.apple.com/source/CF/CF-744.18/CFBinaryPList.c
463471
"""
464-
def __init__(self, dict_type):
472+
def __init__(self, dict_type, aware_datetime=False):
465473
self._dict_type = dict_type
474+
self._aware_datime = aware_datetime
466475

467476
def parse(self, fp):
468477
try:
@@ -556,8 +565,11 @@ def _read_object(self, ref):
556565
f = struct.unpack('>d', self._fp.read(8))[0]
557566
# timestamp 0 of binary plists corresponds to 1/1/2001
558567
# (year of Mac OS X 10.0), instead of 1/1/1970.
559-
result = (datetime.datetime(2001, 1, 1) +
560-
datetime.timedelta(seconds=f))
568+
if self._aware_datime:
569+
epoch = datetime.datetime(2001, 1, 1, tzinfo=datetime.UTC)
570+
else:
571+
epoch = datetime.datetime(2001, 1, 1)
572+
result = epoch + datetime.timedelta(seconds=f)
561573

562574
elif tokenH == 0x40: # data
563575
s = self._get_size(tokenL)
@@ -629,10 +641,11 @@ def _count_to_size(count):
629641
_scalars = (str, int, float, datetime.datetime, bytes)
630642

631643
class _BinaryPlistWriter (object):
632-
def __init__(self, fp, sort_keys, skipkeys):
644+
def __init__(self, fp, sort_keys, skipkeys, aware_datetime=False):
633645
self._fp = fp
634646
self._sort_keys = sort_keys
635647
self._skipkeys = skipkeys
648+
self._aware_datetime = aware_datetime
636649

637650
def write(self, value):
638651

@@ -778,7 +791,12 @@ def _write_object(self, value):
778791
self._fp.write(struct.pack('>Bd', 0x23, value))
779792

780793
elif isinstance(value, datetime.datetime):
781-
f = (value - datetime.datetime(2001, 1, 1)).total_seconds()
794+
if self._aware_datetime:
795+
dt = value.astimezone(datetime.UTC)
796+
offset = dt - datetime.datetime(2001, 1, 1, tzinfo=datetime.UTC)
797+
f = offset.total_seconds()
798+
else:
799+
f = (value - datetime.datetime(2001, 1, 1)).total_seconds()
782800
self._fp.write(struct.pack('>Bd', 0x33, f))
783801

784802
elif isinstance(value, (bytes, bytearray)):
@@ -862,7 +880,7 @@ def _is_fmt_binary(header):
862880
}
863881

864882

865-
def load(fp, *, fmt=None, dict_type=dict):
883+
def load(fp, *, fmt=None, dict_type=dict, aware_datetime=False):
866884
"""Read a .plist file. 'fp' should be a readable and binary file object.
867885
Return the unpacked root object (which usually is a dictionary).
868886
"""
@@ -880,32 +898,36 @@ def load(fp, *, fmt=None, dict_type=dict):
880898
else:
881899
P = _FORMATS[fmt]['parser']
882900

883-
p = P(dict_type=dict_type)
901+
p = P(dict_type=dict_type, aware_datetime=aware_datetime)
884902
return p.parse(fp)
885903

886904

887-
def loads(value, *, fmt=None, dict_type=dict):
905+
def loads(value, *, fmt=None, dict_type=dict, aware_datetime=False):
888906
"""Read a .plist file from a bytes object.
889907
Return the unpacked root object (which usually is a dictionary).
890908
"""
891909
fp = BytesIO(value)
892-
return load(fp, fmt=fmt, dict_type=dict_type)
910+
return load(fp, fmt=fmt, dict_type=dict_type, aware_datetime=aware_datetime)
893911

894912

895-
def dump(value, fp, *, fmt=FMT_XML, sort_keys=True, skipkeys=False):
913+
def dump(value, fp, *, fmt=FMT_XML, sort_keys=True, skipkeys=False,
914+
aware_datetime=False):
896915
"""Write 'value' to a .plist file. 'fp' should be a writable,
897916
binary file object.
898917
"""
899918
if fmt not in _FORMATS:
900919
raise ValueError("Unsupported format: %r"%(fmt,))
901920

902-
writer = _FORMATS[fmt]["writer"](fp, sort_keys=sort_keys, skipkeys=skipkeys)
921+
writer = _FORMATS[fmt]["writer"](fp, sort_keys=sort_keys, skipkeys=skipkeys,
922+
aware_datetime=aware_datetime)
903923
writer.write(value)
904924

905925

906-
def dumps(value, *, fmt=FMT_XML, skipkeys=False, sort_keys=True):
926+
def dumps(value, *, fmt=FMT_XML, skipkeys=False, sort_keys=True,
927+
aware_datetime=False):
907928
"""Return a bytes object with the contents for a .plist file.
908929
"""
909930
fp = BytesIO()
910-
dump(value, fp, fmt=fmt, skipkeys=skipkeys, sort_keys=sort_keys)
931+
dump(value, fp, fmt=fmt, skipkeys=skipkeys, sort_keys=sort_keys,
932+
aware_datetime=aware_datetime)
911933
return fp.getvalue()

Lib/test/test_plistlib.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import subprocess
1414
import binascii
1515
import collections
16+
import time
17+
import zoneinfo
1618
from test import support
1719
from test.support import os_helper
1820
from io import BytesIO
@@ -838,6 +840,54 @@ def test_xml_plist_with_entity_decl(self):
838840
"XML entity declarations are not supported"):
839841
plistlib.loads(XML_PLIST_WITH_ENTITY, fmt=plistlib.FMT_XML)
840842

843+
def test_load_aware_datetime(self):
844+
dt = plistlib.loads(b"<plist><date>2023-12-10T08:03:30Z</date></plist>",
845+
aware_datetime=True)
846+
self.assertEqual(dt.tzinfo, datetime.UTC)
847+
848+
@unittest.skipUnless("America/Los_Angeles" in zoneinfo.available_timezones(),
849+
"Can't find timezone datebase")
850+
def test_dump_aware_datetime(self):
851+
dt = datetime.datetime(2345, 6, 7, 8, 9, 10,
852+
tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles"))
853+
for fmt in ALL_FORMATS:
854+
s = plistlib.dumps(dt, fmt=fmt, aware_datetime=True)
855+
loaded_dt = plistlib.loads(s, fmt=fmt, aware_datetime=True)
856+
self.assertEqual(loaded_dt.tzinfo, datetime.UTC)
857+
self.assertEqual(loaded_dt, dt)
858+
859+
def test_dump_utc_aware_datetime(self):
860+
dt = datetime.datetime(2345, 6, 7, 8, 9, 10, tzinfo=datetime.UTC)
861+
for fmt in ALL_FORMATS:
862+
s = plistlib.dumps(dt, fmt=fmt, aware_datetime=True)
863+
loaded_dt = plistlib.loads(s, fmt=fmt, aware_datetime=True)
864+
self.assertEqual(loaded_dt.tzinfo, datetime.UTC)
865+
self.assertEqual(loaded_dt, dt)
866+
867+
@unittest.skipUnless("America/Los_Angeles" in zoneinfo.available_timezones(),
868+
"Can't find timezone datebase")
869+
def test_dump_aware_datetime_without_aware_datetime_option(self):
870+
dt = datetime.datetime(2345, 6, 7, 8,
871+
tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles"))
872+
s = plistlib.dumps(dt, fmt=plistlib.FMT_XML, aware_datetime=False)
873+
self.assertIn(b"2345-06-07T08:00:00Z", s)
874+
875+
def test_dump_utc_aware_datetime_without_aware_datetime_option(self):
876+
dt = datetime.datetime(2345, 6, 7, 8, tzinfo=datetime.UTC)
877+
s = plistlib.dumps(dt, fmt=plistlib.FMT_XML, aware_datetime=False)
878+
self.assertIn(b"2345-06-07T08:00:00Z", s)
879+
880+
def test_dump_naive_datetime_with_aware_datetime_option(self):
881+
# Save a naive datetime with aware_datetime set to true. This will lead
882+
# to having different time as compared to the current machine's
883+
# timezone, which is UTC.
884+
dt = datetime.datetime(2345, 6, 7, 8, tzinfo=None)
885+
for fmt in ALL_FORMATS:
886+
s = plistlib.dumps(dt, fmt=fmt, aware_datetime=True)
887+
parsed = plistlib.loads(s, aware_datetime=False)
888+
expected = dt + datetime.timedelta(seconds=time.timezone)
889+
self.assertEqual(parsed, expected)
890+
841891

842892
class TestBinaryPlistlib(unittest.TestCase):
843893

@@ -962,6 +1012,28 @@ def test_invalid_binary(self):
9621012
with self.assertRaises(plistlib.InvalidFileException):
9631013
plistlib.loads(b'bplist00' + data, fmt=plistlib.FMT_BINARY)
9641014

1015+
def test_load_aware_datetime(self):
1016+
data = (b'bplist003B\x04>\xd0d\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00'
1017+
b'\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00'
1018+
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x11')
1019+
self.assertEqual(plistlib.loads(data, aware_datetime=True),
1020+
datetime.datetime(2345, 6, 7, 8, tzinfo=datetime.UTC))
1021+
1022+
@unittest.skipUnless("America/Los_Angeles" in zoneinfo.available_timezones(),
1023+
"Can't find timezone datebase")
1024+
def test_dump_aware_datetime_without_aware_datetime_option(self):
1025+
dt = datetime.datetime(2345, 6, 7, 8,
1026+
tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles"))
1027+
msg = "can't subtract offset-naive and offset-aware datetimes"
1028+
with self.assertRaisesRegex(TypeError, msg):
1029+
plistlib.dumps(dt, fmt=plistlib.FMT_BINARY, aware_datetime=False)
1030+
1031+
def test_dump_utc_aware_datetime_without_aware_datetime_option(self):
1032+
dt = datetime.datetime(2345, 6, 7, 8, tzinfo=datetime.UTC)
1033+
msg = "can't subtract offset-naive and offset-aware datetimes"
1034+
with self.assertRaisesRegex(TypeError, msg):
1035+
plistlib.dumps(dt, fmt=plistlib.FMT_BINARY, aware_datetime=False)
1036+
9651037

9661038
class TestKeyedArchive(unittest.TestCase):
9671039
def test_keyed_archive_data(self):
@@ -1072,5 +1144,6 @@ def test_octal_and_hex(self):
10721144
self.assertEqual(p.get("HexType"), 16777228)
10731145
self.assertEqual(p.get("IntType"), 83)
10741146

1147+
10751148
if __name__ == '__main__':
10761149
unittest.main()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add a new option ``aware_datetime`` in :mod:`plistlib` to loads or dumps
2+
aware datetime.

0 commit comments

Comments
 (0)