• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

p2p-ld / numpydantic / 24373313656

14 Apr 2026 12:00AM UTC coverage: 97.821% (-0.5%) from 98.345%
24373313656

push

github

web-flow
Merge pull request #67 from p2p-ld/ndarray-schema-proxies

allow proxies to be used with ndarray schema as input

15 of 17 new or added lines in 4 files covered. (88.24%)

23 existing lines in 11 files now uncovered.

1571 of 1606 relevant lines covered (97.82%)

9.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.65
/src/numpydantic/validation/shape.py
1
"""
2
Declaration and validation functions for array shapes.
3

4
Mostly a mildly modified version of nptyping's
5
:func:`npytping.shape_expression.validate_shape`
6
and its internals to allow for extended syntax, including ranges of shapes.
7

8
Modifications from nptyping:
9

10
- **"..."** - In nptyping, ``'...'`` means "any number of dimensions with the same shape
11
  as the last dimension. ie ``Shape[2, ...]`` means "any number of 2-length
12
  dimensions. Here ``'...'`` always means "any number of any-shape dimensions"
13
- **Ranges** - (inclusive) shape ranges are allowed. eg. to specify an array
14
  where the first dimension can be 2, 3, or 4 length:
15

16
     Shape["2-4, ..."]
17

18
  To specify a range with an unbounded min or max, use wildcards, eg. for
19
  an array with the first dimension at least length 2, and the second dimension
20
  at most length 5 (both inclusive):
21

22
      Shape["2-*, *-5"]
23

24
"""
25

26
import re
10✔
27
import string
10✔
28
from abc import ABC
10✔
29
from functools import lru_cache
10✔
30
from typing import Any, Generic, Literal, TypeVar
10✔
31

32
from numpydantic.vendor.nptyping.base_meta_classes import ContainerMeta
10✔
33
from numpydantic.vendor.nptyping.error import InvalidShapeError
10✔
34
from numpydantic.vendor.nptyping.nptyping_type import NPTypingType
10✔
35
from numpydantic.vendor.nptyping.shape_expression import (
10✔
36
    get_dimensions,
37
    normalize_shape_expression,
38
    remove_labels,
39
)
40
from numpydantic.vendor.nptyping.typing_ import ShapeExpression, ShapeTuple
10✔
41

42
T = TypeVar("T", bound=Literal[str])
10✔
43

44

45
class ShapeMeta(ContainerMeta["Shape"], implementation="Shape"):
10✔
46
    """
47
    Metaclass that is coupled to nptyping.Shape.
48

49
    Overridden from nptyping to use local shape validation function
50
    """
51

52
    def _validate_expression(cls, item: str) -> str:
10✔
53
        return validate_shape_expression(item)
10✔
54

55
    def _normalize_expression(cls, item: str) -> str:
10✔
56
        return normalize_shape_expression(item)
10✔
57

58
    def _get_additional_values(cls, item: Any) -> dict[str, Any]:
10✔
59
        if isinstance(item, tuple):
10✔
60
            item = ", ".join(str(i) for i in item)
10✔
61
        dim_strings = get_dimensions(item)
10✔
62
        dim_string_without_labels = remove_labels(dim_strings)
10✔
63
        return {"prepared_args": dim_string_without_labels}
10✔
64

65

66
class Shape(NPTypingType, ABC, Generic[T], metaclass=ShapeMeta):
10✔
67
    """
68
    A container for shape expressions that describe the shape of an multi
69
    dimensional array.
70

71
    Simple example:
72

73
    >>> Shape['2, 2']
74
    Shape['2, 2']
75

76
    A Shape can be compared to a typing.Literal. You can use Literals in
77
    NDArray as well.
78

79
    >>> from typing import Literal
80

81
    >>> Shape['2, 2'] == Literal['2, 2']
82
    True
83

84
    A Shape can be constructed by calling for type checker compatibility
85

86
    >>> Shape['2, 2'] == Shape('2, 2')
87

88
    And its arguments can be pased as *args, with ints and strings as appropriate
89

90
    >>> Shape(2, 2, "...") == Shape("2, 2, ...")
91

92
    """
93

94
    def __new__(cls, *args: str | int) -> type["Shape"]:
10✔
95
        """Create a new Shape as a callable"""
96
        return Shape[args]
10✔
97

98
    __args__ = ("*, ...",)
10✔
99
    prepared_args = ("*", "...")
10✔
100

101

102
def validate_shape_expression(
10✔
103
    shape_expression: ShapeExpression | tuple[str, ...] | Any,
104
) -> str:
105
    """
106
    CHANGES FROM NPTYPING:
107
    - Allow ranges
108
    - Allow specifying as a tuple
109
    """
110
    if isinstance(shape_expression, tuple):
10✔
111
        shape_expression = ", ".join(str(term) for term in shape_expression)
10✔
112
    shape_expression_no_quotes = shape_expression.replace("'", "").replace('"', "")
10✔
113
    if shape_expression is not Any and not re.match(
10✔
114
        _REGEX_SHAPE_EXPRESSION, shape_expression_no_quotes
115
    ):
UNCOV
116
        raise InvalidShapeError(
×
117
            f"'{shape_expression}' is not a valid shape expression."
118
        )
119
    return shape_expression
10✔
120

121

122
@lru_cache
10✔
123
def validate_shape(shape: ShapeTuple, target: "Shape") -> bool:
10✔
124
    """
125
    Check whether the given shape corresponds to the given shape_expression.
126
    :param shape: the shape in question.
127
    :param target: the shape expression to which shape is tested.
128
    :return: True if the given shape corresponds to shape_expression.
129
    """
130
    target_shape = _handle_ellipsis(shape, target.prepared_args)
10✔
131
    return _check_dimensions_against_shape(shape, target_shape)
10✔
132

133

134
def _check_dimensions_against_shape(shape: ShapeTuple, target: list[str]) -> bool:
10✔
135
    # Walk through the shape and test them against the given target,
136
    # taking into consideration variables, wildcards, etc.
137

138
    if len(shape) != len(target):
10✔
139
        return False
10✔
140
    shape_as_strings = (str(dim) for dim in shape)
10✔
141
    variables: dict[str, str] = {}
10✔
142
    for dim, target_dim in zip(shape_as_strings, target):
10✔
143
        if _is_wildcard(target_dim) or _is_assignable_var(dim, target_dim, variables):
10✔
144
            continue
10✔
145
        if _is_range(target_dim) and _check_range(dim, target_dim):
10✔
146
            continue
10✔
147
        if dim != target_dim:
10✔
148
            return False
10✔
149
    return True
10✔
150

151

152
def _handle_ellipsis(shape: ShapeTuple, target: list[str]) -> list[str]:
10✔
153
    # Let the ellipsis allows for any number of dimensions by replacing the
154
    # ellipsis with the dimension size repeated the number of times that
155
    # corresponds to the shape of the instance.
156
    if target[-1] == "...":
10✔
157
        dim_to_repeat = "*"
10✔
158
        target = target[0:-1]
10✔
159
        if len(shape) > len(target):
10✔
160
            difference = len(shape) - len(target)
10✔
161
            target += difference * [dim_to_repeat]
10✔
162
    return target
10✔
163

164

165
def _is_range(target_dim: str) -> bool:
10✔
166
    """Whether the dimension is a range (literally whether it includes a hyphen)"""
167
    return "-" in target_dim and len(target_dim.split("-")) == 2
10✔
168

169

170
def _check_range(dim: str, target_dim: str) -> bool:
10✔
171
    """check whether the given dimension is within the target_dim range"""
172
    dim = int(dim)
10✔
173

174
    range_min, range_max = target_dim.split("-")
10✔
175
    if _is_wildcard(range_min):
10✔
176
        return dim <= int(range_max)
10✔
177
    elif _is_wildcard(range_max):
10✔
178
        return dim >= int(range_min)
10✔
179
    else:
180
        return int(range_min) <= dim <= int(range_max)
10✔
181

182

183
def _is_wildcard(dim: str) -> bool:
10✔
184
    """
185
    CHANGES FROM NPTYPING: added '*-*' range, which is a wildcard
186
    """
187
    # Return whether dim is a wildcard (i.e. the character that takes any
188
    # dimension size).
189
    return dim == "*" or dim == "*-*"
10✔
190

191

192
# CHANGES FROM NPTYPING: Allow ranges
193
_REGEX_SEPARATOR = r"(\s*,\s*)"
10✔
194
_REGEX_DIMENSION_SIZE = r"(\s*[0-9]+\s*)"
10✔
195
_REGEX_DIMENSION_RANGE = r"(\s*[0-9\*]+-[0-9\*]+\s*)"
10✔
196
_REGEX_VARIABLE = r"(\s*\b[A-Z]\w*\s*)"
10✔
197
_REGEX_LABEL = r"(\s*\b[a-z]\w*\s*)"
10✔
198
_REGEX_LABELS = rf"({_REGEX_LABEL}({_REGEX_SEPARATOR}{_REGEX_LABEL})*)"
10✔
199
_REGEX_WILDCARD = r"(\s*\*\s*)"
10✔
200
_REGEX_DIMENSION_BREAKDOWN = rf"(\s*\[{_REGEX_LABELS}\]\s*)"
10✔
201
_REGEX_DIMENSION = (
10✔
202
    rf"({_REGEX_DIMENSION_SIZE}"
203
    rf"|{_REGEX_DIMENSION_RANGE}"
204
    rf"|{_REGEX_VARIABLE}"
205
    rf"|{_REGEX_WILDCARD}"
206
    rf"|{_REGEX_DIMENSION_BREAKDOWN})"
207
)
208
_REGEX_DIMENSION_WITH_LABEL = rf"({_REGEX_DIMENSION}(\s+{_REGEX_LABEL})*)"
10✔
209
_REGEX_DIMENSIONS = (
10✔
210
    rf"{_REGEX_DIMENSION_WITH_LABEL}({_REGEX_SEPARATOR}{_REGEX_DIMENSION_WITH_LABEL})*"
211
)
212
_REGEX_DIMENSIONS_ELLIPSIS = rf"({_REGEX_DIMENSIONS}{_REGEX_SEPARATOR}\.\.\.\s*)"
10✔
213
_REGEX_SHAPE_EXPRESSION = rf"^({_REGEX_DIMENSIONS}|{_REGEX_DIMENSIONS_ELLIPSIS})$"
10✔
214

215
# --------------------------------------------------
216
# Below - unchanged from nptyping
217
# --------------------------------------------------
218

219

220
def _is_assignable_var(dim: str, target_dim: str, variables: dict[str, str]) -> bool:
10✔
221
    # Return whether target_dim is a variable and can be assigned with dim.
222
    return _is_variable(target_dim) and _can_assign_variable(dim, target_dim, variables)
10✔
223

224

225
def _is_variable(dim: str) -> bool:
10✔
226
    # Return whether dim is a variable.
227
    return dim[0] in string.ascii_uppercase
10✔
228

229

230
def _can_assign_variable(dim: str, target_dim: str, variables: dict[str, str]) -> bool:
10✔
231
    # Check and assign a variable.
UNCOV
232
    assignable = variables.get(target_dim) in (None, dim)
×
UNCOV
233
    variables[target_dim] = dim
×
UNCOV
234
    return assignable
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc