diff --git a/sdk/basyx/aas/model/datatypes.py b/sdk/basyx/aas/model/datatypes.py index e33b3e0b..6bd72f39 100644 --- a/sdk/basyx/aas/model/datatypes.py +++ b/sdk/basyx/aas/model/datatypes.py @@ -612,21 +612,42 @@ def _parse_xsd_date(value: str) -> Date: def _parse_xsd_datetime(value: str) -> DateTime: match = DATETIME_RE.match(value) if not match: - raise ValueError("Value is not a valid XSD datetime string") + raise ValueError(f"{value} is not a valid XSD datetime string") if match[1]: raise NotImplementedError("Negative dates are not supported: Python stdlib datetime requires year >= 1. " "Report at https://github.com/eclipse-basyx/basyx-python-sdk/issues") microseconds = int(float(match[8]) * 1e6) if match[8] else 0 - return DateTime(int(match[2]), int(match[3]), int(match[4]), int(match[5]), int(match[6]), int(match[7]), - microseconds, _parse_xsd_date_tzinfo(match[9])) + hour = int(match[5]) + # xsd_datetime allows for hour=24 to represent midnight, + # Python's datetime.DateTime doesn't. + # If we get an hour=24, we accept and parse it as hour=0 of the next day. + # See: https://github.com/eclipse-basys/basys-python-sdk/issues/564 + is_midnight_24 = False + if hour == 24: + if int(match[6]) != 0 or int(match[7]) != 0 or microseconds != 0: + raise ValueError(f"{value} is not a valid xsd:datetime.") + hour = 0 + is_midnight_24 = True + res = DateTime(int(match[2]), int(match[3]), int(match[4]), hour, int(match[6]), int(match[7]), + microseconds, _parse_xsd_date_tzinfo(match[9])) + return res + datetime.timedelta(days=1) if is_midnight_24 else res def _parse_xsd_time(value: str) -> Time: match = TIME_RE.match(value) if not match: - raise ValueError("Value is not a valid XSD datetime string") + raise ValueError(f"{value} is not a valid XSD time string") microseconds = int(float(match[4]) * 1e6) if match[4] else 0 - return Time(int(match[1]), int(match[2]), int(match[3]), microseconds, _parse_xsd_date_tzinfo(match[5])) + hour = int(match[1]) + # xsd_time allows for hour=24 to represent midnight, + # Python's datetime.Time doesn't. + # If we get an hour=24, we accept and parse it as hour=0. + # See: https://github.com/eclipse-basys/basys-python-sdk/issues/564 + if hour == 24: + if int(match[2]) != 0 or int(match[3]) != 0 or microseconds != 0: + raise ValueError(f"{value} is not a valid xsd:time.") + hour = 0 + return Time(hour, int(match[2]), int(match[3]), microseconds, _parse_xsd_date_tzinfo(match[5])) def _parse_xsd_bool(value: str) -> Boolean: diff --git a/sdk/test/model/test_datatypes.py b/sdk/test/model/test_datatypes.py index f314d302..0b754111 100644 --- a/sdk/test/model/test_datatypes.py +++ b/sdk/test/model/test_datatypes.py @@ -212,7 +212,7 @@ def test_parse_datetime(self) -> None: model.datatypes.from_xsd("2020-01-24T15:25:17-00:20", model.datatypes.DateTime)) with self.assertRaises(ValueError) as cm: model.datatypes.from_xsd("--2020-01-24T15:25:17-00:20", model.datatypes.DateTime) - self.assertEqual("Value is not a valid XSD datetime string", str(cm.exception)) + self.assertEqual("--2020-01-24T15:25:17-00:20 is not a valid XSD datetime string", str(cm.exception)) with self.assertRaises(NotImplementedError): model.datatypes.from_xsd("-2020-01-24T15:25:17+01:00", model.datatypes.DateTime) @@ -248,6 +248,31 @@ def test_serialize_time(self) -> None: self.assertEqual("15:25:17.250000+01:00", model.datatypes.xsd_repr( datetime.time(15, 25, 17, 250000, tzinfo=datetime.timezone(datetime.timedelta(hours=1))))) + def test_parse_datetime_midnight_24(self) -> None: + res = model.datatypes.from_xsd("2020-01-24T24:00:00", model.datatypes.DateTime) + self.assertEqual(datetime.datetime(2020, 1, 25, 0, 0, 0), res) + res_tz = model.datatypes.from_xsd("2020-01-24T24:00:00Z", model.datatypes.DateTime) + self.assertEqual(datetime.datetime(2020, 1, 25, 0, 0, 0, tzinfo=datetime.timezone.utc), res_tz) + + def test_parse_datetime_midnight_24_invalid(self) -> None: + with self.assertRaises(ValueError) as cm: + model.datatypes.from_xsd("2020-01-24T24:01:00", model.datatypes.DateTime) + self.assertEqual( + "2020-01-24T24:01:00 is not a valid xsd:datetime.", + str(cm.exception)) + + def test_parse_time_midnight_24(self) -> None: + res = model.datatypes.from_xsd("24:00:00", model.datatypes.Time) + self.assertEqual(datetime.time(0, 0, 0), res) + + def test_parse_time_midnight_24_invalid(self) -> None: + with self.assertRaises(ValueError) as cm: + model.datatypes.from_xsd("24:00:01", model.datatypes.Time) + self.assertEqual( + "24:00:01 is not a valid xsd:time.", + str(cm.exception) + ) + def test_trivial_cast(self) -> None: val = model.datatypes.trivial_cast(datetime.date(2017, 11, 13), model.datatypes.Date) self.assertEqual(model.datatypes.Date(2017, 11, 13), val)