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)