From 28d33f845208e541a2c0b0c72c057b2b94658775 Mon Sep 17 00:00:00 2001 From: Vincent Koppen Date: Fri, 26 Jun 2026 12:37:28 +0200 Subject: [PATCH 01/11] fix: support for numpy v2.5 Signed-off-by: Vincent Koppen --- .../_core/model/arrays/base/array.py | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/power_grid_model_ds/_core/model/arrays/base/array.py b/src/power_grid_model_ds/_core/model/arrays/base/array.py index d22cafb0..d10e5612 100644 --- a/src/power_grid_model_ds/_core/model/arrays/base/array.py +++ b/src/power_grid_model_ds/_core/model/arrays/base/array.py @@ -7,10 +7,11 @@ from collections.abc import Iterable from copy import copy from functools import lru_cache -from typing import Any, ClassVar, Literal, TypeVar, overload +from typing import Any, ClassVar, Literal, TypeVar, get_args, get_origin, overload import numpy as np from numpy.typing import ArrayLike, NDArray +from packaging import version from power_grid_model_ds._core.model.arrays.base._build import build_array from power_grid_model_ds._core.model.arrays.base._filters import apply_exclude, apply_filter, apply_get, get_filter_mask @@ -103,17 +104,26 @@ def get_dtype(cls): annotations = get_public_annotations(cls) str_lengths = combine_attribute_from_parent_classes(cls, "_str_lengths", dict) dtypes = {} + + # Numpy 2.5 changed the typing interface, so we need to treat these differently + is_before_numpy_25 = version.parse(np.__version__) < version.parse("2.5.0") + for name, dtype in annotations.items(): - if len(dtype.__args__) > 1: - # regular numpy dtype (i.e. without shape) - dtypes[name] = dtype.__args__[1].__args__[0] - elif hasattr(dtype, "__metadata__"): - # metadata annotation contains shape - # define dtype using a (type, shape) tuple - # see: #1 in https://numpy.org/doc/stable/user/basics.rec.html#structured-datatype-creation - dtype_type = dtype.__args__[0].__args__[1].__args__[0] - dtype_shape = dtype.__metadata__[0].__args__ - dtypes[name] = (dtype_type, dtype_shape) + dtype_args = get_args(dtype) + + # Expected dtype_args pre-2.5 for NDArray[]: (tuple[typing.Any, ...], numpy.dtype[numpy.int32]) + if is_before_numpy_25 and len(dtype_args) == 2 and get_origin(dtype_args[1]) is np.dtype: # noqa: PLR2004 + dtypes[name] = get_args(dtype_args[1])[0] + # Expected dtype_args pre-2.5 for NDArray3[]: + # (numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.float64]], typing.Literal[3]) + elif is_before_numpy_25 and len(dtype_args) == 2 and get_origin(dtype_args[1]) is Literal: # noqa: PLR2004 + dtypes[name] = (get_args(get_args(dtype_args[0])[1])[0], get_args(dtype_args[1])[0]) + # Expected dtype_args post-2.5 for NDArray: (numpy.int32,) + elif len(dtype_args) == 1: + dtypes[name] = dtype_args[0] + # Expected dtype_args post-2.5 for NDArray3: (NDArray[numpy.float64], typing.Literal[3]) + elif len(dtype_args) == 2: # noqa: PLR2004 + dtypes[name] = (get_args(dtype_args[0])[0], get_args(dtype_args[1])[0]) else: raise ValueError(f"dtype {dtype} not understood or supported") From b398f93eecee6044c6db84bfcd59e1dd615f2082 Mon Sep 17 00:00:00 2001 From: Vincent Koppen Date: Fri, 26 Jun 2026 12:52:05 +0200 Subject: [PATCH 02/11] misc Signed-off-by: Vincent Koppen --- .../_core/model/arrays/base/array.py | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/power_grid_model_ds/_core/model/arrays/base/array.py b/src/power_grid_model_ds/_core/model/arrays/base/array.py index d10e5612..58a1f329 100644 --- a/src/power_grid_model_ds/_core/model/arrays/base/array.py +++ b/src/power_grid_model_ds/_core/model/arrays/base/array.py @@ -100,7 +100,7 @@ def get_id_columns(cls) -> set[str]: @classmethod @lru_cache - def get_dtype(cls): + def get_dtype(cls): # noqa: python:S3776 annotations = get_public_annotations(cls) str_lengths = combine_attribute_from_parent_classes(cls, "_str_lengths", dict) dtypes = {} @@ -108,24 +108,24 @@ def get_dtype(cls): # Numpy 2.5 changed the typing interface, so we need to treat these differently is_before_numpy_25 = version.parse(np.__version__) < version.parse("2.5.0") - for name, dtype in annotations.items(): - dtype_args = get_args(dtype) + for name, type_def in annotations.items(): + type_args = get_args(type_def) - # Expected dtype_args pre-2.5 for NDArray[]: (tuple[typing.Any, ...], numpy.dtype[numpy.int32]) - if is_before_numpy_25 and len(dtype_args) == 2 and get_origin(dtype_args[1]) is np.dtype: # noqa: PLR2004 - dtypes[name] = get_args(dtype_args[1])[0] - # Expected dtype_args pre-2.5 for NDArray3[]: + # Expected type_args pre-2.5 for NDArray[]: (tuple[typing.Any, ...], numpy.dtype[numpy.int32]) + if is_before_numpy_25 and len(type_args) == 2 and get_origin(type_args[1]) is np.dtype: # noqa: PLR2004 + dtypes[name] = get_args(type_args[1])[0] + # Expected type_args pre-2.5 for NDArray3[]: # (numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.float64]], typing.Literal[3]) - elif is_before_numpy_25 and len(dtype_args) == 2 and get_origin(dtype_args[1]) is Literal: # noqa: PLR2004 - dtypes[name] = (get_args(get_args(dtype_args[0])[1])[0], get_args(dtype_args[1])[0]) - # Expected dtype_args post-2.5 for NDArray: (numpy.int32,) - elif len(dtype_args) == 1: - dtypes[name] = dtype_args[0] - # Expected dtype_args post-2.5 for NDArray3: (NDArray[numpy.float64], typing.Literal[3]) - elif len(dtype_args) == 2: # noqa: PLR2004 - dtypes[name] = (get_args(dtype_args[0])[0], get_args(dtype_args[1])[0]) + elif is_before_numpy_25 and len(type_args) == 2 and get_origin(type_args[1]) is Literal: # noqa: PLR2004 + dtypes[name] = (get_args(get_args(type_args[0])[1])[0], get_args(type_args[1])[0]) + # Expected type_args post-2.5 for NDArray: (numpy.int32,) + elif len(type_args) == 1: + dtypes[name] = type_args[0] + # Expected type_args post-2.5 for NDArray3: (NDArray[numpy.float64], typing.Literal[3]) + elif len(type_args) == 2: # noqa: PLR2004 + dtypes[name] = (get_args(type_args[0])[0], get_args(type_args[1])[0]) else: - raise ValueError(f"dtype {dtype} not understood or supported") + raise ValueError(f"dtype {type_def} not understood or supported") if not dtypes: raise ArrayDefinitionError("Array has no defined Columns") @@ -133,14 +133,14 @@ def get_dtype(cls): raise ArrayDefinitionError(f"Columns cannot be reserved names: {reserved}") dtype_list = [] - for name, dtype in dtypes.items(): - if dtype is np.str_: + for name, type_def in dtypes.items(): + if type_def is np.str_: string_length = str_lengths.get(name, _DEFAULT_STR_LENGTH) dtype_list.append((name, np.dtype(f"U{string_length}"))) - elif dtype is tuple: - dtype_list.append((name, *dtype)) + elif type_def is tuple: + dtype_list.append((name, *type_def)) else: - dtype_list.append((name, dtype)) + dtype_list.append((name, type_def)) return np.dtype(dtype_list) def __repr__(self) -> str: From 32f1a7d7b4bd0b83d99224b18a557c1063828659 Mon Sep 17 00:00:00 2001 From: Vincent Koppen Date: Fri, 26 Jun 2026 12:56:42 +0200 Subject: [PATCH 03/11] try to make sonar happy Signed-off-by: Vincent Koppen --- src/power_grid_model_ds/_core/model/arrays/base/array.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/power_grid_model_ds/_core/model/arrays/base/array.py b/src/power_grid_model_ds/_core/model/arrays/base/array.py index 58a1f329..570c7aa8 100644 --- a/src/power_grid_model_ds/_core/model/arrays/base/array.py +++ b/src/power_grid_model_ds/_core/model/arrays/base/array.py @@ -119,10 +119,10 @@ def get_dtype(cls): # noqa: python:S3776 elif is_before_numpy_25 and len(type_args) == 2 and get_origin(type_args[1]) is Literal: # noqa: PLR2004 dtypes[name] = (get_args(get_args(type_args[0])[1])[0], get_args(type_args[1])[0]) # Expected type_args post-2.5 for NDArray: (numpy.int32,) - elif len(type_args) == 1: + elif len(type_args) == 1: # pragma: no cover dtypes[name] = type_args[0] # Expected type_args post-2.5 for NDArray3: (NDArray[numpy.float64], typing.Literal[3]) - elif len(type_args) == 2: # noqa: PLR2004 + elif len(type_args) == 2: # noqa: PLR2004 # pragma: no cover dtypes[name] = (get_args(type_args[0])[0], get_args(type_args[1])[0]) else: raise ValueError(f"dtype {type_def} not understood or supported") From 3480f43274bb2d9edcbb5f27b65cf831411a16a9 Mon Sep 17 00:00:00 2001 From: Vincent Koppen Date: Fri, 26 Jun 2026 13:08:31 +0200 Subject: [PATCH 04/11] formatting Signed-off-by: Vincent Koppen --- src/power_grid_model_ds/_core/model/arrays/base/array.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/power_grid_model_ds/_core/model/arrays/base/array.py b/src/power_grid_model_ds/_core/model/arrays/base/array.py index 570c7aa8..b9205ad4 100644 --- a/src/power_grid_model_ds/_core/model/arrays/base/array.py +++ b/src/power_grid_model_ds/_core/model/arrays/base/array.py @@ -100,7 +100,7 @@ def get_id_columns(cls) -> set[str]: @classmethod @lru_cache - def get_dtype(cls): # noqa: python:S3776 + def get_dtype(cls): # noqa: python:S3776 annotations = get_public_annotations(cls) str_lengths = combine_attribute_from_parent_classes(cls, "_str_lengths", dict) dtypes = {} @@ -119,7 +119,7 @@ def get_dtype(cls): # noqa: python:S3776 elif is_before_numpy_25 and len(type_args) == 2 and get_origin(type_args[1]) is Literal: # noqa: PLR2004 dtypes[name] = (get_args(get_args(type_args[0])[1])[0], get_args(type_args[1])[0]) # Expected type_args post-2.5 for NDArray: (numpy.int32,) - elif len(type_args) == 1: # pragma: no cover + elif len(type_args) == 1: # pragma: no cover dtypes[name] = type_args[0] # Expected type_args post-2.5 for NDArray3: (NDArray[numpy.float64], typing.Literal[3]) elif len(type_args) == 2: # noqa: PLR2004 # pragma: no cover From 2295598af661b038b8647db75fb04329f9a9b00d Mon Sep 17 00:00:00 2001 From: Vincent Koppen Date: Fri, 26 Jun 2026 13:27:20 +0200 Subject: [PATCH 05/11] misc Signed-off-by: Vincent Koppen --- .../_core/model/arrays/base/array.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/power_grid_model_ds/_core/model/arrays/base/array.py b/src/power_grid_model_ds/_core/model/arrays/base/array.py index b9205ad4..9c309828 100644 --- a/src/power_grid_model_ds/_core/model/arrays/base/array.py +++ b/src/power_grid_model_ds/_core/model/arrays/base/array.py @@ -133,14 +133,14 @@ def get_dtype(cls): # noqa: python:S3776 raise ArrayDefinitionError(f"Columns cannot be reserved names: {reserved}") dtype_list = [] - for name, type_def in dtypes.items(): - if type_def is np.str_: + for name, dtype in dtypes.items(): + if dtype is np.str_: string_length = str_lengths.get(name, _DEFAULT_STR_LENGTH) dtype_list.append((name, np.dtype(f"U{string_length}"))) - elif type_def is tuple: - dtype_list.append((name, *type_def)) + elif dtype is tuple: + dtype_list.append((name, *dtype)) else: - dtype_list.append((name, type_def)) + dtype_list.append((name, dtype)) return np.dtype(dtype_list) def __repr__(self) -> str: From c601dcfd88eb8c4b41e428bdbf575d66133c9440 Mon Sep 17 00:00:00 2001 From: Vincent Koppen Date: Fri, 26 Jun 2026 13:37:10 +0200 Subject: [PATCH 06/11] cleanup Signed-off-by: Vincent Koppen --- .../_core/model/arrays/base/array.py | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/power_grid_model_ds/_core/model/arrays/base/array.py b/src/power_grid_model_ds/_core/model/arrays/base/array.py index 9c309828..2ca01c84 100644 --- a/src/power_grid_model_ds/_core/model/arrays/base/array.py +++ b/src/power_grid_model_ds/_core/model/arrays/base/array.py @@ -100,10 +100,17 @@ def get_id_columns(cls) -> set[str]: @classmethod @lru_cache - def get_dtype(cls): # noqa: python:S3776 + def get_dtype(cls): annotations = get_public_annotations(cls) + + if not annotations.keys(): + raise ArrayDefinitionError("Array has no defined Columns") + + if reserved := set(annotations.keys()) & _RESERVED_COLUMN_NAMES: + raise ArrayDefinitionError(f"Columns cannot be reserved names: {reserved}") + str_lengths = combine_attribute_from_parent_classes(cls, "_str_lengths", dict) - dtypes = {} + dtype_list = [] # Numpy 2.5 changed the typing interface, so we need to treat these differently is_before_numpy_25 = version.parse(np.__version__) < version.parse("2.5.0") @@ -113,34 +120,26 @@ def get_dtype(cls): # noqa: python:S3776 # Expected type_args pre-2.5 for NDArray[]: (tuple[typing.Any, ...], numpy.dtype[numpy.int32]) if is_before_numpy_25 and len(type_args) == 2 and get_origin(type_args[1]) is np.dtype: # noqa: PLR2004 - dtypes[name] = get_args(type_args[1])[0] + dtype_list.append((name, get_args(type_args[1])[0])) # Expected type_args pre-2.5 for NDArray3[]: # (numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.float64]], typing.Literal[3]) elif is_before_numpy_25 and len(type_args) == 2 and get_origin(type_args[1]) is Literal: # noqa: PLR2004 - dtypes[name] = (get_args(get_args(type_args[0])[1])[0], get_args(type_args[1])[0]) + dtype_list.append((name, get_args(get_args(type_args[0])[1])[0], get_args(type_args[1])[0])) # Expected type_args post-2.5 for NDArray: (numpy.int32,) elif len(type_args) == 1: # pragma: no cover - dtypes[name] = type_args[0] + dtype_list.append((name, type_args[0])) # Expected type_args post-2.5 for NDArray3: (NDArray[numpy.float64], typing.Literal[3]) elif len(type_args) == 2: # noqa: PLR2004 # pragma: no cover - dtypes[name] = (get_args(type_args[0])[0], get_args(type_args[1])[0]) + dtype_list.append((name, get_args(type_args[0])[0], get_args(type_args[1])[0])) else: raise ValueError(f"dtype {type_def} not understood or supported") - if not dtypes: - raise ArrayDefinitionError("Array has no defined Columns") - if reserved := set(dtypes.keys()) & _RESERVED_COLUMN_NAMES: - raise ArrayDefinitionError(f"Columns cannot be reserved names: {reserved}") + # Change the np.str_ types to include a max length: + dtype_list = [ + (name, np.dtype(f"U{str_lengths.get(name, _DEFAULT_STR_LENGTH)}") if dtype is np.str_ else dtype, *rest) + for (name, dtype, *rest) in dtype_list + ] - dtype_list = [] - for name, dtype in dtypes.items(): - if dtype is np.str_: - string_length = str_lengths.get(name, _DEFAULT_STR_LENGTH) - dtype_list.append((name, np.dtype(f"U{string_length}"))) - elif dtype is tuple: - dtype_list.append((name, *dtype)) - else: - dtype_list.append((name, dtype)) return np.dtype(dtype_list) def __repr__(self) -> str: From 90f67ec59855873f303748cd71160624225e50b2 Mon Sep 17 00:00:00 2001 From: Vincent Koppen Date: Fri, 26 Jun 2026 13:43:49 +0200 Subject: [PATCH 07/11] format Signed-off-by: Vincent Koppen --- src/power_grid_model_ds/_core/model/arrays/base/array.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/power_grid_model_ds/_core/model/arrays/base/array.py b/src/power_grid_model_ds/_core/model/arrays/base/array.py index 2ca01c84..8f74a21e 100644 --- a/src/power_grid_model_ds/_core/model/arrays/base/array.py +++ b/src/power_grid_model_ds/_core/model/arrays/base/array.py @@ -136,7 +136,11 @@ def get_dtype(cls): # Change the np.str_ types to include a max length: dtype_list = [ - (name, np.dtype(f"U{str_lengths.get(name, _DEFAULT_STR_LENGTH)}") if dtype is np.str_ else dtype, *rest) + ( + name, + np.dtype(f"U{str_lengths.get(name, _DEFAULT_STR_LENGTH)}") if dtype is np.str_ else dtype, + *rest, + ) for (name, dtype, *rest) in dtype_list ] From 48dc81c87e1c908eac2381dbc5ef273d27bcddb4 Mon Sep 17 00:00:00 2001 From: jaapschoutenalliander Date: Mon, 13 Jul 2026 13:01:01 +0200 Subject: [PATCH 08/11] chore: split into subfunctions Signed-off-by: jaapschoutenalliander --- .../_core/model/arrays/base/array.py | 83 ++++++++++++------- tests/unit/model/arrays/test_get_dtype.py | 64 ++++++++++++++ 2 files changed, 116 insertions(+), 31 deletions(-) create mode 100644 tests/unit/model/arrays/test_get_dtype.py diff --git a/src/power_grid_model_ds/_core/model/arrays/base/array.py b/src/power_grid_model_ds/_core/model/arrays/base/array.py index 8f74a21e..10f1f324 100644 --- a/src/power_grid_model_ds/_core/model/arrays/base/array.py +++ b/src/power_grid_model_ds/_core/model/arrays/base/array.py @@ -31,12 +31,51 @@ _RESERVED_COLUMN_NAMES: set = set(dir(np.array([]))).union({"data"}) _DEFAULT_STR_LENGTH: int = 50 _MAX_DATA_SIZE: int = 3 +_NDARRAY_TYPE_ARGS: int = 2 Column = NDArray Self = TypeVar("Self", bound="FancyArray") +def _resolve_str_dtype(name: str, dtype: Any, str_lengths: dict[str, int]) -> Any: + """Replace np.str_ with a fixed-length unicode dtype, leaving other dtypes untouched.""" + if dtype is np.str_: + return np.dtype(f"U{str_lengths.get(name, _DEFAULT_STR_LENGTH)}") + return dtype + + +def _parse_annotation_pre_25(name: str, type_def: Any, type_args: tuple, str_lengths: dict[str, int]) -> tuple: + """Parse an NDArray annotation into a numpy dtype tuple for NumPy < 2.5.""" + # Expected type_args for NDArray[]: (tuple[typing.Any, ...], numpy.dtype[numpy.int32]) + if len(type_args) == _NDARRAY_TYPE_ARGS and get_origin(type_args[1]) is np.dtype: + dtype = get_args(type_args[1])[0] + return (name, _resolve_str_dtype(name, dtype, str_lengths)) + # Expected type_args for NDArray3[]: + # (numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.float64]], typing.Literal[3]) + if len(type_args) == _NDARRAY_TYPE_ARGS and get_origin(type_args[1]) is Literal: + try: + dtype = get_args(get_args(type_args[0])[1])[0] + size = get_args(type_args[1])[0] + except IndexError: + raise ValueError(f"dtype {type_def} not understood or supported") from None + return (name, _resolve_str_dtype(name, dtype, str_lengths), size) + raise ValueError(f"dtype {type_def} not understood or supported") + + +def _parse_annotation_post_25(name: str, type_def: Any, type_args: tuple, str_lengths: dict[str, int]) -> tuple: + """Parse an NDArray annotation into a numpy dtype tuple for NumPy >= 2.5.""" + # Expected type_args for NDArray: (numpy.int32,) + if len(type_args) == 1: + dtype = type_args[0] + return (name, _resolve_str_dtype(name, dtype, str_lengths)) + # Expected type_args for NDArray3: (NDArray[numpy.float64], typing.Literal[3]) + if len(type_args) == _NDARRAY_TYPE_ARGS: + dtype = get_args(type_args[0])[0] + return (name, _resolve_str_dtype(name, dtype, str_lengths), get_args(type_args[1])[0]) + raise ValueError(f"dtype {type_def} not understood or supported") + + class FancyArray(ABC): # noqa: B024 """Base class for all arrays. @@ -100,48 +139,30 @@ def get_id_columns(cls) -> set[str]: @classmethod @lru_cache - def get_dtype(cls): + def get_dtype(cls) -> np.dtype: annotations = get_public_annotations(cls) if not annotations.keys(): - raise ArrayDefinitionError("Array has no defined Columns") + raise ArrayDefinitionError(f"Array '{cls.__name__}' has no defined Columns") if reserved := set(annotations.keys()) & _RESERVED_COLUMN_NAMES: - raise ArrayDefinitionError(f"Columns cannot be reserved names: {reserved}") + raise ArrayDefinitionError( + f"Columns of '{cls.__name__}' cannot be reserved names: {reserved} " + f"(reserved names are: {_RESERVED_COLUMN_NAMES})" + ) str_lengths = combine_attribute_from_parent_classes(cls, "_str_lengths", dict) - dtype_list = [] # Numpy 2.5 changed the typing interface, so we need to treat these differently - is_before_numpy_25 = version.parse(np.__version__) < version.parse("2.5.0") - - for name, type_def in annotations.items(): - type_args = get_args(type_def) - - # Expected type_args pre-2.5 for NDArray[]: (tuple[typing.Any, ...], numpy.dtype[numpy.int32]) - if is_before_numpy_25 and len(type_args) == 2 and get_origin(type_args[1]) is np.dtype: # noqa: PLR2004 - dtype_list.append((name, get_args(type_args[1])[0])) - # Expected type_args pre-2.5 for NDArray3[]: - # (numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.float64]], typing.Literal[3]) - elif is_before_numpy_25 and len(type_args) == 2 and get_origin(type_args[1]) is Literal: # noqa: PLR2004 - dtype_list.append((name, get_args(get_args(type_args[0])[1])[0], get_args(type_args[1])[0])) - # Expected type_args post-2.5 for NDArray: (numpy.int32,) - elif len(type_args) == 1: # pragma: no cover - dtype_list.append((name, type_args[0])) - # Expected type_args post-2.5 for NDArray3: (NDArray[numpy.float64], typing.Literal[3]) - elif len(type_args) == 2: # noqa: PLR2004 # pragma: no cover - dtype_list.append((name, get_args(type_args[0])[0], get_args(type_args[1])[0])) - else: - raise ValueError(f"dtype {type_def} not understood or supported") + parse_annotation = ( + _parse_annotation_pre_25 + if version.parse(np.__version__) < version.parse("2.5.0") + else _parse_annotation_post_25 + ) - # Change the np.str_ types to include a max length: dtype_list = [ - ( - name, - np.dtype(f"U{str_lengths.get(name, _DEFAULT_STR_LENGTH)}") if dtype is np.str_ else dtype, - *rest, - ) - for (name, dtype, *rest) in dtype_list + parse_annotation(name, type_def, get_args(type_def), str_lengths) + for name, type_def in annotations.items() ] return np.dtype(dtype_list) diff --git a/tests/unit/model/arrays/test_get_dtype.py b/tests/unit/model/arrays/test_get_dtype.py new file mode 100644 index 00000000..c7f9fefa --- /dev/null +++ b/tests/unit/model/arrays/test_get_dtype.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +from typing import Any, Literal + +import numpy as np +import pytest + +from power_grid_model_ds._core.model.arrays.base import array as array_module +from power_grid_model_ds._core.model.arrays.base.array import ( + _parse_annotation_post_25, + _parse_annotation_pre_25, +) + + +class TestParseAnnotationPre25: + def test_scalar(self): + type_args = (tuple[Any, ...], np.dtype[np.int64]) + assert _parse_annotation_pre_25("value", None, type_args, {}) == ("value", np.int64) + + def test_ndarray3(self): + type_args = (np.ndarray[tuple[Any, ...], np.dtype[np.float64]], Literal[3]) + assert _parse_annotation_pre_25("value", None, type_args, {}) == ("value", np.float64, 3) + + def test_str_length_folding(self): + type_args = (tuple[Any, ...], np.dtype[np.str_]) + assert _parse_annotation_pre_25("name", None, type_args, {"name": 100}) == ("name", np.dtype("U100")) + + def test_str_default_length(self): + type_args = (tuple[Any, ...], np.dtype[np.str_]) + expected = ("name", np.dtype(f"U{array_module._DEFAULT_STR_LENGTH}")) + assert _parse_annotation_pre_25("name", None, type_args, {}) == expected + + def test_unsupported_shape_raises(self): + with pytest.raises(ValueError, match="not understood or supported"): + _parse_annotation_pre_25("value", "bad", (), {}) + + def test_malformed_ndarray3_raises(self): + # A Literal-tagged annotation whose inner element lacks the expected nested structure must + # raise the clear ValueError rather than an opaque IndexError. + type_args = (int, Literal[3]) + with pytest.raises(ValueError, match="not understood or supported"): + _parse_annotation_pre_25("value", "bad", type_args, {}) + + +class TestParseAnnotationPost25: + def test_scalar(self): + assert _parse_annotation_post_25("value", None, (np.int64,), {}) == ("value", np.int64) + + def test_ndarray3(self): + type_args = (np.dtype[np.float64], Literal[3]) + assert _parse_annotation_post_25("value", None, type_args, {}) == ("value", np.float64, 3) + + def test_str_length_folding(self): + assert _parse_annotation_post_25("name", None, (np.str_,), {"name": 100}) == ("name", np.dtype("U100")) + + def test_str_default_length(self): + expected = ("name", np.dtype(f"U{array_module._DEFAULT_STR_LENGTH}")) + assert _parse_annotation_post_25("name", None, (np.str_,), {}) == expected + + def test_unsupported_shape_raises(self): + with pytest.raises(ValueError, match="not understood or supported"): + _parse_annotation_post_25("value", "bad", (), {}) From 4c9fe941622ad6bd1c3cbf10430619ab5bbde788 Mon Sep 17 00:00:00 2001 From: jaapschoutenalliander Date: Tue, 14 Jul 2026 13:32:44 +0200 Subject: [PATCH 09/11] chore: small changes Signed-off-by: jaapschoutenalliander --- .../_core/model/arrays/base/array.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/power_grid_model_ds/_core/model/arrays/base/array.py b/src/power_grid_model_ds/_core/model/arrays/base/array.py index 10f1f324..4b2b4efe 100644 --- a/src/power_grid_model_ds/_core/model/arrays/base/array.py +++ b/src/power_grid_model_ds/_core/model/arrays/base/array.py @@ -31,7 +31,6 @@ _RESERVED_COLUMN_NAMES: set = set(dir(np.array([]))).union({"data"}) _DEFAULT_STR_LENGTH: int = 50 _MAX_DATA_SIZE: int = 3 -_NDARRAY_TYPE_ARGS: int = 2 Column = NDArray @@ -48,17 +47,17 @@ def _resolve_str_dtype(name: str, dtype: Any, str_lengths: dict[str, int]) -> An def _parse_annotation_pre_25(name: str, type_def: Any, type_args: tuple, str_lengths: dict[str, int]) -> tuple: """Parse an NDArray annotation into a numpy dtype tuple for NumPy < 2.5.""" # Expected type_args for NDArray[]: (tuple[typing.Any, ...], numpy.dtype[numpy.int32]) - if len(type_args) == _NDARRAY_TYPE_ARGS and get_origin(type_args[1]) is np.dtype: + if len(type_args) == 2 and get_origin(type_args[1]) is np.dtype: # noqa: PLR2004 dtype = get_args(type_args[1])[0] return (name, _resolve_str_dtype(name, dtype, str_lengths)) # Expected type_args for NDArray3[]: # (numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.float64]], typing.Literal[3]) - if len(type_args) == _NDARRAY_TYPE_ARGS and get_origin(type_args[1]) is Literal: + if len(type_args) == 2 and get_origin(type_args[1]) is Literal: # noqa: PLR2004 try: dtype = get_args(get_args(type_args[0])[1])[0] size = get_args(type_args[1])[0] - except IndexError: - raise ValueError(f"dtype {type_def} not understood or supported") from None + except IndexError as error: + raise ValueError(f"dtype {type_def} not understood or supported") from error return (name, _resolve_str_dtype(name, dtype, str_lengths), size) raise ValueError(f"dtype {type_def} not understood or supported") @@ -70,7 +69,7 @@ def _parse_annotation_post_25(name: str, type_def: Any, type_args: tuple, str_le dtype = type_args[0] return (name, _resolve_str_dtype(name, dtype, str_lengths)) # Expected type_args for NDArray3: (NDArray[numpy.float64], typing.Literal[3]) - if len(type_args) == _NDARRAY_TYPE_ARGS: + if len(type_args) == 2: # noqa: PLR2004 dtype = get_args(type_args[0])[0] return (name, _resolve_str_dtype(name, dtype, str_lengths), get_args(type_args[1])[0]) raise ValueError(f"dtype {type_def} not understood or supported") From 0c2fd5dfd81a54458127a58bbfbe3815c57703f8 Mon Sep 17 00:00:00 2001 From: jaapschoutenalliander Date: Tue, 14 Jul 2026 13:40:11 +0200 Subject: [PATCH 10/11] chore: quality Signed-off-by: jaapschoutenalliander --- src/power_grid_model_ds/_core/model/arrays/base/array.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/power_grid_model_ds/_core/model/arrays/base/array.py b/src/power_grid_model_ds/_core/model/arrays/base/array.py index cd0ef376..4884e395 100644 --- a/src/power_grid_model_ds/_core/model/arrays/base/array.py +++ b/src/power_grid_model_ds/_core/model/arrays/base/array.py @@ -160,8 +160,7 @@ def get_dtype(cls) -> np.dtype: # noqa: python:S3776 ) dtype_list = [ - parse_annotation(name, type_def, get_args(type_def), str_lengths) - for name, type_def in annotations.items() + parse_annotation(name, type_def, get_args(type_def), str_lengths) for name, type_def in annotations.items() ] return np.dtype(dtype_list) From 9b41772660e53f893b7f4a15619a0bcbc4e7c5b2 Mon Sep 17 00:00:00 2001 From: jaapschoutenalliander Date: Tue, 14 Jul 2026 13:46:28 +0200 Subject: [PATCH 11/11] chore: remove type Signed-off-by: jaapschoutenalliander --- src/power_grid_model_ds/_core/model/arrays/base/array.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/power_grid_model_ds/_core/model/arrays/base/array.py b/src/power_grid_model_ds/_core/model/arrays/base/array.py index 4884e395..8d328848 100644 --- a/src/power_grid_model_ds/_core/model/arrays/base/array.py +++ b/src/power_grid_model_ds/_core/model/arrays/base/array.py @@ -138,7 +138,7 @@ def get_id_columns(cls) -> set[str]: @classmethod @lru_cache - def get_dtype(cls) -> np.dtype: # noqa: python:S3776 + def get_dtype(cls): # noqa: python:S3776 annotations = get_public_annotations(cls) if not annotations.keys():