From 2c6e563591a3689818daee6a08557d7f901239ea Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Mon, 6 Jul 2026 15:00:17 +0200 Subject: [PATCH 1/3] model.datatypes: Fix xs:gYear and xs:gYearMonth rejecting valid XSD year values Previously, `_parse_xsd_gyear` and `_parse_xsd_gyearmonth` in `datatypes.py` reject values with more than 4 digits or negative signs. Now, the regexes support an optional minus sign and 4+ digits, and the parsers correctly handle the sign from `match[1]`. Unit tests were added. Fixes #566 --- sdk/basyx/aas/model/datatypes.py | 14 ++++++++++---- sdk/test/model/test_datatypes.py | 8 ++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/sdk/basyx/aas/model/datatypes.py b/sdk/basyx/aas/model/datatypes.py index e33b3e0b..2331637b 100644 --- a/sdk/basyx/aas/model/datatypes.py +++ b/sdk/basyx/aas/model/datatypes.py @@ -638,10 +638,10 @@ def _parse_xsd_bool(value: str) -> Boolean: raise ValueError("Invalid literal for XSD bool type") -GYEAR_RE = re.compile(r'^(\d\d\d\d)([+\-]\d\d:\d\d|Z)?$') +GYEAR_RE = re.compile(r'^(-?)(\d{4,})([+\-]\d\d:\d\d|Z)?$') GMONTH_RE = re.compile(r'^--(\d\d)([+\-]\d\d:\d\d|Z)?$') GDAY_RE = re.compile(r'^---(\d\d)([+\-]\d\d:\d\d|Z)?$') -GYEARMONTH_RE = re.compile(r'^(\d\d\d\d)-(\d\d)([+\-]\d\d:\d\d|Z)?$') +GYEARMONTH_RE = re.compile(r'^(-?)(\d{4,})-(\d\d)([+\-]\d\d:\d\d|Z)?$') GMONTHDAY_RE = re.compile(r'^--(\d\d)-(\d\d)([+\-]\d\d:\d\d|Z)?$') @@ -649,7 +649,10 @@ def _parse_xsd_gyear(value: str) -> GYear: match = GYEAR_RE.match(value) if not match: raise ValueError("Value is not a valid XSD GYear string") - return GYear(int(match[1]), _parse_xsd_date_tzinfo(match[2])) + year = int(match[2]) + if match[1]: + year = -year + return GYear(year, _parse_xsd_date_tzinfo(match[3])) def _parse_xsd_gmonth(value: str) -> GMonth: @@ -670,7 +673,10 @@ def _parse_xsd_gyearmonth(value: str) -> GYearMonth: match = GYEARMONTH_RE.match(value) if not match: raise ValueError("Value is not a valid XSD GYearMonth string") - return GYearMonth(int(match[1]), int(match[2]), _parse_xsd_date_tzinfo(match[3])) + year = int(match[2]) + if match[1]: + year = -year + return GYearMonth(year, int(match[3]), _parse_xsd_date_tzinfo(match[4])) def _parse_xsd_gmonthday(value: str) -> GMonthDay: diff --git a/sdk/test/model/test_datatypes.py b/sdk/test/model/test_datatypes.py index f314d302..3da6750b 100644 --- a/sdk/test/model/test_datatypes.py +++ b/sdk/test/model/test_datatypes.py @@ -156,10 +156,18 @@ def test_serialize_date(self) -> None: def test_parse_partial_dates(self) -> None: self.assertEqual(model.datatypes.GYear(2019), model.datatypes.from_xsd("2019", model.datatypes.GYear)) + self.assertEqual(model.datatypes.GYear(-2001), + model.datatypes.from_xsd("-2001", model.datatypes.GYear)) + self.assertEqual(model.datatypes.GYear(20000), + model.datatypes.from_xsd("20000", model.datatypes.GYear)) self.assertEqual(model.datatypes.GMonth(7), model.datatypes.from_xsd("--07", model.datatypes.GMonth)) self.assertEqual(model.datatypes.GYearMonth(2020, 5), model.datatypes.from_xsd("2020-05", model.datatypes.GYearMonth)) + self.assertEqual(model.datatypes.GYearMonth(-2001, 10), + model.datatypes.from_xsd("-2001-10", model.datatypes.GYearMonth)) + self.assertEqual(model.datatypes.GYearMonth(20000, 5), + model.datatypes.from_xsd("20000-05", model.datatypes.GYearMonth)) self.assertEqual(model.datatypes.GMonthDay(12, 6), model.datatypes.from_xsd("--12-06", model.datatypes.GMonthDay)) self.assertEqual(model.datatypes.GDay(23), From 7de9a9ce8318825f8554ce7ffc08745ab180d555 Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Wed, 15 Jul 2026 09:28:56 +0200 Subject: [PATCH 2/3] raise ValueError with clear message for negative years in G-Classes and fix bug in GMonthDay --- sdk/basyx/aas/model/datatypes.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/sdk/basyx/aas/model/datatypes.py b/sdk/basyx/aas/model/datatypes.py index 2331637b..c5025867 100644 --- a/sdk/basyx/aas/model/datatypes.py +++ b/sdk/basyx/aas/model/datatypes.py @@ -110,8 +110,13 @@ def into_date(self, day: int = 1) -> Date: @classmethod def from_date(cls, date: datetime.date) -> "GYearMonth": - tzinfo = date.tzinfo if hasattr(date, 'tzinfo') else None # type: ignore - return cls(date.year, date.month, tzinfo) + try: + tzinfo = date.tzinfo if hasattr(date, 'tzinfo') else None # type: ignore + return cls(date.year, date.month, tzinfo) + except ValueError as e: + if date.year < 0: + raise ValueError("Negative years are not supported by Python's `datetime` library.") from e + raise e def __eq__(self, other: object) -> bool: if not isinstance(other, GYearMonth): @@ -135,8 +140,13 @@ def into_date(self, month: int = 1, day: int = 1) -> Date: @classmethod def from_date(cls, date: datetime.date) -> "GYear": - tzinfo = date.tzinfo if hasattr(date, 'tzinfo') else None # type: ignore - return cls(date.year, tzinfo) + try: + tzinfo = date.tzinfo if hasattr(date, 'tzinfo') else None # type: ignore + return cls(date.year, tzinfo) + except ValueError as e: + if date.year < 0: + raise ValueError("Negative years are not supported by Python's `datetime` library.") from e + raise e def __eq__(self, other: object) -> bool: if not isinstance(other, GYear): @@ -166,7 +176,7 @@ def into_date(self, year: int = 1970) -> Date: @classmethod def from_date(cls, date: datetime.date) -> "GMonthDay": tzinfo = date.tzinfo if hasattr(date, 'tzinfo') else None # type: ignore - return cls(date.month, date.year, tzinfo) + return cls(date.month, date.day, tzinfo) def __eq__(self, other: object) -> bool: if not isinstance(other, GMonthDay): From 5ae63e36ca973ba4e3382c53f675c5a3a05d0737 Mon Sep 17 00:00:00 2001 From: s-heppner Date: Wed, 15 Jul 2026 10:07:31 +0200 Subject: [PATCH 3/3] model.datatypes: Raise clear error for negative years in `into_date` The previous commit added a clear error message for negative years, but placed it in `GYear.from_date` and `GYearMonth.from_date`. That branch is unreachable: a Python `datetime.date` can never hold a negative year, so `from_date` never receives one, and the G-Class constructors accept negative years without raising. Negative years actually fail in the opposite direction, in `into_date`, which builds a `Date` and hits Python's cryptic "year -2001 is out of range" error. This change moves the clear error message from `from_date` to `into_date` on `GYear` and `GYearMonth`, and adds a test covering the negative-year case. --- sdk/basyx/aas/model/datatypes.py | 28 ++++++++++++++-------------- sdk/test/model/test_datatypes.py | 8 ++++++++ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/sdk/basyx/aas/model/datatypes.py b/sdk/basyx/aas/model/datatypes.py index c5025867..351981c0 100644 --- a/sdk/basyx/aas/model/datatypes.py +++ b/sdk/basyx/aas/model/datatypes.py @@ -106,18 +106,18 @@ def __init__(self, year: int, month: int, tzinfo: Optional[datetime.tzinfo] = No self.tzinfo: Optional[datetime.tzinfo] = tzinfo def into_date(self, day: int = 1) -> Date: - return Date(self.year, self.month, day, self.tzinfo) - - @classmethod - def from_date(cls, date: datetime.date) -> "GYearMonth": try: - tzinfo = date.tzinfo if hasattr(date, 'tzinfo') else None # type: ignore - return cls(date.year, date.month, tzinfo) + return Date(self.year, self.month, day, self.tzinfo) except ValueError as e: - if date.year < 0: + if self.year < 0: raise ValueError("Negative years are not supported by Python's `datetime` library.") from e raise e + @classmethod + def from_date(cls, date: datetime.date) -> "GYearMonth": + tzinfo = date.tzinfo if hasattr(date, 'tzinfo') else None # type: ignore + return cls(date.year, date.month, tzinfo) + def __eq__(self, other: object) -> bool: if not isinstance(other, GYearMonth): return NotImplemented @@ -136,18 +136,18 @@ def __init__(self, year: int, tzinfo: Optional[datetime.tzinfo] = None): self.tzinfo: Optional[datetime.tzinfo] = tzinfo def into_date(self, month: int = 1, day: int = 1) -> Date: - return Date(self.year, month, day, self.tzinfo) - - @classmethod - def from_date(cls, date: datetime.date) -> "GYear": try: - tzinfo = date.tzinfo if hasattr(date, 'tzinfo') else None # type: ignore - return cls(date.year, tzinfo) + return Date(self.year, month, day, self.tzinfo) except ValueError as e: - if date.year < 0: + if self.year < 0: raise ValueError("Negative years are not supported by Python's `datetime` library.") from e raise e + @classmethod + def from_date(cls, date: datetime.date) -> "GYear": + tzinfo = date.tzinfo if hasattr(date, 'tzinfo') else None # type: ignore + return cls(date.year, tzinfo) + def __eq__(self, other: object) -> bool: if not isinstance(other, GYear): return NotImplemented diff --git a/sdk/test/model/test_datatypes.py b/sdk/test/model/test_datatypes.py index 3da6750b..c8126400 100644 --- a/sdk/test/model/test_datatypes.py +++ b/sdk/test/model/test_datatypes.py @@ -188,6 +188,14 @@ def test_parse_partial_dates(self) -> None: model.datatypes.from_xsd("10-10", model.datatypes.GYearMonth) self.assertEqual("Value is not a valid XSD GYearMonth string", str(cm.exception)) + def test_partial_dates_negative_year_into_date(self) -> None: + # Python's `datetime` library does not support negative years. Converting a G-Class with a negative year into a + # `Date` must therefore fail with a clear error message instead of the cryptic "year -2001 is out of range". + for value in (model.datatypes.GYear(-2001), model.datatypes.GYearMonth(-2001, 5)): + with self.assertRaises(ValueError) as cm: + value.into_date() + self.assertEqual("Negative years are not supported by Python's `datetime` library.", str(cm.exception)) + def test_copy_date(self) -> None: date = model.datatypes.Date(2020, 1, 24) date_copy_shallow = copy.copy(date)