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

MuellerSeb / nml-tools / 29619529631

17 Jul 2026 11:01PM UTC coverage: 86.477% (+0.4%) from 86.054%
29619529631

Pull #63

github

web-flow
Merge b3777c4e5 into 6717357e4
Pull Request #63: Allow defaults to satisfy required values

270 of 287 new or added lines in 7 files covered. (94.08%)

3 existing lines in 1 file now uncovered.

5039 of 5827 relevant lines covered (86.48%)

0.86 hits per line

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

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

3
from __future__ import annotations
1✔
4

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

9
from ._utils import (
1✔
10
    FORTRAN_IDENTIFIER,
11
    normalize_constant_values,
12
    normalize_runtime_dimensions,
13
    reject_constant_dimension_overlap,
14
    validate_user_fortran_identifier,
15
)
16
from .schema import _is_intrinsic_scalar_schema
1✔
17

18

19
@dataclass(frozen=True)
1✔
20
class ScalarConstraints:
1✔
21
    category: str
1✔
22
    length: int | None
1✔
23
    enum_values: tuple[int | str, ...] | None
1✔
24
    enum_trimmed: tuple[str, ...] | None
1✔
25
    min_value: int | float | None
1✔
26
    max_value: int | float | None
1✔
27
    min_exclusive: bool
1✔
28
    max_exclusive: bool
1✔
29

30

31
@dataclass(frozen=True)
1✔
32
class PropertyRequirement:
1✔
33
    """Schema-time requiredness and operational-default coverage for a property."""
34

35
    declared_required: bool
1✔
36
    required_defaults_complete: bool
1✔
37
    fully_default_initialized: bool
1✔
38
    requires_input: bool
1✔
39
    uncovered_required_components: frozenset[str]
1✔
40

41

42
def validate_schema_defaults(
1✔
43
    schema: Mapping[str, Any],
44
    *,
45
    constants: dict[str, int] | None = None,
46
    dimensions: dict[str, int] | None = None,
47
) -> None:
48
    """Validate operational defaults against each property's constraints."""
49
    if _has_reachable_reference(schema, position="root"):
1✔
50
        raise ValueError(
1✔
51
            "schema contains unresolved '$ref'; use load_schema() or resolve_schema() "
52
            "before validation or generation"
53
        )
54
    constants = normalize_constant_values(constants)
1✔
55
    dimensions = normalize_runtime_dimensions(dimensions)
1✔
56
    reject_constant_dimension_overlap(constants, dimensions)
1✔
57
    properties = schema.get("properties")
1✔
58
    if not isinstance(properties, Mapping):
1✔
59
        return
×
60
    required_names = {
1✔
61
        name.lower()
62
        for name in schema.get("required", [])
63
        if isinstance(name, str)
64
    }
65
    for name, prop in properties.items():
1✔
66
        if not isinstance(name, str) or not isinstance(prop, Mapping):
1✔
67
            continue
×
68
        _validate_property_defaults(
1✔
69
            name,
70
            prop,
71
            constants=constants,
72
            dimensions=dimensions,
73
        )
74
        analyze_property_requirement(
1✔
75
            name,
76
            prop,
77
            declared_required=name.lower() in required_names,
78
        )
79

80

81
def _has_reachable_reference(raw: Mapping[str, Any], *, position: str) -> bool:
1✔
82
    if "$ref" in raw:
1✔
83
        return True
1✔
84
    if position == "root":
1✔
85
        properties = raw.get("properties")
1✔
86
        if isinstance(properties, Mapping):
1✔
87
            for prop in properties.values():
1✔
88
                if isinstance(prop, Mapping) and _has_reachable_reference(
1✔
89
                    prop, position="property"
90
                ):
91
                    return True
1✔
92
    items = raw.get("items")
1✔
93
    return isinstance(items, Mapping) and _has_reachable_reference(items, position="items")
1✔
94

95

96
def _validate_property_defaults(
1✔
97
    name: str,
98
    prop: Mapping[str, Any],
99
    *,
100
    constants: dict[str, int] | None,
101
    dimensions: dict[str, int] | None,
102
) -> None:
103
    if prop.get("type") == "object":
1✔
104
        _validate_derived_declaration(name, prop)
1✔
105
        properties = prop.get("properties")
1✔
106
        if not isinstance(properties, Mapping):
1✔
107
            raise ValueError(f"derived property '{name}' must define object 'properties'")
×
108
        for child_name, child in properties.items():
1✔
109
            if isinstance(child_name, str):
1✔
110
                validate_user_fortran_identifier(
1✔
111
                    child_name, label=f"derived property '{name}' component '{child_name}'"
112
                )
113
            if not isinstance(child_name, str) or not _is_intrinsic_scalar_schema(child):
1✔
114
                raise ValueError(
1✔
115
                    f"derived property '{name}' component '{child_name}' "
116
                    "must define an intrinsic scalar type"
117
                )
118
            _validate_property_defaults(
1✔
119
                f"{name}.{child_name}",
120
                child,
121
                constants=constants,
122
                dimensions=dimensions,
123
            )
124
        _validate_derived_object_default(
1✔
125
            name,
126
            prop,
127
            constants=constants,
128
            dimensions=dimensions,
129
        )
130
        return
1✔
131

132
    if prop.get("type") != "array":
1✔
133
        if "default" in prop:
1✔
134
            _validate_scalar_default(
1✔
135
                name,
136
                prop,
137
                prop["default"],
138
                constants=constants,
139
                dimensions=dimensions,
140
            )
141
        return
1✔
142

143
    controls = {
1✔
144
        "x-fortran-default-order",
145
        "x-fortran-default-repeat",
146
        "x-fortran-default-pad",
147
    }
148
    if controls.intersection(prop) and "default" not in prop:
1✔
149
        raise ValueError(f"array property '{name}' default options require an array default")
×
150
    items = prop.get("items")
1✔
151
    if not isinstance(items, Mapping):
1✔
152
        if "default" in prop:
×
153
            raise ValueError(f"array property '{name}' with a default must define object 'items'")
×
154
        return
×
155
    if items.get("type") == "object":
1✔
156
        if "default" in prop or controls.intersection(prop):
1✔
157
            raise ValueError(f"derived array property '{name}' must not define defaults")
×
158
        if "x-fortran-flex-tail-dims" in prop:
1✔
159
            raise ValueError(
×
160
                f"derived array property '{name}' must not define x-fortran-flex-tail-dims"
161
            )
162
        _validate_property_defaults(
1✔
163
            f"{name}[]",
164
            items,
165
            constants=constants,
166
            dimensions=dimensions,
167
        )
168
        return
1✔
169
    if "default" in prop and "default" in items:
1✔
170
        raise ValueError(
×
171
            f"array property '{name}' default must be defined on property or items, not both"
172
        )
173
    has_default = "default" in prop or "default" in items
1✔
174
    if has_default:
1✔
175
        shape_constants = {**(constants or {}), **(dimensions or {})}
1✔
176
        shape = _parse_shape(prop.get("x-fortran-shape"), shape_constants, name)
1✔
177
        if _parse_flex_tail_dims(prop, len(shape), name, shape) > 0:
1✔
178
            raise ValueError(f"array property '{name}' flex arrays cannot define defaults")
1✔
179

180
    if "default" in prop:
1✔
181
        default = prop["default"]
1✔
182
        if not isinstance(default, list):
1✔
183
            raise ValueError(f"array default must be a list for property '{name}'")
1✔
184
        _validate_array_default_layout(name, prop, default, constants, dimensions)
1✔
185
        for value in _iter_scalars(default):
1✔
186
            _validate_scalar_default(
1✔
187
                name,
188
                items,
189
                value,
190
                constants=constants,
191
                dimensions=dimensions,
192
            )
193
    elif "default" in items:
1✔
194
        _validate_scalar_default(
1✔
195
            name,
196
            items,
197
            items["default"],
198
            constants=constants,
199
            dimensions=dimensions,
200
        )
201

202
    if "x-fortran-default-pad" in prop:
1✔
203
        pad = prop["x-fortran-default-pad"]
1✔
204
        pad_values = pad if isinstance(pad, list) else [pad]
1✔
205
        for value in _iter_scalars(pad_values):
1✔
206
            _validate_scalar_default(
1✔
207
                name,
208
                items,
209
                value,
210
                constants=constants,
211
                dimensions=dimensions,
212
            )
213

214

215
def _validate_scalar_default(
1✔
216
    name: str,
217
    prop: Mapping[str, Any],
218
    value: Any,
219
    *,
220
    constants: dict[str, int] | None,
221
    dimensions: dict[str, int] | None,
222
) -> None:
223
    category = prop.get("type")
1✔
224
    if category not in {"integer", "number", "boolean", "string"}:
1✔
225
        raise ValueError(f"property '{name}' has unsupported type '{category}'")
×
226
    constraints = _scalar_constraints(name, prop, category, constants, dimensions)
1✔
227
    _validate_scalar_value(name, value, constraints)
1✔
228

229

230
def _validate_derived_declaration(name: str, prop: Mapping[str, Any]) -> None:
1✔
231
    type_name = prop.get("x-fortran-type")
1✔
232
    if not isinstance(type_name, str) or not type_name.strip():
1✔
233
        raise ValueError(f"derived property '{name}' must define non-empty 'x-fortran-type'")
1✔
234
    try:
1✔
235
        validate_user_fortran_identifier(
1✔
236
            type_name.strip(), label=f"derived property '{name}' x-fortran-type"
237
        )
238
    except ValueError as exc:
1✔
239
        raise ValueError(str(exc).replace("Fortran identifier", "identifier")) from exc
1✔
240
    module_name = prop.get("x-fortran-module")
1✔
241
    if module_name is not None and (
1✔
242
        not isinstance(module_name, str) or not module_name.strip()
243
    ):
244
        raise ValueError(
×
245
            f"derived property '{name}' x-fortran-module must be a valid identifier"
246
        )
247
    if isinstance(module_name, str):
1✔
248
        try:
1✔
249
            validate_user_fortran_identifier(
1✔
250
                module_name.strip(), label=f"derived property '{name}' x-fortran-module"
251
            )
252
        except ValueError as exc:
×
253
            raise ValueError(str(exc).replace("Fortran identifier", "identifier")) from exc
×
254

255

256
def _validate_array_default_layout(
1✔
257
    name: str,
258
    prop: Mapping[str, Any],
259
    default: list[Any],
260
    constants: dict[str, int] | None,
261
    dimensions: dict[str, int] | None,
262
) -> None:
263
    if any(isinstance(value, list) for value in default):
1✔
264
        raise ValueError(f"array property '{name}' default must be a flat list")
×
265
    if not default:
1✔
266
        raise ValueError(f"array property '{name}' default must contain at least one value")
×
267
    shape_constants = {**(constants or {}), **(dimensions or {})}
1✔
268
    shape = _parse_shape(prop.get("x-fortran-shape"), shape_constants, name)
1✔
269
    if any(dimension is None for dimension in shape):
1✔
270
        raise ValueError(
×
271
            f"array property '{name}' defaults do not support deferred-size dimensions"
272
        )
273
    total_size = math.prod(dimension for dimension in shape if dimension is not None)
1✔
274
    if len(default) > total_size:
1✔
275
        raise ValueError(f"array property '{name}' default is longer than its shape")
×
276
    order = prop.get("x-fortran-default-order", "F")
1✔
277
    if not isinstance(order, str) or order.upper() not in {"F", "C"}:
1✔
278
        raise ValueError(f"array property '{name}' default order must be 'F' or 'C'")
×
279
    repeat = prop.get("x-fortran-default-repeat", False)
1✔
280
    if not isinstance(repeat, bool):
1✔
281
        raise ValueError(f"array property '{name}' default repeat must be boolean")
×
282
    pad = prop.get("x-fortran-default-pad")
1✔
283
    if pad is not None and repeat:
1✔
284
        raise ValueError(f"array property '{name}' default cannot set both pad and repeat")
×
285
    if isinstance(pad, list) and not pad:
1✔
286
        raise ValueError(f"array property '{name}' default pad must not be empty")
×
287
    if len(default) < total_size and pad is None and not repeat:
1✔
288
        raise ValueError(
1✔
289
            "array default shorter than declared x-fortran-shape without pad or repeat"
290
        )
291

292

293
def _normalize_properties(
1✔
294
    properties: Mapping[str, Any],
295
    namelist_name: str,
296
) -> dict[str, tuple[str, dict[str, Any]]]:
297
    normalized: dict[str, tuple[str, dict[str, Any]]] = {}
1✔
298
    for name, prop in properties.items():
1✔
299
        if not isinstance(name, str):
1✔
300
            raise ValueError(f"schema '{namelist_name}' property names must be strings")
×
301
        validate_user_fortran_identifier(
1✔
302
            name, label=f"schema '{namelist_name}' property '{name}'"
303
        )
304
        if not isinstance(prop, dict):
1✔
305
            raise ValueError(f"schema '{namelist_name}' property '{name}' must be an object")
×
306
        key = name.lower()
1✔
307
        if key in normalized:
1✔
308
            raise ValueError(
×
309
                f"schema '{namelist_name}' defines duplicate property '{name}'"
310
            )
311
        normalized[key] = (name, prop)
1✔
312
    return normalized
1✔
313

314

315
def _parse_required(
1✔
316
    raw: Any,
317
    properties: Mapping[str, tuple[str, dict[str, Any]]],
318
    namelist_name: str,
319
) -> set[str]:
320
    if raw is None:
1✔
321
        return set()
×
322
    if not isinstance(raw, list):
1✔
323
        raise ValueError(f"schema '{namelist_name}' required must be a list")
×
324
    required: set[str] = set()
1✔
325
    for item in raw:
1✔
326
        if not isinstance(item, str):
1✔
327
            raise ValueError(f"schema '{namelist_name}' required entries must be strings")
×
328
        key = item.lower()
1✔
329
        if key not in properties:
1✔
330
            raise ValueError(
×
331
                f"schema '{namelist_name}' required entry '{item}' is not a property"
332
            )
333
        required.add(key)
1✔
334
    return required
1✔
335

336

337
def analyze_property_requirement(
1✔
338
    name: str,
339
    prop: Mapping[str, Any],
340
    *,
341
    declared_required: bool,
342
) -> PropertyRequirement:
343
    """Return operational-default coverage and effective input requiredness."""
344
    derived = _derived_object_schema(prop)
1✔
345
    if derived is None:
1✔
346
        has_default = _intrinsic_property_has_default(prop)
1✔
347
        return PropertyRequirement(
1✔
348
            declared_required=declared_required,
349
            required_defaults_complete=has_default,
350
            fully_default_initialized=has_default,
351
            requires_input=declared_required and not has_default,
352
            uncovered_required_components=frozenset(),
353
        )
354

355
    components = _normalized_derived_components(name, derived)
1✔
356
    required = _normalized_derived_required(name, derived, components)
1✔
357
    object_default = _normalized_derived_default_mapping(name, derived, components)
1✔
358
    defaulted = {
1✔
359
        key
360
        for key, (_, child) in components.items()
361
        if "default" in child or key in object_default
362
    }
363
    uncovered = frozenset(required - defaulted)
1✔
364
    required_defaults_complete = not uncovered
1✔
365
    if not declared_required and uncovered:
1✔
366
        missing = ", ".join(components[key][0] for key in sorted(uncovered))
1✔
367
        raise ValueError(
1✔
368
            f"optional derived property '{name}' has required components without defaults: "
369
            f"{missing}; declare the outer property required or provide effective defaults"
370
        )
371
    return PropertyRequirement(
1✔
372
        declared_required=declared_required,
373
        required_defaults_complete=required_defaults_complete,
374
        fully_default_initialized=len(defaulted) == len(components),
375
        requires_input=declared_required and not required_defaults_complete,
376
        uncovered_required_components=uncovered,
377
    )
378

379

380
def derived_component_defaults(name: str, prop: Mapping[str, Any]) -> dict[str, Any]:
1✔
381
    """Return effective derived component defaults keyed by lowercase component name."""
382
    derived = _derived_object_schema(prop)
1✔
383
    if derived is None:
1✔
384
        return {}
1✔
385
    components = _normalized_derived_components(name, derived)
1✔
386
    object_default = _normalized_derived_default_mapping(name, derived, components)
1✔
387
    defaults: dict[str, Any] = {}
1✔
388
    for key, (_, child) in components.items():
1✔
389
        if "default" in child:
1✔
390
            defaults[key] = child["default"]
1✔
391
        if key in object_default:
1✔
392
            defaults[key] = object_default[key]
1✔
393
    return defaults
1✔
394

395

396
def derived_object_default(name: str, prop: Mapping[str, Any]) -> dict[str, Any]:
1✔
397
    """Return the selected object/item default keyed by lowercase component name."""
398
    derived = _derived_object_schema(prop)
1✔
399
    if derived is None:
1✔
NEW
400
        return {}
×
401
    components = _normalized_derived_components(name, derived)
1✔
402
    return _normalized_derived_default_mapping(name, derived, components)
1✔
403

404

405
def _intrinsic_property_has_default(prop: Mapping[str, Any]) -> bool:
1✔
406
    if prop.get("type") != "array":
1✔
407
        return "default" in prop
1✔
408
    if "default" in prop:
1✔
409
        return True
1✔
410
    items = prop.get("items")
1✔
411
    return isinstance(items, Mapping) and "default" in items
1✔
412

413

414
def _normalized_derived_components(
1✔
415
    name: str,
416
    derived: Mapping[str, Any],
417
) -> dict[str, tuple[str, Mapping[str, Any]]]:
418
    raw = derived.get("properties")
1✔
419
    if not isinstance(raw, Mapping) or not raw:
1✔
NEW
420
        raise ValueError(f"derived property '{name}' must define object 'properties'")
×
421
    components: dict[str, tuple[str, Mapping[str, Any]]] = {}
1✔
422
    for child_name, child in raw.items():
1✔
423
        if not isinstance(child_name, str) or not isinstance(child, Mapping):
1✔
NEW
424
            raise ValueError(f"derived property '{name}' has an invalid component declaration")
×
425
        key = child_name.lower()
1✔
426
        if key in components:
1✔
427
            previous = components[key][0]
1✔
428
            raise ValueError(
1✔
429
                f"derived property '{name}' defines duplicate component '{child_name}' "
430
                f"matching '{previous}' case-insensitively"
431
            )
432
        components[key] = (child_name, child)
1✔
433
    return components
1✔
434

435

436
def _normalized_derived_required(
1✔
437
    name: str,
438
    derived: Mapping[str, Any],
439
    components: Mapping[str, tuple[str, Mapping[str, Any]]],
440
) -> set[str]:
441
    raw = derived.get("required", [])
1✔
442
    if raw is None:
1✔
NEW
443
        return set()
×
444
    if not isinstance(raw, list):
1✔
NEW
445
        raise ValueError(f"derived property '{name}' required must be a list")
×
446
    required: set[str] = set()
1✔
447
    for item in raw:
1✔
448
        if not isinstance(item, str):
1✔
NEW
449
            raise ValueError(f"derived property '{name}' required entries must be strings")
×
450
        key = item.lower()
1✔
451
        if key not in components:
1✔
452
            raise ValueError(
1✔
453
                f"derived property '{name}' required component '{item}' is not declared"
454
            )
455
        required.add(key)
1✔
456
    return required
1✔
457

458

459
def _normalized_derived_default_mapping(
1✔
460
    name: str,
461
    derived: Mapping[str, Any],
462
    components: Mapping[str, tuple[str, Mapping[str, Any]]],
463
) -> dict[str, Any]:
464
    if "default" not in derived:
1✔
465
        return {}
1✔
466
    raw = derived["default"]
1✔
467
    if not isinstance(raw, Mapping):
1✔
NEW
468
        raise ValueError(f"derived property '{name}' object default must be a mapping")
×
469
    normalized: dict[str, Any] = {}
1✔
470
    spelling: dict[str, str] = {}
1✔
471
    for child_name, value in raw.items():
1✔
472
        if not isinstance(child_name, str):
1✔
NEW
473
            raise ValueError(f"derived property '{name}' object default keys must be strings")
×
474
        key = child_name.lower()
1✔
475
        if key in normalized:
1✔
NEW
476
            raise ValueError(
×
477
                f"derived property '{name}' object default defines duplicate component "
478
                f"'{child_name}' matching '{spelling[key]}' case-insensitively"
479
            )
480
        if key not in components:
1✔
NEW
481
            raise ValueError(
×
482
                f"derived property '{name}' object default has unknown component '{child_name}'"
483
            )
484
        normalized[key] = value
1✔
485
        spelling[key] = child_name
1✔
486
    return normalized
1✔
487

488

489
def _validate_derived_object_default(
1✔
490
    name: str,
491
    derived: Mapping[str, Any],
492
    *,
493
    constants: dict[str, int] | None,
494
    dimensions: dict[str, int] | None,
495
) -> dict[str, Any]:
496
    components = _normalized_derived_components(name, derived)
1✔
497
    normalized = _normalized_derived_default_mapping(name, derived, components)
1✔
498
    for key, value in normalized.items():
1✔
499
        child_name, child = components[key]
1✔
500
        _validate_scalar_default(
1✔
501
            f"{name}.{child_name}",
502
            child,
503
            value,
504
            constants=constants,
505
            dimensions=dimensions,
506
        )
507
    return normalized
1✔
508

509

510
def _derived_object_schema(prop: Mapping[str, Any]) -> Mapping[str, Any] | None:
1✔
511
    if prop.get("type") == "object":
1✔
512
        return prop
1✔
513
    if prop.get("type") == "array":
1✔
514
        items = prop.get("items")
1✔
515
        if isinstance(items, Mapping) and items.get("type") == "object":
1✔
516
            return items
1✔
517
    return None
1✔
518

519

520
def _scalar_constraints(
1✔
521
    name: str,
522
    prop: Mapping[str, Any],
523
    category: str,
524
    constants: dict[str, int] | None,
525
    dimensions: dict[str, int] | None,
526
) -> ScalarConstraints:
527
    length = None
1✔
528
    if category == "string":
1✔
529
        length = _parse_length(prop, constants, dimensions, name)
1✔
530
    enum_values, enum_trimmed = _parse_enum(prop, category, length, name)
1✔
531
    min_value, min_exclusive = _extract_bound(prop, "minimum", "exclusiveMinimum", name)
1✔
532
    max_value, max_exclusive = _extract_bound(prop, "maximum", "exclusiveMaximum", name)
1✔
533
    if min_value is not None or max_value is not None:
1✔
534
        if category not in {"integer", "number"}:
1✔
535
            raise ValueError(f"property '{name}' bounds require integer or number")
×
536
        _validate_bound_scalar(min_value, category, name, "minimum")
1✔
537
        _validate_bound_scalar(max_value, category, name, "maximum")
1✔
538
        _validate_bound_range(min_value, max_value, min_exclusive, max_exclusive, name)
1✔
539
    return ScalarConstraints(
1✔
540
        category=category,
541
        length=length,
542
        enum_values=enum_values,
543
        enum_trimmed=enum_trimmed,
544
        min_value=min_value,
545
        max_value=max_value,
546
        min_exclusive=min_exclusive,
547
        max_exclusive=max_exclusive,
548
    )
549

550

551
def _parse_length(
1✔
552
    prop: Mapping[str, Any],
553
    constants: dict[str, int] | None,
554
    dimensions: dict[str, int] | None,
555
    name: str,
556
) -> int:
557
    raw = prop.get("x-fortran-len")
1✔
558
    if isinstance(raw, bool) or raw is None:
1✔
559
        raise ValueError(f"string property '{name}' must define 'x-fortran-len'")
×
560
    if isinstance(raw, int):
1✔
561
        if raw <= 0:
1✔
562
            raise ValueError(f"string property '{name}' length must be positive")
×
563
        return raw
1✔
564
    if isinstance(raw, str):
1✔
565
        token = raw.strip()
1✔
566
        if not token:
1✔
567
            raise ValueError(f"string property '{name}' length must be non-empty")
×
568
        if _is_int_literal(token):
1✔
569
            length = int(token)
×
570
            if length <= 0:
×
571
                raise ValueError(f"string property '{name}' length must be positive")
×
572
            return length
×
573
        if not FORTRAN_IDENTIFIER.match(token):
1✔
574
            raise ValueError(f"string property '{name}' length must be literal or identifier")
×
575
        token_key = token.lower()
1✔
576
        if dimensions is not None and token_key in dimensions:
1✔
577
            raise ValueError(
×
578
                f"string property '{name}' length must not use runtime dimension '{token}'"
579
            )
580
        if constants is None or token_key not in constants:
1✔
581
            raise ValueError(f"string property '{name}' length constant '{token}' not defined")
×
582
        value = constants[token_key]
1✔
583
        if isinstance(value, bool) or not isinstance(value, int):
1✔
584
            raise ValueError(f"string property '{name}' length constant '{token}' must be int")
×
585
        if value <= 0:
1✔
586
            raise ValueError(f"string property '{name}' length constant '{token}' must be positive")
×
587
        return value
1✔
588
    raise ValueError(f"string property '{name}' must define 'x-fortran-len'")
×
589

590

591
def _parse_enum(
1✔
592
    prop: Mapping[str, Any],
593
    category: str,
594
    length: int | None,
595
    name: str,
596
) -> tuple[tuple[int | str, ...] | None, tuple[str, ...] | None]:
597
    if "enum" not in prop:
1✔
598
        return None, None
1✔
599
    enum_raw = prop.get("enum")
1✔
600
    if not isinstance(enum_raw, list) or not enum_raw:
1✔
601
        raise ValueError(f"property '{name}' enum must be a non-empty list")
×
602
    if category == "integer":
1✔
603
        values: list[int] = []
1✔
604
        for item in enum_raw:
1✔
605
            if isinstance(item, bool) or not isinstance(item, int):
1✔
606
                raise ValueError(f"property '{name}' enum values must be integers")
×
607
            values.append(item)
1✔
608
        return tuple(values), None
1✔
609
    if category == "string":
1✔
610
        values_str: list[str] = []
1✔
611
        trimmed: list[str] = []
1✔
612
        for item in enum_raw:
1✔
613
            if not isinstance(item, str):
1✔
614
                raise ValueError(f"property '{name}' enum values must be strings")
×
615
            if length is not None and len(item) > length:
1✔
616
                raise ValueError(f"property '{name}' enum value '{item}' exceeds length")
×
617
            values_str.append(item)
1✔
618
            trimmed.append(item.rstrip())
1✔
619
        return tuple(values_str), tuple(trimmed)
1✔
620
    raise ValueError(f"property '{name}' enum only supports strings or integers")
×
621

622

623
def _extract_bound(
1✔
624
    prop: Mapping[str, Any],
625
    inclusive_key: str,
626
    exclusive_key: str,
627
    name: str,
628
) -> tuple[int | float | None, bool]:
629
    has_inclusive = inclusive_key in prop
1✔
630
    has_exclusive = exclusive_key in prop
1✔
631
    if has_inclusive and has_exclusive:
1✔
632
        raise ValueError(
×
633
            f"property '{name}' must not define both '{inclusive_key}' and '{exclusive_key}'"
634
        )
635
    if has_exclusive:
1✔
636
        value = prop.get(exclusive_key)
×
637
        if value is None:
×
638
            raise ValueError(f"property '{name}' {exclusive_key} must be a number")
×
639
        return _ensure_number(value, name, exclusive_key), True
×
640
    if has_inclusive:
1✔
641
        value = prop.get(inclusive_key)
1✔
642
        if value is None:
1✔
643
            raise ValueError(f"property '{name}' {inclusive_key} must be a number")
×
644
        return _ensure_number(value, name, inclusive_key), False
1✔
645
    return None, False
1✔
646

647

648
def _ensure_number(value: object, name: str, label: str) -> int | float:
1✔
649
    if isinstance(value, bool) or not isinstance(value, (int, float)):
1✔
650
        raise ValueError(f"property '{name}' {label} must be a number")
×
651
    return value
1✔
652

653

654
def _validate_bound_scalar(
1✔
655
    value: int | float | None,
656
    category: str,
657
    name: str,
658
    label: str,
659
) -> None:
660
    if value is None:
1✔
661
        return
1✔
662
    if category == "integer":
1✔
663
        if isinstance(value, bool) or not isinstance(value, int):
1✔
664
            raise ValueError(f"property '{name}' {label} must be an integer")
×
665
        return
1✔
666
    if category == "number":
1✔
667
        if isinstance(value, bool) or not isinstance(value, (int, float)):
1✔
668
            raise ValueError(f"property '{name}' {label} must be a number")
×
669
        if math.isinf(float(value)):
1✔
670
            raise ValueError(f"property '{name}' {label} must not be infinite")
×
671
        if math.isnan(float(value)):
1✔
672
            raise ValueError(f"property '{name}' {label} must not be NaN")
×
673
        return
1✔
674
    raise ValueError(f"property '{name}' bounds only support integers or numbers")
×
675

676

677
def _validate_bound_range(
1✔
678
    min_value: int | float | None,
679
    max_value: int | float | None,
680
    min_exclusive: bool,
681
    max_exclusive: bool,
682
    name: str,
683
) -> None:
684
    if min_value is None or max_value is None:
1✔
685
        return
1✔
686
    min_comp = float(min_value)
1✔
687
    max_comp = float(max_value)
1✔
688
    if min_exclusive or max_exclusive:
1✔
689
        if min_comp >= max_comp:
×
690
            raise ValueError(f"property '{name}' minimum must be < maximum for exclusive bounds")
×
691
    else:
692
        if min_comp > max_comp:
1✔
693
            raise ValueError(f"property '{name}' minimum must be <= maximum")
×
694

695

696
def _validate_scalar_value(
1✔
697
    name: str,
698
    value: Any,
699
    constraints: ScalarConstraints,
700
) -> None:
701
    category = constraints.category
1✔
702
    if category == "integer":
1✔
703
        if isinstance(value, bool) or not isinstance(value, int):
1✔
704
            raise ValueError(f"property '{name}' must be an integer")
×
705
        if constraints.enum_values is not None and value not in constraints.enum_values:
1✔
706
            raise ValueError(f"property '{name}' has value outside enum")
1✔
707
        _validate_value_bounds(value, constraints, name)
1✔
708
        return
1✔
709
    if category == "number":
1✔
710
        if isinstance(value, bool) or not isinstance(value, (int, float)):
1✔
711
            raise ValueError(f"property '{name}' must be a number")
×
712
        if math.isnan(float(value)):
1✔
713
            raise ValueError(f"property '{name}' must not be NaN")
1✔
714
        if math.isinf(float(value)):
1✔
715
            raise ValueError(f"property '{name}' must not be infinite")
1✔
716
        _validate_value_bounds(float(value), constraints, name)
1✔
717
        return
1✔
718
    if category == "boolean":
1✔
719
        if not isinstance(value, bool):
1✔
720
            raise ValueError(f"property '{name}' must be boolean")
×
721
        return
1✔
722
    if category == "string":
1✔
723
        if not isinstance(value, str):
1✔
724
            raise ValueError(f"property '{name}' must be a string")
×
725
        if constraints.length is not None and len(value) > constraints.length:
1✔
726
            raise ValueError(f"property '{name}' exceeds length {constraints.length}")
1✔
727
        if constraints.enum_trimmed is not None:
1✔
728
            if value.rstrip() not in constraints.enum_trimmed:
1✔
729
                raise ValueError(f"property '{name}' has value outside enum")
×
730
        return
1✔
731
    raise ValueError(f"property '{name}' has unsupported type '{category}'")
×
732

733

734
def _validate_value_bounds(
1✔
735
    value: int | float,
736
    constraints: ScalarConstraints,
737
    name: str,
738
) -> None:
739
    if constraints.min_value is not None:
1✔
740
        if constraints.min_exclusive:
1✔
741
            if value <= constraints.min_value:
×
742
                raise ValueError(f"property '{name}' must be > {constraints.min_value}")
×
743
        else:
744
            if value < constraints.min_value:
1✔
745
                raise ValueError(f"property '{name}' must be >= {constraints.min_value}")
1✔
746
    if constraints.max_value is not None:
1✔
747
        if constraints.max_exclusive:
1✔
748
            if value >= constraints.max_value:
×
749
                raise ValueError(f"property '{name}' must be < {constraints.max_value}")
×
750
        else:
751
            if value > constraints.max_value:
1✔
752
                raise ValueError(f"property '{name}' must be <= {constraints.max_value}")
×
753

754

755
def _parse_shape(
1✔
756
    raw: Any,
757
    constants: dict[str, int] | None,
758
    name: str,
759
) -> list[int | None]:
760
    shape_entries: list[Any]
761
    if isinstance(raw, bool) or raw is None:
1✔
762
        raise ValueError(f"array property '{name}' must define non-empty x-fortran-shape")
×
763
    if isinstance(raw, (int, str)):
1✔
764
        shape_entries = [raw]
1✔
765
    elif isinstance(raw, list) and raw:
1✔
766
        shape_entries = raw
1✔
767
    else:
768
        raise ValueError(f"array property '{name}' must define non-empty x-fortran-shape")
×
769

770
    parsed: list[int | None] = []
1✔
771
    for dim in shape_entries:
1✔
772
        if isinstance(dim, bool):
1✔
773
            raise ValueError(f"array property '{name}' shape entries must be int or str")
×
774
        if isinstance(dim, int):
1✔
775
            if dim <= 0:
1✔
776
                raise ValueError(f"array property '{name}' shape values must be positive")
×
777
            parsed.append(dim)
1✔
778
            continue
1✔
779
        if isinstance(dim, str):
1✔
780
            token = dim.strip()
1✔
781
            if not token:
1✔
782
                raise ValueError(f"array property '{name}' shape entries must be non-empty")
×
783
            if token == ":":
1✔
784
                parsed.append(None)
1✔
785
                continue
1✔
786
            if _is_int_literal(token):
1✔
787
                size = int(token)
×
788
                if size <= 0:
×
789
                    raise ValueError(f"array property '{name}' shape values must be positive")
×
790
                parsed.append(size)
×
791
                continue
×
792
            if not FORTRAN_IDENTIFIER.match(token):
1✔
793
                raise ValueError(
×
794
                    f"array property '{name}' shape entries must be ints or identifiers"
795
                )
796
            token_key = token.lower()
1✔
797
            if constants is None or token_key not in constants:
1✔
798
                raise ValueError(f"array property '{name}' constant '{token}' not defined")
×
799
            value = constants[token_key]
1✔
800
            if isinstance(value, bool) or not isinstance(value, int):
1✔
801
                raise ValueError(f"array property '{name}' constant '{token}' must be int")
×
802
            if value <= 0:
1✔
803
                raise ValueError(f"array property '{name}' constant '{token}' must be positive")
×
804
            parsed.append(value)
1✔
805
            continue
1✔
806
        raise ValueError(f"array property '{name}' shape entries must be int or str")
×
807
    return parsed
1✔
808

809

810
def _parse_flex_tail_dims(
1✔
811
    prop: Mapping[str, Any],
812
    rank: int,
813
    name: str,
814
    dimensions: Iterable[int | None],
815
) -> int:
816
    raw = prop.get("x-fortran-flex-tail-dims")
1✔
817
    if raw is None:
1✔
818
        flex = 0
1✔
819
    else:
820
        if isinstance(raw, bool) or not isinstance(raw, int):
1✔
821
            raise ValueError(f"array property '{name}' flex tail dims must be an integer")
×
822
        flex = raw
1✔
823
    if flex < 0:
1✔
824
        raise ValueError(f"array property '{name}' flex tail dims must be >= 0")
×
825
    if flex == 0:
1✔
826
        return 0
1✔
827
    if flex > rank:
1✔
828
        raise ValueError(f"array property '{name}' flex tail dims must not exceed rank")
×
829
    if any(dim is None for dim in dimensions):
1✔
830
        raise ValueError(f"array property '{name}' flex tail dims require concrete shape")
×
831
    return flex
1✔
832

833

834
def _iter_scalars(value: Any) -> Iterable[Any]:
1✔
835
    if isinstance(value, (list, tuple)):
1✔
836
        for item in value:
1✔
837
            yield from _iter_scalars(item)
1✔
838
        return
1✔
839
    yield value
1✔
840

841

842
def _is_int_literal(value: str) -> bool:
1✔
843
    try:
1✔
844
        int(value)
1✔
845
    except ValueError:
1✔
846
        return False
1✔
847
    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