diff --git a/src/docx/oxml/coreprops.py b/src/docx/oxml/coreprops.py index fcff0c7ba..8b31461a1 100644 --- a/src/docx/oxml/coreprops.py +++ b/src/docx/oxml/coreprops.py @@ -234,6 +234,7 @@ def _parse_W3CDTF_to_datetime(cls, w3cdtf_str: str) -> dt.datetime: # yyyy-mm-dd e.g. "2003-12-31" # UTC timezone e.g. "2003-12-31T10:14:55Z" # numeric timezone e.g. "2003-12-31T10:14:55-08:00" + # fractional seconds e.g. "2003-12-31T10:14:55.123-08:00" templates = ( "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d", @@ -253,6 +254,11 @@ def _parse_W3CDTF_to_datetime(cls, w3cdtf_str: str) -> dt.datetime: if dt_ is None: tmpl = "could not parse W3CDTF datetime string '%s'" raise ValueError(tmpl % w3cdtf_str) + fraction_match = re.fullmatch(r"\.([0-9]+)(Z|[+-][0-9]{2}:[0-9]{2})?", offset_str) + if fraction_match is not None: + fraction, offset_str = fraction_match.groups(default="") + # datetime supports microseconds; truncate any finer precision. + dt_ = dt_.replace(microsecond=int(fraction[:6].ljust(6, "0"))) if len(offset_str) == 6: dt_ = cls._offset_dt(dt_, offset_str) return dt_.replace(tzinfo=dt.timezone.utc) diff --git a/tests/opc/test_coreprops.py b/tests/opc/test_coreprops.py index 5d9743397..41f2f6cd6 100644 --- a/tests/opc/test_coreprops.py +++ b/tests/opc/test_coreprops.py @@ -79,6 +79,60 @@ def it_knows_the_date_property_values( actual_datetime = getattr(core_properties, prop_name) assert actual_datetime == expected_datetime + @pytest.mark.parametrize( + ("prop_name", "tagname"), + [ + ("created", "dcterms:created"), + ("modified", "dcterms:modified"), + ("last_printed", "cp:lastPrinted"), + ], + ) + @pytest.mark.parametrize( + ("str_val", "expected_datetime"), + [ + ("1997", dt.datetime(1997, 1, 1, tzinfo=dt.timezone.utc)), + ("1997-07", dt.datetime(1997, 7, 1, tzinfo=dt.timezone.utc)), + ("1997-07-16", dt.datetime(1997, 7, 16, tzinfo=dt.timezone.utc)), + ("1997-07-16T19:20:30Z", dt.datetime(1997, 7, 16, 19, 20, 30, tzinfo=dt.timezone.utc)), + ( + "1997-07-16T19:20:30+01:00", + dt.datetime(1997, 7, 16, 18, 20, 30, tzinfo=dt.timezone.utc), + ), + ( + "1997-07-16T19:20:30.45+01:00", + dt.datetime(1997, 7, 16, 18, 20, 30, 450000, tzinfo=dt.timezone.utc), + ), + ( + "1997-07-16T19:20:30.1234Z", + dt.datetime(1997, 7, 16, 19, 20, 30, 123400, tzinfo=dt.timezone.utc), + ), + ( + "1997-07-16T23:45:30.987654-05:30", + dt.datetime(1997, 7, 17, 5, 15, 30, 987654, tzinfo=dt.timezone.utc), + ), + ( + "1997-01-01T00:15:30.1+05:45", + dt.datetime(1996, 12, 31, 18, 30, 30, 100000, tzinfo=dt.timezone.utc), + ), + ( + "1997-07-16T19:20:30.123456789Z", + dt.datetime(1997, 7, 16, 19, 20, 30, 123456, tzinfo=dt.timezone.utc), + ), + ( + "1997-07-16T19:20:30.0Z", + dt.datetime(1997, 7, 16, 19, 20, 30, tzinfo=dt.timezone.utc), + ), + ("not a date", None), + ], + ) + def it_reads_date_properties_with_fractional_seconds( + self, prop_name: str, tagname: str, str_val: str, expected_datetime: dt.datetime | None + ): + xml = self.coreProperties(tagname, str_val) + core_properties = CoreProperties(cast("CT_CoreProperties", parse_xml(xml))) + + assert getattr(core_properties, prop_name) == expected_datetime + @pytest.mark.parametrize( ("prop_name", "tagname", "value", "str_val", "attrs"), [