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

MuellerSeb / nml-tools / 26169996067

20 May 2026 02:41PM UTC coverage: 79.35% (+5.0%) from 74.315%
26169996067

Pull #27

github

MuellerSeb
Align integer-only constant API typing
Pull Request #27: Add Runtime Array Dimensions

466 of 520 new or added lines in 7 files covered. (89.62%)

4 existing lines in 2 files now uncovered.

2663 of 3356 relevant lines covered (79.35%)

0.79 hits per line

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

65.1
/src/nml_tools/validate.py
1
"""Namelist validation utilities."""
2

3
from __future__ import annotations
1✔
4

5
import math
1✔
6
import re
1✔
7
from dataclasses import dataclass
1✔
8
from typing import Any, Iterable, Mapping
1✔
9

10
_FORTRAN_IDENTIFIER = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
1✔
11

12

13
@dataclass(frozen=True)
1✔
14
class ScalarConstraints:
1✔
15
    category: str
1✔
16
    length: int | None
1✔
17
    enum_values: tuple[int | str, ...] | None
1✔
18
    enum_trimmed: tuple[str, ...] | None
1✔
19
    min_value: int | float | None
1✔
20
    max_value: int | float | None
1✔
21
    min_exclusive: bool
1✔
22
    max_exclusive: bool
1✔
23

24

25
def validate_namelist(
1✔
26
    schema: dict[str, Any],
27
    namelist: dict[str, Any],
28
    *,
29
    constants: dict[str, int] | None = None,
30
    dimensions: dict[str, int] | None = None,
31
) -> None:
32
    """Validate *namelist* against *schema*."""
33
    constants = _normalize_constants(constants)
1✔
34
    dimensions = _normalize_dimensions(dimensions)
1✔
35
    overlap = sorted(set(constants) & set(dimensions))
1✔
36
    if overlap:
1✔
37
        names = ", ".join(overlap)
1✔
38
        raise ValueError(f"constants and dimensions must not share names: {names}")
1✔
39

40
    namelist_name = schema.get("x-fortran-namelist")
1✔
41
    if not isinstance(namelist_name, str) or not namelist_name.strip():
1✔
42
        raise ValueError("schema must define non-empty 'x-fortran-namelist'")
×
43
    if schema.get("type") != "object":
1✔
44
        raise ValueError(f"schema '{namelist_name}' must be of type 'object'")
×
45
    properties_raw = schema.get("properties")
1✔
46
    if not isinstance(properties_raw, dict) or not properties_raw:
1✔
47
        raise ValueError(f"schema '{namelist_name}' must define object 'properties'")
×
48

49
    properties = _normalize_properties(properties_raw, namelist_name)
1✔
50
    required = _parse_required(schema.get("required", []), properties, namelist_name)
1✔
51
    values = _normalize_namelist(namelist, namelist_name)
1✔
52

53
    for key in values:
1✔
54
        if key not in properties:
1✔
55
            raise ValueError(
1✔
56
                f"namelist '{namelist_name}' has unknown property '{values[key][0]}'"
57
            )
58

59
    for key, (prop_name, prop) in properties.items():
1✔
60
        if key in values:
1✔
61
            _validate_property(
1✔
62
                prop_name,
63
                prop,
64
                values[key][1],
65
                constants=constants,
66
                dimensions=dimensions,
67
            )
68
        elif key in required:
×
69
            raise ValueError(f"namelist '{namelist_name}' is missing required '{prop_name}'")
×
70

71

72
def _normalize_constants(
1✔
73
    constants: Mapping[str, int] | None,
74
) -> dict[str, int]:
75
    if constants is None:
1✔
76
        return {}
1✔
77
    normalized: dict[str, int] = {}
1✔
78
    for name, value in constants.items():
1✔
79
        if not isinstance(name, str) or not name.strip():
1✔
NEW
80
            raise ValueError("constant names must be non-empty strings")
×
81
        if not _FORTRAN_IDENTIFIER.match(name):
1✔
NEW
82
            raise ValueError(f"constant '{name}' must be a valid Fortran identifier")
×
83
        canonical_name = name.lower()
1✔
84
        if canonical_name in normalized:
1✔
85
            raise ValueError(f"constant '{name}' duplicates another constant name")
1✔
86
        if isinstance(value, bool) or not isinstance(value, int):
1✔
87
            raise ValueError(f"constant '{name}' must be an integer")
1✔
88
        normalized[canonical_name] = value
1✔
89
    return normalized
1✔
90

91

92
def _normalize_dimensions(dimensions: Mapping[str, int] | None) -> dict[str, int]:
1✔
93
    if dimensions is None:
1✔
94
        return {}
1✔
95
    normalized: dict[str, int] = {}
1✔
96
    for name, value in dimensions.items():
1✔
97
        if not isinstance(name, str) or not name.strip():
1✔
NEW
98
            raise ValueError("runtime dimension names must be non-empty strings")
×
99
        if not _FORTRAN_IDENTIFIER.match(name):
1✔
100
            raise ValueError(f"runtime dimension '{name}' must be a valid Fortran identifier")
1✔
101
        canonical_name = name.lower()
1✔
102
        if canonical_name in normalized:
1✔
NEW
103
            raise ValueError(f"runtime dimension '{name}' duplicates another dimension name")
×
104
        if isinstance(value, bool) or not isinstance(value, int):
1✔
105
            raise ValueError(f"runtime dimension '{name}' must be an integer")
1✔
106
        if value <= 0:
1✔
107
            raise ValueError(f"runtime dimension '{name}' must be positive")
1✔
108
        normalized[canonical_name] = value
1✔
109
    return normalized
1✔
110

111

112
def _normalize_properties(
1✔
113
    properties: Mapping[str, Any],
114
    namelist_name: str,
115
) -> dict[str, tuple[str, dict[str, Any]]]:
116
    normalized: dict[str, tuple[str, dict[str, Any]]] = {}
1✔
117
    for name, prop in properties.items():
1✔
118
        if not isinstance(name, str):
1✔
119
            raise ValueError(f"schema '{namelist_name}' property names must be strings")
×
120
        if not isinstance(prop, dict):
1✔
121
            raise ValueError(f"schema '{namelist_name}' property '{name}' must be an object")
×
122
        key = name.lower()
1✔
123
        if key in normalized:
1✔
124
            raise ValueError(
×
125
                f"schema '{namelist_name}' defines duplicate property '{name}'"
126
            )
127
        normalized[key] = (name, prop)
1✔
128
    return normalized
1✔
129

130

131
def _parse_required(
1✔
132
    raw: Any,
133
    properties: Mapping[str, tuple[str, dict[str, Any]]],
134
    namelist_name: str,
135
) -> set[str]:
136
    if raw is None:
1✔
137
        return set()
×
138
    if not isinstance(raw, list):
1✔
139
        raise ValueError(f"schema '{namelist_name}' required must be a list")
×
140
    required: set[str] = set()
1✔
141
    for item in raw:
1✔
142
        if not isinstance(item, str):
1✔
143
            raise ValueError(f"schema '{namelist_name}' required entries must be strings")
×
144
        key = item.lower()
1✔
145
        if key not in properties:
1✔
146
            raise ValueError(
×
147
                f"schema '{namelist_name}' required entry '{item}' is not a property"
148
            )
149
        required.add(key)
1✔
150
    return required
1✔
151

152

153
def _normalize_namelist(
1✔
154
    namelist: Mapping[str, Any],
155
    namelist_name: str,
156
) -> dict[str, tuple[str, Any]]:
157
    normalized: dict[str, tuple[str, Any]] = {}
1✔
158
    for name, value in namelist.items():
1✔
159
        if not isinstance(name, str):
1✔
160
            raise ValueError(f"namelist '{namelist_name}' keys must be strings")
×
161
        key = name.lower()
1✔
162
        if key in normalized:
1✔
163
            raise ValueError(
×
164
                f"namelist '{namelist_name}' defines duplicate key '{name}'"
165
            )
166
        normalized[key] = (name, value)
1✔
167
    return normalized
1✔
168

169

170
def _validate_property(
1✔
171
    name: str,
172
    prop: Mapping[str, Any],
173
    value: Any,
174
    *,
175
    constants: dict[str, int] | None,
176
    dimensions: dict[str, int] | None,
177
) -> None:
178
    prop_type = prop.get("type")
1✔
179
    if prop_type == "array":
1✔
180
        _validate_array(name, prop, value, constants, dimensions)
1✔
181
        return
1✔
182
    if prop_type in {"integer", "number", "boolean", "string"}:
1✔
183
        constraints = _scalar_constraints(name, prop, prop_type, constants, dimensions)
1✔
184
        _validate_scalar_value(name, value, constraints)
1✔
185
        return
1✔
186
    raise ValueError(f"property '{name}' has unsupported type '{prop_type}'")
×
187

188

189
def _validate_array(
1✔
190
    name: str,
191
    prop: Mapping[str, Any],
192
    value: Any,
193
    constants: dict[str, int] | None,
194
    dimensions: dict[str, int] | None,
195
) -> None:
196
    items = prop.get("items")
1✔
197
    if not isinstance(items, dict):
1✔
198
        raise ValueError(f"array property '{name}' must define 'items'")
×
199
    items_type = items.get("type")
1✔
200
    if items_type == "array":
1✔
201
        raise ValueError(f"array property '{name}' must not nest arrays")
×
202
    if items_type not in {"integer", "number", "boolean", "string"}:
1✔
203
        raise ValueError(f"array property '{name}' items must define a scalar type")
×
204

205
    shape_constants = {**(constants or {}), **(dimensions or {})}
1✔
206
    shape = _parse_shape(prop.get("x-fortran-shape"), shape_constants, name)
1✔
207
    flex_tail_dims = _parse_flex_tail_dims(prop, len(shape), name, shape)
1✔
208

209
    array_value = _coerce_array_value(value, name)
1✔
210
    provided_shape = _nested_shape(array_value, name)
1✔
211
    if len(provided_shape) != len(shape):
1✔
UNCOV
212
        raise ValueError(
×
213
            f"array '{name}' rank mismatch: expected {len(shape)} got {len(provided_shape)}"
214
        )
215
    shape_fortran = list(reversed(provided_shape))
1✔
216
    for idx, (provided, expected) in enumerate(zip(shape_fortran, shape), start=1):
1✔
217
        if provided <= 0:
1✔
218
            raise ValueError(f"array '{name}' has empty dimension {idx}")
×
219
        if expected is None:
1✔
220
            continue
×
221
        if flex_tail_dims > 0 and idx <= len(shape) - flex_tail_dims:
1✔
222
            if provided != expected:
1✔
223
                raise ValueError(
×
224
                    f"array '{name}' dimension {idx} must be {expected}, got {provided}"
225
                )
226
        else:
227
            if provided > expected:
1✔
228
                raise ValueError(
×
229
                    f"array '{name}' dimension {idx} must be <= {expected}, got {provided}"
230
                )
231

232
    constraints = _scalar_constraints(name, items, items_type, constants, dimensions)
1✔
233
    for element in _iter_scalars(array_value):
1✔
234
        _validate_scalar_value(name, element, constraints)
1✔
235

236

237
def _scalar_constraints(
1✔
238
    name: str,
239
    prop: Mapping[str, Any],
240
    category: str,
241
    constants: dict[str, int] | None,
242
    dimensions: dict[str, int] | None,
243
) -> ScalarConstraints:
244
    length = None
1✔
245
    if category == "string":
1✔
246
        length = _parse_length(prop, constants, dimensions, name)
1✔
247
    enum_values, enum_trimmed = _parse_enum(prop, category, length, name)
1✔
248
    min_value, min_exclusive = _extract_bound(prop, "minimum", "exclusiveMinimum", name)
1✔
249
    max_value, max_exclusive = _extract_bound(prop, "maximum", "exclusiveMaximum", name)
1✔
250
    if min_value is not None or max_value is not None:
1✔
251
        if category not in {"integer", "number"}:
1✔
252
            raise ValueError(f"property '{name}' bounds require integer or number")
×
253
        _validate_bound_scalar(min_value, category, name, "minimum")
1✔
254
        _validate_bound_scalar(max_value, category, name, "maximum")
1✔
255
        _validate_bound_range(min_value, max_value, min_exclusive, max_exclusive, name)
1✔
256
    return ScalarConstraints(
1✔
257
        category=category,
258
        length=length,
259
        enum_values=enum_values,
260
        enum_trimmed=enum_trimmed,
261
        min_value=min_value,
262
        max_value=max_value,
263
        min_exclusive=min_exclusive,
264
        max_exclusive=max_exclusive,
265
    )
266

267

268
def _parse_length(
1✔
269
    prop: Mapping[str, Any],
270
    constants: dict[str, int] | None,
271
    dimensions: dict[str, int] | None,
272
    name: str,
273
) -> int:
274
    raw = prop.get("x-fortran-len")
1✔
275
    if isinstance(raw, bool) or raw is None:
1✔
276
        raise ValueError(f"string property '{name}' must define 'x-fortran-len'")
×
277
    if isinstance(raw, int):
1✔
278
        if raw <= 0:
×
279
            raise ValueError(f"string property '{name}' length must be positive")
×
280
        return raw
×
281
    if isinstance(raw, str):
1✔
282
        token = raw.strip()
1✔
283
        if not token:
1✔
284
            raise ValueError(f"string property '{name}' length must be non-empty")
×
285
        if _is_int_literal(token):
1✔
286
            length = int(token)
×
287
            if length <= 0:
×
288
                raise ValueError(f"string property '{name}' length must be positive")
×
289
            return length
×
290
        if not _FORTRAN_IDENTIFIER.match(token):
1✔
291
            raise ValueError(f"string property '{name}' length must be literal or identifier")
×
292
        token_key = token.lower()
1✔
293
        if dimensions is not None and token_key in dimensions:
1✔
294
            raise ValueError(
1✔
295
                f"string property '{name}' length must not use runtime dimension '{token}'"
296
            )
297
        if constants is None or token_key not in constants:
1✔
298
            raise ValueError(f"string property '{name}' length constant '{token}' not defined")
×
299
        value = constants[token_key]
1✔
300
        if isinstance(value, bool) or not isinstance(value, int):
1✔
301
            raise ValueError(f"string property '{name}' length constant '{token}' must be int")
×
302
        if value <= 0:
1✔
303
            raise ValueError(f"string property '{name}' length constant '{token}' must be positive")
×
304
        return value
1✔
305
    raise ValueError(f"string property '{name}' must define 'x-fortran-len'")
×
306

307

308
def _parse_enum(
1✔
309
    prop: Mapping[str, Any],
310
    category: str,
311
    length: int | None,
312
    name: str,
313
) -> tuple[tuple[int | str, ...] | None, tuple[str, ...] | None]:
314
    if "enum" not in prop:
1✔
315
        return None, None
1✔
316
    enum_raw = prop.get("enum")
×
317
    if not isinstance(enum_raw, list) or not enum_raw:
×
318
        raise ValueError(f"property '{name}' enum must be a non-empty list")
×
319
    if category == "integer":
×
320
        values: list[int] = []
×
321
        for item in enum_raw:
×
322
            if isinstance(item, bool) or not isinstance(item, int):
×
323
                raise ValueError(f"property '{name}' enum values must be integers")
×
324
            values.append(item)
×
325
        return tuple(values), None
×
326
    if category == "string":
×
327
        values_str: list[str] = []
×
328
        trimmed: list[str] = []
×
329
        for item in enum_raw:
×
330
            if not isinstance(item, str):
×
331
                raise ValueError(f"property '{name}' enum values must be strings")
×
332
            if length is not None and len(item) > length:
×
333
                raise ValueError(f"property '{name}' enum value '{item}' exceeds length")
×
334
            values_str.append(item)
×
335
            trimmed.append(item.rstrip())
×
336
        return tuple(values_str), tuple(trimmed)
×
337
    raise ValueError(f"property '{name}' enum only supports strings or integers")
×
338

339

340
def _extract_bound(
1✔
341
    prop: Mapping[str, Any],
342
    inclusive_key: str,
343
    exclusive_key: str,
344
    name: str,
345
) -> tuple[int | float | None, bool]:
346
    has_inclusive = inclusive_key in prop
1✔
347
    has_exclusive = exclusive_key in prop
1✔
348
    if has_inclusive and has_exclusive:
1✔
349
        raise ValueError(
×
350
            f"property '{name}' must not define both '{inclusive_key}' and '{exclusive_key}'"
351
        )
352
    if has_exclusive:
1✔
353
        value = prop.get(exclusive_key)
×
354
        if value is None:
×
355
            raise ValueError(f"property '{name}' {exclusive_key} must be a number")
×
356
        return _ensure_number(value, name, exclusive_key), True
×
357
    if has_inclusive:
1✔
358
        value = prop.get(inclusive_key)
1✔
359
        if value is None:
1✔
360
            raise ValueError(f"property '{name}' {inclusive_key} must be a number")
×
361
        return _ensure_number(value, name, inclusive_key), False
1✔
362
    return None, False
1✔
363

364

365
def _ensure_number(value: object, name: str, label: str) -> int | float:
1✔
366
    if isinstance(value, bool) or not isinstance(value, (int, float)):
1✔
367
        raise ValueError(f"property '{name}' {label} must be a number")
×
368
    return value
1✔
369

370

371
def _validate_bound_scalar(
1✔
372
    value: int | float | None,
373
    category: str,
374
    name: str,
375
    label: str,
376
) -> None:
377
    if value is None:
1✔
378
        return
1✔
379
    if category == "integer":
1✔
380
        if isinstance(value, bool) or not isinstance(value, int):
1✔
381
            raise ValueError(f"property '{name}' {label} must be an integer")
×
382
        return
1✔
383
    if category == "number":
×
384
        if isinstance(value, bool) or not isinstance(value, (int, float)):
×
385
            raise ValueError(f"property '{name}' {label} must be a number")
×
386
        if math.isinf(float(value)):
×
387
            raise ValueError(f"property '{name}' {label} must not be infinite")
×
388
        if math.isnan(float(value)):
×
389
            raise ValueError(f"property '{name}' {label} must not be NaN")
×
390
        return
×
391
    raise ValueError(f"property '{name}' bounds only support integers or numbers")
×
392

393

394
def _validate_bound_range(
1✔
395
    min_value: int | float | None,
396
    max_value: int | float | None,
397
    min_exclusive: bool,
398
    max_exclusive: bool,
399
    name: str,
400
) -> None:
401
    if min_value is None or max_value is None:
1✔
402
        return
1✔
403
    min_comp = float(min_value)
×
404
    max_comp = float(max_value)
×
405
    if min_exclusive or max_exclusive:
×
406
        if min_comp >= max_comp:
×
407
            raise ValueError(f"property '{name}' minimum must be < maximum for exclusive bounds")
×
408
    else:
409
        if min_comp > max_comp:
×
410
            raise ValueError(f"property '{name}' minimum must be <= maximum")
×
411

412

413
def _validate_scalar_value(
1✔
414
    name: str,
415
    value: Any,
416
    constraints: ScalarConstraints,
417
) -> None:
418
    category = constraints.category
1✔
419
    if category == "integer":
1✔
420
        if isinstance(value, bool) or not isinstance(value, int):
1✔
421
            raise ValueError(f"property '{name}' must be an integer")
×
422
        if constraints.enum_values is not None and value not in constraints.enum_values:
1✔
423
            raise ValueError(f"property '{name}' has value outside enum")
×
424
        _validate_value_bounds(value, constraints, name)
1✔
425
        return
1✔
426
    if category == "number":
1✔
427
        if isinstance(value, bool) or not isinstance(value, (int, float)):
×
428
            raise ValueError(f"property '{name}' must be a number")
×
429
        if math.isnan(float(value)):
×
430
            raise ValueError(f"property '{name}' must not be NaN")
×
431
        if math.isinf(float(value)):
×
432
            raise ValueError(f"property '{name}' must not be infinite")
×
433
        _validate_value_bounds(float(value), constraints, name)
×
434
        return
×
435
    if category == "boolean":
1✔
436
        if not isinstance(value, bool):
×
437
            raise ValueError(f"property '{name}' must be boolean")
×
438
        return
×
439
    if category == "string":
1✔
440
        if not isinstance(value, str):
1✔
441
            raise ValueError(f"property '{name}' must be a string")
×
442
        if constraints.length is not None and len(value) > constraints.length:
1✔
443
            raise ValueError(f"property '{name}' exceeds length {constraints.length}")
×
444
        if constraints.enum_trimmed is not None:
1✔
445
            if value.rstrip() not in constraints.enum_trimmed:
×
446
                raise ValueError(f"property '{name}' has value outside enum")
×
447
        return
1✔
448
    raise ValueError(f"property '{name}' has unsupported type '{category}'")
×
449

450

451
def _validate_value_bounds(
1✔
452
    value: int | float,
453
    constraints: ScalarConstraints,
454
    name: str,
455
) -> None:
456
    if constraints.min_value is not None:
1✔
457
        if constraints.min_exclusive:
1✔
458
            if value <= constraints.min_value:
×
459
                raise ValueError(f"property '{name}' must be > {constraints.min_value}")
×
460
        else:
461
            if value < constraints.min_value:
1✔
462
                raise ValueError(f"property '{name}' must be >= {constraints.min_value}")
×
463
    if constraints.max_value is not None:
1✔
464
        if constraints.max_exclusive:
×
465
            if value >= constraints.max_value:
×
466
                raise ValueError(f"property '{name}' must be < {constraints.max_value}")
×
467
        else:
468
            if value > constraints.max_value:
×
469
                raise ValueError(f"property '{name}' must be <= {constraints.max_value}")
×
470

471

472
def _parse_shape(
1✔
473
    raw: Any,
474
    constants: dict[str, int] | None,
475
    name: str,
476
) -> list[int | None]:
477
    shape_entries: list[Any]
478
    if isinstance(raw, bool) or raw is None:
1✔
NEW
479
        raise ValueError(f"array property '{name}' must define non-empty x-fortran-shape")
×
480
    if isinstance(raw, (int, str)):
1✔
481
        shape_entries = [raw]
1✔
482
    elif isinstance(raw, list) and raw:
1✔
483
        shape_entries = raw
1✔
484
    else:
UNCOV
485
        raise ValueError(f"array property '{name}' must define non-empty x-fortran-shape")
×
486

487
    parsed: list[int | None] = []
1✔
488
    for dim in shape_entries:
1✔
489
        if isinstance(dim, bool):
1✔
490
            raise ValueError(f"array property '{name}' shape entries must be int or str")
×
491
        if isinstance(dim, int):
1✔
492
            if dim <= 0:
1✔
493
                raise ValueError(f"array property '{name}' shape values must be positive")
×
494
            parsed.append(dim)
1✔
495
            continue
1✔
496
        if isinstance(dim, str):
1✔
497
            token = dim.strip()
1✔
498
            if not token:
1✔
499
                raise ValueError(f"array property '{name}' shape entries must be non-empty")
×
500
            if token == ":":
1✔
501
                parsed.append(None)
×
502
                continue
×
503
            if _is_int_literal(token):
1✔
504
                size = int(token)
×
505
                if size <= 0:
×
506
                    raise ValueError(f"array property '{name}' shape values must be positive")
×
507
                parsed.append(size)
×
508
                continue
×
509
            if not _FORTRAN_IDENTIFIER.match(token):
1✔
510
                raise ValueError(
×
511
                    f"array property '{name}' shape entries must be ints or identifiers"
512
                )
513
            token_key = token.lower()
1✔
514
            if constants is None or token_key not in constants:
1✔
515
                raise ValueError(f"array property '{name}' constant '{token}' not defined")
×
516
            value = constants[token_key]
1✔
517
            if isinstance(value, bool) or not isinstance(value, int):
1✔
518
                raise ValueError(f"array property '{name}' constant '{token}' must be int")
×
519
            if value <= 0:
1✔
520
                raise ValueError(f"array property '{name}' constant '{token}' must be positive")
×
521
            parsed.append(value)
1✔
522
            continue
1✔
523
        raise ValueError(f"array property '{name}' shape entries must be int or str")
×
524
    return parsed
1✔
525

526

527
def _parse_flex_tail_dims(
1✔
528
    prop: Mapping[str, Any],
529
    rank: int,
530
    name: str,
531
    dimensions: Iterable[int | None],
532
) -> int:
533
    raw = prop.get("x-fortran-flex-tail-dims")
1✔
534
    if raw is None:
1✔
535
        flex = 0
1✔
536
    else:
537
        if isinstance(raw, bool) or not isinstance(raw, int):
1✔
538
            raise ValueError(f"array property '{name}' flex tail dims must be an integer")
×
539
        flex = raw
1✔
540
    if flex < 0:
1✔
541
        raise ValueError(f"array property '{name}' flex tail dims must be >= 0")
×
542
    if flex == 0:
1✔
543
        return 0
1✔
544
    if flex > rank:
1✔
545
        raise ValueError(f"array property '{name}' flex tail dims must not exceed rank")
×
546
    if any(dim is None for dim in dimensions):
1✔
547
        raise ValueError(f"array property '{name}' flex tail dims require concrete shape")
×
548
    return flex
1✔
549

550

551
def _coerce_array_value(value: Any, name: str) -> Any:
1✔
552
    if isinstance(value, (list, tuple)):
1✔
553
        return value
1✔
554
    if hasattr(value, "tolist"):
×
555
        return value.tolist()
×
556
    raise ValueError(f"array property '{name}' must be a list")
×
557

558

559
def _nested_shape(value: Any, name: str) -> list[int]:
1✔
560
    if isinstance(value, (list, tuple)):
1✔
561
        if not value:
1✔
562
            raise ValueError(f"array property '{name}' must not be empty")
×
563
        first_shape: list[int] | None = None
1✔
564
        for item in value:
1✔
565
            item_shape = _nested_shape(item, name)
1✔
566
            if first_shape is None:
1✔
567
                first_shape = item_shape
1✔
568
            elif item_shape != first_shape:
1✔
569
                raise ValueError(f"array property '{name}' must be rectangular")
×
570
        return [len(value)] + (first_shape or [])
1✔
571
    return []
1✔
572

573

574
def _iter_scalars(value: Any) -> Iterable[Any]:
1✔
575
    if isinstance(value, (list, tuple)):
1✔
576
        for item in value:
1✔
577
            yield from _iter_scalars(item)
1✔
578
        return
1✔
579
    yield value
1✔
580

581

582
def _is_int_literal(value: str) -> bool:
1✔
583
    try:
1✔
584
        int(value)
1✔
585
    except ValueError:
1✔
586
        return False
1✔
587
    return True
×
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