|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +"""``<date>`` values in a ``plist`` report (GitHub issue #1448). |
| 3 | +
|
| 4 | +:meth:`dictdumper.plist.PLIST._append_date` writes every date as |
| 5 | +``%Y-%m-%dT%H:%M:%S.%fZ``. The property list ``<date>`` grammar has no |
| 6 | +fractional part, so :func:`plistlib.load` failed on every report with |
| 7 | +``AttributeError: 'NoneType' object has no attribute 'groupdict'``. These tests |
| 8 | +dump through :func:`~pcapkit.dumpkit.common.make_dumper` and read the file back |
| 9 | +with :mod:`plistlib`, so they need no capture. :func:`plistlib.loads` is |
| 10 | +called without ``aware_datetime``, which is new in Python 3.13, so every |
| 11 | +date comes back as a naive UTC value. :mod:`pcapkit` is imported |
| 12 | +inside each test, after :func:`~tests._support.reimport_once_per_class`. |
| 13 | +
|
| 14 | +""" |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import datetime |
| 18 | +import decimal |
| 19 | +import importlib.util |
| 20 | +import os |
| 21 | +import plistlib |
| 22 | +import tempfile |
| 23 | +import unittest |
| 24 | + |
| 25 | +from tests._support import reimport_once_per_class |
| 26 | + |
| 27 | +RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') |
| 28 | +HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) |
| 29 | + |
| 30 | +UTC = datetime.timezone.utc |
| 31 | + |
| 32 | + |
| 33 | +@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') |
| 34 | +class TestPlistDate(unittest.TestCase): |
| 35 | + """Every ``<date>`` the customised ``PLIST`` writer emits parses.""" |
| 36 | + |
| 37 | + def setUp(self) -> None: |
| 38 | + reimport_once_per_class(self) |
| 39 | + tmpdir = tempfile.TemporaryDirectory(prefix='pcapkit-1448-') # pylint: disable=consider-using-with |
| 40 | + self.addCleanup(tmpdir.cleanup) |
| 41 | + self.tmpdir = tmpdir.name |
| 42 | + |
| 43 | + def dump(self, kind: 'str', value: 'dict[str, object]') -> 'bytes': |
| 44 | + """Dump ``value`` through the customised ``dictdumper`` writer ``kind``.""" |
| 45 | + import dictdumper |
| 46 | + |
| 47 | + from pcapkit.dumpkit.common import make_dumper |
| 48 | + |
| 49 | + path = os.path.join(self.tmpdir, f'out.{kind.lower()}') |
| 50 | + make_dumper(getattr(dictdumper, kind))(path)(value, name='x') |
| 51 | + with open(path, 'rb') as file: |
| 52 | + return file.read() |
| 53 | + |
| 54 | + def load(self, value: 'dict[str, object]') -> 'dict[str, object]': |
| 55 | + """Dump ``value`` as ``plist`` and read it back with :func:`plistlib.loads`.""" |
| 56 | + return plistlib.loads(self.dump('PLIST', value))['x'] |
| 57 | + |
| 58 | + def test_sub_second_date_is_written_to_whole_seconds(self) -> None: |
| 59 | + """The regression: a fractional date parses, truncated, not rounded.""" |
| 60 | + stamp = datetime.datetime(2017, 11, 19, 15, 49, 5, 999999, tzinfo=UTC) |
| 61 | + |
| 62 | + raw = self.dump('PLIST', {'time': stamp}) |
| 63 | + |
| 64 | + self.assertIn(b'<date>2017-11-19T15:49:05Z</date>', raw) |
| 65 | + self.assertEqual(self.load({'time': stamp}), |
| 66 | + {'time': datetime.datetime(2017, 11, 19, 15, 49, 5)}) |
| 67 | + |
| 68 | + def test_exact_sibling_survives_alongside_the_date(self) -> None: |
| 69 | + """The exact value is carried by the ``*_epoch`` sibling, as a frame reports it.""" |
| 70 | + epoch = decimal.Decimal('1511106545.471719') |
| 71 | + stamp = datetime.datetime(2017, 11, 19, 15, 49, 5, 471719, tzinfo=UTC) |
| 72 | + |
| 73 | + got = self.load({'time': stamp, 'time_epoch': epoch}) |
| 74 | + |
| 75 | + self.assertEqual(got['time'], datetime.datetime(2017, 11, 19, 15, 49, 5)) |
| 76 | + self.assertEqual(decimal.Decimal(got['time_epoch']), epoch) |
| 77 | + |
| 78 | + def test_aware_date_is_converted_to_utc(self) -> None: |
| 79 | + """``Z`` means UTC, so an offset is applied rather than dropped.""" |
| 80 | + tz = datetime.timezone(datetime.timedelta(hours=8)) |
| 81 | + stamp = datetime.datetime(2020, 1, 1, 8, 0, 0, 500000, tzinfo=tz) |
| 82 | + |
| 83 | + self.assertEqual(self.load({'t': stamp}), |
| 84 | + {'t': datetime.datetime(2020, 1, 1, 0, 0, 0)}) |
| 85 | + |
| 86 | + def test_naive_date_and_plain_date_are_written_as_is(self) -> None: |
| 87 | + """A naive value is taken as UTC already, as :func:`plistlib.dump` takes it.""" |
| 88 | + got = self.load({ |
| 89 | + 'naive': datetime.datetime(1999, 12, 31, 23, 59, 59, 1), |
| 90 | + 'day': datetime.date(2000, 2, 29), |
| 91 | + 'early': datetime.datetime(5, 1, 2, 3, 4, 5), |
| 92 | + }) |
| 93 | + |
| 94 | + self.assertEqual(got, { |
| 95 | + 'naive': datetime.datetime(1999, 12, 31, 23, 59, 59), |
| 96 | + 'day': datetime.datetime(2000, 2, 29), |
| 97 | + 'early': datetime.datetime(5, 1, 2, 3, 4, 5), |
| 98 | + }) |
| 99 | + |
| 100 | + def test_json_and_tree_keep_the_fractional_date(self) -> None: |
| 101 | + """Only the ``plist`` writer is changed; the others keep full precision.""" |
| 102 | + stamp = datetime.datetime(2017, 11, 19, 15, 49, 5, 471719, tzinfo=UTC) |
| 103 | + |
| 104 | + self.assertIn(b'2017-11-19T15:49:05.471719', self.dump('JSON', {'time': stamp})) |
| 105 | + self.assertIn(b'2017-11-19T15:49:05.471719', self.dump('Tree', {'time': stamp})) |
| 106 | + |
| 107 | + |
| 108 | +if __name__ == '__main__': |
| 109 | + unittest.main() |
0 commit comments