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

MuellerSeb / nml-tools / 26040350358

18 May 2026 02:36PM UTC coverage: 58.354% (+1.9%) from 56.443%
26040350358

Pull #19

github

MuellerSeb
Add Python wrapper handle invalidation
Pull Request #19: F2py wrapper support

233 of 349 new or added lines in 4 files covered. (66.76%)

1 existing line in 1 file now uncovered.

1652 of 2831 relevant lines covered (58.35%)

0.58 hits per line

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

82.19
/src/nml_tools/codegen_fortran.py
1
"""Fortran code generation."""
2

3
from __future__ import annotations
1✔
4

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

11
from jinja2 import Environment, FileSystemLoader, StrictUndefined
1✔
12

13
_TEMPLATE_ENV = Environment(
1✔
14
    loader=FileSystemLoader(Path(__file__).resolve().parent / "templates"),
15
    trim_blocks=True,
16
    lstrip_blocks=False,
17
    keep_trailing_newline=True,
18
    undefined=StrictUndefined,
19
)
20

21

22
@dataclass
1✔
23
class ScalarTypeInfo:
1✔
24
    """Information about a scalar Fortran type."""
25

26
    type_spec: str
1✔
27
    arg_type_spec: str
1✔
28
    kind: str | None
1✔
29
    category: str
1✔
30
    length_expr: str | None = None
1✔
31

32

33
@dataclass
1✔
34
class FieldTypeInfo:
1✔
35
    """Information about a field (scalar or array) Fortran type."""
36

37
    type_spec: str
1✔
38
    arg_type_spec: str
1✔
39
    dimensions: list[str]
1✔
40
    kind: str | None
1✔
41
    category: str
1✔
42
    length_expr: str | None = None
1✔
43
    element_category: str | None = None
1✔
44

45

46
@dataclass
1✔
47
class FieldSpec:
1✔
48
    """Information required to render a schema property."""
49

50
    order: int
1✔
51
    name: str
1✔
52
    title: str
1✔
53
    description: str | None
1✔
54
    declaration: str
1✔
55
    local_declaration: str
1✔
56
    required: bool
1✔
57
    sentinel_assignment: str | None
1✔
58
    sentinel_check: str | None
1✔
59
    default_assignment: str | None
1✔
60
    set_default_assignment: str | None
1✔
61
    set_present_assignment: str | None
1✔
62
    argument_declaration: str
1✔
63
    type_category: str
1✔
64

65

66
@dataclass
1✔
67
class ArrayDefaultSpec:
1✔
68
    """Normalized representation of an array default value."""
69

70
    source_values: list[Any]
1✔
71
    pad_values: list[Any] | None
1✔
72
    order_values: list[int] | None
1✔
73

74

75
@dataclass
1✔
76
class ConstantSpec:
1✔
77
    """Constant definition for helper modules."""
78

79
    name: str
1✔
80
    type_spec: str
1✔
81
    value: str
1✔
82
    doc: str | None
1✔
83

84

85
def generate_fortran(
1✔
86
    schema: dict[str, Any],
87
    output: str | Path,
88
    *,
89
    helper_module: str = "nml_helper",
90
    kind_module: str | None = None,
91
    kind_map: dict[str, str] | None = None,
92
    kind_allowlist: Iterable[str] | None = None,
93
    constants: dict[str, int | float] | None = None,
94
    module_doc: str | None = None,
95
    f2py_handle_helpers: bool = False,
96
) -> None:
97
    """Generate a Fortran module from *schema* at *output*."""
98
    output_path = Path(output)
1✔
99
    context = _build_context(
1✔
100
        schema,
101
        helper_module=helper_module,
102
        kind_module=kind_module,
103
        kind_map=kind_map,
104
        kind_allowlist=kind_allowlist,
105
        constants=constants,
106
        module_doc=module_doc,
107
        f2py_handle_helpers=f2py_handle_helpers,
108
    )
109
    context["file_name"] = output_path.name
1✔
110
    rendered = _TEMPLATE_ENV.get_template("fortran_module.f90.j2").render(context)
1✔
111
    output_path.parent.mkdir(parents=True, exist_ok=True)
1✔
112
    output_path.write_text(rendered, encoding="ascii")
1✔
113

114

115
def generate_helper(
1✔
116
    output: str | Path,
117
    *,
118
    module_name: str = "nml_helper",
119
    len_buf: int = 1024,
120
    constants: list[ConstantSpec] | None = None,
121
    module_doc: str | None = None,
122
    helper_header: str | None = None,
123
) -> None:
124
    """Generate the helper Fortran module at *output*."""
125
    if not module_name:
×
126
        raise ValueError("helper module name must be a non-empty string")
×
127
    if len_buf <= 0:
×
128
        raise ValueError("helper len_buf must be positive")
×
129
    output_path = Path(output)
×
130
    rendered = _TEMPLATE_ENV.get_template("nml_helper.f90.j2").render(
×
131
        {
132
            "file_name": output_path.name,
133
            "module_name": module_name,
134
            "len_buf": len_buf,
135
            "constants": constants or [],
136
            "module_doc": module_doc,
137
            "helper_header": helper_header,
138
        }
139
    )
140
    output_path.parent.mkdir(parents=True, exist_ok=True)
×
141
    output_path.write_text(rendered, encoding="ascii")
×
142

143

144
def _build_context(
1✔
145
    schema: dict[str, Any],
146
    *,
147
    helper_module: str,
148
    kind_module: str | None,
149
    kind_map: dict[str, str] | None,
150
    kind_allowlist: Iterable[str] | None,
151
    constants: dict[str, int | float] | None,
152
    module_doc: str | None,
153
    f2py_handle_helpers: bool = False,
154
) -> dict[str, Any]:
155
    if not helper_module:
1✔
156
        raise ValueError("helper module name must be a non-empty string")
×
157
    if "x-fortran-kind-module" in schema:
1✔
158
        raise ValueError("schema must not define 'x-fortran-kind-module'")
×
159
    namelist_name = schema.get("x-fortran-namelist")
1✔
160
    if not isinstance(namelist_name, str):
1✔
161
        raise ValueError("schema must define 'x-fortran-namelist'")
×
162

163
    if schema.get("type") != "object":
1✔
164
        raise ValueError("schema root must be of type 'object'")
×
165

166
    properties = schema.get("properties")
1✔
167
    if not isinstance(properties, dict) or not properties:
1✔
168
        raise ValueError("schema must define object 'properties'")
×
169

170
    property_items: list[tuple[str, str, dict[str, Any]]] = []
1✔
171
    property_name_map: dict[str, str] = {}
1✔
172
    for prop_name, prop in properties.items():
1✔
173
        if not isinstance(prop_name, str) or not prop_name.strip():
1✔
174
            raise ValueError("property names must be non-empty strings")
×
175
        key = prop_name.lower()
1✔
176
        if key in property_name_map:
1✔
177
            raise ValueError(
1✔
178
                "property names must be unique (case-insensitive): "
179
                f"'{property_name_map[key]}' and '{prop_name}'"
180
            )
181
        property_name_map[key] = prop_name
1✔
182
        if not isinstance(prop, dict):
1✔
183
            raise ValueError(f"property '{prop_name}' must be an object")
×
184
        property_items.append((prop_name, key, prop))
1✔
185

186
    required_fields_raw = _ordered_unique(schema.get("required", []))
1✔
187
    required_fields: list[str] = []
1✔
188
    for req_name in required_fields_raw:
1✔
189
        if not isinstance(req_name, str):
1✔
190
            raise ValueError("schema 'required' entries must be strings")
×
191
        req_key = req_name.lower()
1✔
192
        if req_key not in property_name_map:
1✔
193
            raise ValueError(f"required property '{req_name}' is not defined")
1✔
194
        if req_key not in required_fields:
1✔
195
            required_fields.append(req_key)
1✔
196
    required_set = set(required_fields)
1✔
197
    module_name = f"nml_{namelist_name}"
1✔
198
    type_name = f"{module_name}_t"
1✔
199
    doc_class = f"{module_name}_t"
1✔
200
    brief_text = schema.get("title", namelist_name)
1✔
201
    details_text = schema.get("description", brief_text)
1✔
202

203
    fields: list[FieldSpec] = []
1✔
204
    sentinel_assignments: list[str] = []
1✔
205
    default_assignments: list[str] = []
1✔
206
    set_optional_defaults: list[str] = []
1✔
207
    local_init_assignments: list[str] = []
1✔
208
    set_required_assignments: list[str] = []
1✔
209
    presence_cases: list[dict[str, Any]] = []
1✔
210
    required_scalar_names: set[str] = set()
1✔
211
    required_array_by_name: dict[str, dict[str, Any]] = {}
1✔
212
    flex_bound_vars: set[str] = set()
1✔
213
    flex_arrays: list[dict[str, Any]] = []
1✔
214
    default_parameters: list[str] = []
1✔
215
    enum_parameters: list[str] = []
1✔
216
    enum_functions: list[dict[str, Any]] = []
1✔
217
    enum_checks: list[dict[str, Any]] = []
1✔
218
    bounds_parameters: list[str] = []
1✔
219
    bounds_functions: list[dict[str, Any]] = []
1✔
220
    bounds_checks: list[dict[str, Any]] = []
1✔
221
    kind_ids: list[str] = []
1✔
222
    requires_ieee = False
1✔
223
    uses_partly_set = False
1✔
224
    helper_imports = [
1✔
225
        "nml_file_t",
226
        "nml_line_buffer",
227
        "NML_OK",
228
        "NML_ERR_FILE_NOT_FOUND",
229
        "NML_ERR_OPEN",
230
        "NML_ERR_NOT_OPEN",
231
        "NML_ERR_NML_NOT_FOUND",
232
        "NML_ERR_READ",
233
        "NML_ERR_CLOSE",
234
        "NML_ERR_REQUIRED",
235
        "NML_ERR_ENUM",
236
        "NML_ERR_BOUNDS",
237
        "NML_ERR_NOT_SET",
238
        "NML_ERR_INVALID_NAME",
239
        "NML_ERR_INVALID_INDEX",
240
        "idx_check",
241
        "to_lower",
242
    ]
243
    if f2py_handle_helpers:
1✔
NEW
244
        helper_imports.append("NML_ERR_INVALID_HANDLE")
×
245

246
    current_property: str | None = None
1✔
247
    try:
1✔
248
        for index, (display_name, attr_name, prop) in enumerate(property_items):
1✔
249
            current_property = display_name
1✔
250
            name = attr_name
1✔
251
            type_info = _field_type_info(prop, constants)
1✔
252
            for const_name in _collect_dimension_constants(type_info.dimensions, constants):
1✔
253
                if const_name not in helper_imports:
1✔
254
                    helper_imports.append(const_name)
1✔
255
            if type_info.length_expr and not _is_int_literal(type_info.length_expr):
1✔
256
                if type_info.length_expr not in helper_imports:
1✔
257
                    helper_imports.append(type_info.length_expr)
1✔
258
            if type_info.kind:
1✔
259
                kind_ids.append(type_info.kind)
1✔
260

261
            array_default_info: tuple[Any, bool] | None = None
1✔
262
            if type_info.category == "array":
1✔
263
                array_default_info = _array_default_value(prop)
1✔
264

265
            flex_dim = _parse_flex_dim(prop, type_info)
1✔
266
            if flex_dim > 0:
1✔
267
                if type_info.element_category == "boolean":
1✔
268
                    raise ValueError("flex arrays cannot use boolean elements")
1✔
269
                if array_default_info is not None or any(
1✔
270
                    key in prop
271
                    for key in (
272
                        "x-fortran-default-order",
273
                        "x-fortran-default-repeat",
274
                        "x-fortran-default-pad",
275
                    )
276
                ):
277
                    raise ValueError("flex arrays cannot define defaults")
1✔
278

279
            declaration = _render_declaration(type_info.type_spec, type_info.dimensions, name)
1✔
280
            title_raw = prop.get("title")
1✔
281
            if title_raw is None:
1✔
282
                title = display_name
1✔
283
            elif not isinstance(title_raw, str):
1✔
284
                raise ValueError(f"property '{display_name}' title must be a string")
×
285
            else:
286
                title = title_raw.strip() or name
1✔
287
            description = prop.get("description")
1✔
288
            declaration_with_doc = f"{declaration} !< {title}"
1✔
289

290
            local_decl = _render_declaration(type_info.type_spec, type_info.dimensions, name)
1✔
291

292
            is_required = name in required_set
1✔
293
            if type_info.category == "array":
1✔
294
                has_default = array_default_info is not None
1✔
295
            else:
296
                has_default = "default" in prop
1✔
297

298
            default_from_items = False
1✔
299
            default_values: list[Any] | None = None
1✔
300
            parsed_dims: list[int] | None = None
1✔
301
            array_default_spec: ArrayDefaultSpec | None = None
1✔
302

303
            if type_info.category == "array" and has_default:
1✔
304
                if array_default_info is None:
1✔
305
                    raise ValueError(f"missing array default for '{display_name}'")
×
306
                default_raw, default_from_items = array_default_info
1✔
307
                if default_from_items:
1✔
308
                    if isinstance(default_raw, list):
1✔
309
                        raise ValueError("array items default must be a scalar")
×
310
                    default_values = [default_raw]
1✔
311
                else:
312
                    if not isinstance(default_raw, list):
1✔
313
                        raise ValueError("array default must be a list")
×
314
                    default_values = default_raw
1✔
315
                    parsed_dims = _parse_default_dimensions(type_info.dimensions, constants)
1✔
316
                    array_default_spec = _prepare_array_default(default_values, parsed_dims, prop)
1✔
317

318
            needs_sentinel = (not is_required) and (not has_default)
1✔
319
            requires_sentinel = is_required or needs_sentinel
1✔
320

321
            arg_dimensions = type_info.dimensions
1✔
322
            if type_info.category == "array" and (flex_dim > 0 or has_default):
1✔
323
                arg_dimensions = [":" for _ in type_info.dimensions]
1✔
324
            argument_decl = _render_argument_declaration(
1✔
325
                name=name,
326
                type_info=type_info,
327
                is_required=is_required,
328
                dimensions=arg_dimensions,
329
                doc=title,
330
            )
331
            local_init_assignments.append(f"{name} = this%{name}")
1✔
332

333
            sentinel_assignment: str | None = None
1✔
334
            sentinel_condition: str | None = None
1✔
335
            set_sentinel_condition: str | None = None
1✔
336
            if requires_sentinel:
1✔
337
                if is_required and type_info.category == "boolean":
1✔
338
                    raise ValueError(
×
339
                        f"required {type_info.category} '{display_name}' is not supported"
340
                    )
341
                if is_required and type_info.category == "array":
1✔
342
                    if type_info.element_category == "boolean":
1✔
343
                        raise ValueError("required boolean arrays are not supported")
×
344
                if needs_sentinel and type_info.category == "boolean":
1✔
345
                    raise ValueError(f"optional boolean '{display_name}' must define a default")
×
346
                if needs_sentinel and type_info.category == "array":
1✔
347
                    if type_info.element_category == "boolean":
1✔
348
                        raise ValueError("optional boolean arrays must define a default")
×
349
                value_expr, condition_expr, uses_ieee = _sentinel_expressions(
1✔
350
                    type_info,
351
                    var_ref=f"this%{name}",
352
                )
353
                sentinel_assignment = (
1✔
354
                    f"this%{name} = {value_expr}"
355
                    f"{_sentinel_comment(type_info, required=is_required)}"
356
                )
357
                sentinel_condition = condition_expr
1✔
358
                sentinel_assignments.append(sentinel_assignment)
1✔
359
                if uses_ieee:
1✔
360
                    requires_ieee = True
1✔
361
                if not has_default and type_info.category != "boolean":
1✔
362
                    set_value_expr, set_condition_expr, set_uses_ieee = _sentinel_expressions(
1✔
363
                        type_info,
364
                        var_ref=f"this%{name}",
365
                    )
366
                    if set_uses_ieee:
1✔
367
                        requires_ieee = True
1✔
368
                    set_sentinel_condition = set_condition_expr
1✔
369
                    if needs_sentinel:
1✔
370
                        sent_com = _sentinel_comment(type_info, required=False)
1✔
371
                        set_optional_defaults.append(f"this%{name} = {set_value_expr}{sent_com}")
1✔
372

373
            if is_required and type_info.category != "array":
1✔
374
                required_scalar_names.add(name)
1✔
375

376
            if is_required and type_info.category == "array" and flex_dim == 0:
1✔
377
                element_category = type_info.element_category
1✔
378
                if element_category is None:
1✔
379
                    raise ValueError("array field missing element category")
×
380
                all_missing, any_missing, uses_ieee = _array_missing_conditions(
1✔
381
                    element_category,
382
                    var_ref=f"this%{name}",
383
                    len_ref=f"this%{name}",
384
                )
385
                required_array_by_name[name] = {
1✔
386
                    "name": display_name,
387
                    "attr_name": name,
388
                    "all_missing_condition": all_missing,
389
                    "any_missing_condition": any_missing,
390
                }
391
                uses_partly_set = True
1✔
392
                if uses_ieee:
1✔
393
                    requires_ieee = True
1✔
394

395
            flex_bounds: list[dict[str, Any]] | None = None
1✔
396
            partial_bounds: list[dict[str, Any]] | None = None
1✔
397
            if flex_dim > 0:
1✔
398
                rank = len(type_info.dimensions)
1✔
399
                element_category = type_info.element_category
1✔
400
                if element_category is None:
1✔
401
                    raise ValueError("array field missing element category")
×
402
                flex_dims = list(range(rank - flex_dim + 1, rank + 1))
1✔
403
                slice_missing_conditions: list[str] = []
1✔
404
                slice_uses_ieee = False
1✔
405
                bounds: list[dict[str, Any]] = []
1✔
406
                lb_vars: dict[int, str] = {}
1✔
407
                ub_vars: dict[int, str] = {}
1✔
408
                for dim in flex_dims:
1✔
409
                    lb_var, ub_var = _flex_bound_vars(dim)
1✔
410
                    lb_vars[dim] = lb_var
1✔
411
                    ub_vars[dim] = ub_var
1✔
412
                    bounds.append({"dim": dim, "lb_var": lb_var, "ub_var": ub_var})
1✔
413
                    flex_bound_vars.add(lb_var)
1✔
414
                    flex_bound_vars.add(ub_var)
1✔
415
                    slice_ref = _slice_ref(name, rank, dim, "idx")
1✔
416
                    slice_missing_expr, uses_ieee = _element_missing_expression(
1✔
417
                        element_category,
418
                        var_ref=slice_ref,
419
                        len_ref=f"this%{name}",
420
                    )
421
                    slice_missing_conditions.append(
1✔
422
                        f"all({slice_missing_expr})" if rank > 1 else slice_missing_expr
423
                    )
424
                    slice_uses_ieee = slice_uses_ieee or uses_ieee
1✔
425
                prefix_ref = _slice_ref_bounds(name, rank, flex_dims, lb_vars, ub_vars)
1✔
426
                prefix_missing_expr, uses_ieee_prefix = _element_missing_expression(
1✔
427
                    element_category,
428
                    var_ref=prefix_ref,
429
                    len_ref=f"this%{name}",
430
                )
431
                prefix_any_missing_condition = f"any({prefix_missing_expr})"
1✔
432
                flex_bounds = bounds
1✔
433
                flex_arrays.append(
1✔
434
                    {
435
                        "name": name,
436
                        "display_name": display_name,
437
                        "rank": rank,
438
                        "flex_dims": flex_dims,
439
                        "required": is_required,
440
                        "bounds": bounds,
441
                        "slice_missing_conditions": slice_missing_conditions,
442
                        "prefix_any_missing_condition": prefix_any_missing_condition,
443
                    }
444
                )
445
                uses_partly_set = True
1✔
446
                if slice_uses_ieee or uses_ieee_prefix:
1✔
447
                    requires_ieee = True
×
448
            elif type_info.category == "array" and has_default:
1✔
449
                rank = len(type_info.dimensions)
1✔
450
                bounds = []
1✔
451
                for dim in range(1, rank + 1):
1✔
452
                    lb_var, ub_var = _flex_bound_vars(dim)
1✔
453
                    bounds.append({"dim": dim, "lb_var": lb_var, "ub_var": ub_var})
1✔
454
                    flex_bound_vars.add(lb_var)
1✔
455
                    flex_bound_vars.add(ub_var)
1✔
456
                partial_bounds = bounds
1✔
457

458
            default_assignment: str | None = None
1✔
459
            set_default_assignment: str | None = None
1✔
460
            if has_default and is_required:
1✔
461
                raise ValueError(f"required property '{display_name}' cannot define a default")
×
462
            if has_default:
1✔
463
                default_const_name = f"{name}_default"
1✔
464
                if type_info.category == "array":
1✔
465
                    if default_values is None:
1✔
466
                        raise ValueError(f"missing array default for '{display_name}'")
×
467
                    default_is_scalar = default_from_items
1✔
468

469
                    repeat = False
1✔
470
                    pad_raw = None
1✔
471
                    pad_const_name: str | None = None
1✔
472
                    pad_is_scalar = False
1✔
473

474
                    if not default_from_items:
1✔
475
                        repeat_raw = prop.get("x-fortran-default-repeat", False)
1✔
476
                        if not isinstance(repeat_raw, bool):
1✔
477
                            raise ValueError("array default repeat must be a boolean")
×
478
                        repeat = bool(repeat_raw)
1✔
479
                        pad_raw = prop.get("x-fortran-default-pad")
1✔
480
                        if pad_raw is not None:
1✔
481
                            pad_const_name = f"{name}_pad"
1✔
482
                            pad_is_scalar = not isinstance(pad_raw, list)
1✔
483
                            pad_values = pad_raw if isinstance(pad_raw, list) else [pad_raw]
1✔
484
                            pad_values = _ensure_flat_scalar_list(pad_values, "array default pad")
1✔
485
                            pad_elements = [
1✔
486
                                _format_scalar_default(
487
                                    element, type_info.kind, type_info.element_category
488
                                )
489
                                for element in pad_values
490
                            ]
491
                            if pad_is_scalar:
1✔
492
                                pad_literal = _format_scalar_default(
1✔
493
                                    pad_raw, type_info.kind, type_info.element_category
494
                                )
495
                                default_parameters.append(
1✔
496
                                    f"{type_info.type_spec}, parameter, public :: "
497
                                    f"{pad_const_name} = {pad_literal}"
498
                                )
499
                            else:
500
                                default_parameters.append(
×
501
                                    f"{type_info.type_spec}, parameter, public :: "
502
                                    f"{pad_const_name}({len(pad_elements)}) = "
503
                                    f"[{', '.join(pad_elements)}]"
504
                                )
505

506
                        if parsed_dims is None:
1✔
507
                            parsed_dims = _parse_default_dimensions(type_info.dimensions, constants)
×
508
                        if array_default_spec is None:
1✔
509
                            array_default_spec = _prepare_array_default(
×
510
                                default_values, parsed_dims, prop
511
                            )
512

513
                    if default_is_scalar:
1✔
514
                        default_literal = _format_scalar_default(
1✔
515
                            default_values[0], type_info.kind, type_info.element_category
516
                        )
517
                        default_parameters.append(
1✔
518
                            f"{type_info.type_spec}, parameter, public :: "
519
                            f"{default_const_name} = {default_literal}"
520
                        )
521
                    else:
522
                        if array_default_spec is None:
1✔
523
                            raise ValueError(
×
524
                                f"missing array default specification for '{display_name}'"
525
                            )
526
                        default_elements = [
1✔
527
                            _format_scalar_default(
528
                                element, type_info.kind, type_info.element_category
529
                            )
530
                            for element in array_default_spec.source_values
531
                        ]
532
                        default_parameters.append(
1✔
533
                            f"{type_info.type_spec}, parameter, public :: "
534
                            f"{default_const_name}({len(default_elements)}) = "
535
                            f"[{', '.join(default_elements)}]"
536
                        )
537

538
                    if default_from_items:
1✔
539
                        default_assignment = f"this%{name} = {default_const_name}"
1✔
540
                    elif (
1✔
541
                        len(type_info.dimensions) == 1
542
                        and array_default_spec is not None
543
                        and array_default_spec.order_values is None
544
                        and array_default_spec.pad_values is None
545
                    ):
546
                        default_assignment = f"this%{name} = {default_const_name}"
×
547
                    else:
548
                        source_expr = default_const_name
1✔
549
                        shape_expr = ", ".join(type_info.dimensions)
1✔
550
                        arguments = [source_expr, f"shape=[{shape_expr}]"]
1✔
551
                        if array_default_spec is not None:
1✔
552
                            if array_default_spec.order_values is not None:
1✔
553
                                order_literal = ", ".join(
1✔
554
                                    str(index) for index in array_default_spec.order_values
555
                                )
556
                                arguments.append(f"order=[{order_literal}]")
1✔
557
                            if array_default_spec.pad_values is not None:
1✔
558
                                if repeat:
1✔
559
                                    pad_expr = default_const_name
1✔
560
                                else:
561
                                    if pad_const_name is None:
1✔
562
                                        raise ValueError(
×
563
                                            f"missing pad values for array default '{display_name}'"
564
                                        )
565
                                    pad_expr = (
1✔
566
                                        pad_const_name
567
                                        if not pad_is_scalar
568
                                        else f"[{pad_const_name}]"
569
                                    )
570
                                arguments.append(f"pad={pad_expr}")
1✔
571
                        default_assignment = _format_reshape_assignment(name, arguments)
1✔
572
                    set_default_assignment = default_assignment
1✔
573
                else:
574
                    default_literal = _format_default(prop["default"], type_info, prop, constants)
1✔
575
                    default_parameters.append(
1✔
576
                        f"{type_info.type_spec}, parameter, public :: "
577
                        f"{default_const_name} = {default_literal}"
578
                    )
579
                    if type_info.category == "boolean":
1✔
580
                        default_assignment = (
1✔
581
                            f"this%{name} = {default_const_name} "
582
                            "! bool values always need a default"
583
                        )
584
                    else:
585
                        default_assignment = f"this%{name} = {default_const_name}"
1✔
586
                    set_default_assignment = f"this%{name} = {default_const_name}"
1✔
587

588
                if default_assignment is None or set_default_assignment is None:
1✔
589
                    raise ValueError(f"missing default assignment for '{display_name}'")
×
590
                default_assignments.append(default_assignment)
1✔
591
                set_optional_defaults.append(set_default_assignment)
1✔
592

593
            enum_values = _enum_values(prop, type_info, constants)
1✔
594
            if enum_values is not None:
1✔
595
                enum_category = _enum_category(type_info)
1✔
596
                enum_const_name = f"{name}_enum_values"
1✔
597
                enum_literals = [
1✔
598
                    _format_scalar_default(value, type_info.kind, enum_category)
599
                    for value in enum_values
600
                ]
601
                if enum_category == "string":
1✔
602
                    enum_array_literal = (
1✔
603
                        f"[{type_info.type_spec} :: {', '.join(enum_literals)}]"
604
                    )
605
                else:
606
                    enum_array_literal = f"[{', '.join(enum_literals)}]"
1✔
607
                if enum_category == "string":
1✔
608
                    enum_parameters.append(
1✔
609
                        f"{type_info.type_spec}, parameter, public :: &\n"
610
                        f"    {enum_const_name}({len(enum_literals)}) = {enum_array_literal}"
611
                    )
612
                else:
613
                    enum_parameters.append(
1✔
614
                        f"{type_info.type_spec}, parameter, public :: "
615
                        f"{enum_const_name}({len(enum_literals)}) = {enum_array_literal}"
616
                    )
617
                enum_type_info = (
1✔
618
                    _element_type_info(type_info) if type_info.category == "array" else type_info
619
                )
620
                _, missing_condition, _ = _sentinel_expressions(
1✔
621
                    enum_type_info,
622
                    var_ref="val",
623
                    len_ref="val",
624
                )
625
                enum_functions.append(
1✔
626
                    {
627
                        "name": name,
628
                        "func_name": f"{name}_in_enum",
629
                        "arg_type_spec": _enum_arg_type_spec(type_info),
630
                        "enum_values_name": enum_const_name,
631
                        "use_trim": enum_category == "string",
632
                        "missing_condition": missing_condition,
633
                    }
634
                )
635
                if type_info.category == "array":
1✔
636
                    enum_checks.append(
1✔
637
                        {
638
                            "name": name,
639
                            "display_name": display_name,
640
                            "func_name": f"{name}_in_enum",
641
                            "is_array": True,
642
                            "array_ref": f"this%{name}",
643
                        }
644
                    )
645
                else:
646
                    enum_checks.append(
1✔
647
                        {
648
                            "name": name,
649
                            "display_name": display_name,
650
                            "func_name": f"{name}_in_enum",
651
                            "is_array": False,
652
                            "element_ref": f"this%{name}",
653
                        }
654
                    )
655

656
            bounds_spec = _bounds_spec(prop, type_info)
1✔
657
            if bounds_spec is not None:
1✔
658
                bounds_category = bounds_spec["category"]
1✔
659
                bounds_type_info = (
1✔
660
                    _element_type_info(type_info) if type_info.category == "array" else type_info
661
                )
662
                min_value = bounds_spec["min_value"]
1✔
663
                max_value = bounds_spec["max_value"]
1✔
664
                min_exclusive = bounds_spec["min_exclusive"]
1✔
665
                max_exclusive = bounds_spec["max_exclusive"]
1✔
666
                min_name = None
1✔
667
                max_name = None
1✔
668
                if min_value is not None:
1✔
669
                    min_name = f"{name}_min_excl" if min_exclusive else f"{name}_min"
1✔
670
                    min_literal = _format_scalar_default(
1✔
671
                        min_value, bounds_type_info.kind, bounds_category
672
                    )
673
                    bounds_parameters.append(
1✔
674
                        f"{bounds_type_info.type_spec}, parameter, public :: "
675
                        f"{min_name} = {min_literal}"
676
                    )
677
                if max_value is not None:
1✔
678
                    max_name = f"{name}_max_excl" if max_exclusive else f"{name}_max"
1✔
679
                    max_literal = _format_scalar_default(
1✔
680
                        max_value, bounds_type_info.kind, bounds_category
681
                    )
682
                    bounds_parameters.append(
1✔
683
                        f"{bounds_type_info.type_spec}, parameter, public :: "
684
                        f"{max_name} = {max_literal}"
685
                    )
686
                _, missing_condition, uses_ieee = _sentinel_expressions(
1✔
687
                    bounds_type_info,
688
                    var_ref="val",
689
                    len_ref="val",
690
                )
691
                if uses_ieee:
1✔
692
                    requires_ieee = True
1✔
693
                bounds_functions.append(
1✔
694
                    {
695
                        "name": name,
696
                        "func_name": f"{name}_in_bounds",
697
                        "arg_type_spec": bounds_type_info.arg_type_spec,
698
                        "has_min": min_value is not None,
699
                        "has_max": max_value is not None,
700
                        "min_name": min_name,
701
                        "max_name": max_name,
702
                        "min_exclusive": min_exclusive,
703
                        "max_exclusive": max_exclusive,
704
                        "missing_condition": missing_condition,
705
                    }
706
                )
707
                if type_info.category == "array":
1✔
708
                    bounds_checks.append(
1✔
709
                        {
710
                            "name": name,
711
                            "display_name": display_name,
712
                            "func_name": f"{name}_in_bounds",
713
                            "is_array": True,
714
                            "array_ref": f"this%{name}",
715
                        }
716
                    )
717
                else:
718
                    bounds_checks.append(
1✔
719
                        {
720
                            "name": name,
721
                            "display_name": display_name,
722
                            "func_name": f"{name}_in_bounds",
723
                            "is_array": False,
724
                            "element_ref": f"this%{name}",
725
                        }
726
                    )
727

728
            is_array = type_info.category == "array"
1✔
729
            if is_required:
1✔
730
                if is_array and flex_dim > 0:
1✔
731
                    set_required_assignments.append(
1✔
732
                        _render_flex_set_block(
733
                            name,
734
                            len(type_info.dimensions),
735
                            flex_dim,
736
                            flex_bounds or [],
737
                        )
738
                    )
739
                elif is_array and has_default:
1✔
740
                    set_required_assignments.append(
×
741
                        _render_partial_set_block(
742
                            name,
743
                            len(type_info.dimensions),
744
                            partial_bounds or [],
745
                        )
746
                    )
747
                else:
748
                    set_required_assignments.append(f"this%{name} = {name}")
1✔
749

750
            if not is_required:
1✔
751
                if is_array and flex_dim > 0:
1✔
752
                    block = _render_flex_set_block(
×
753
                        name,
754
                        len(type_info.dimensions),
755
                        flex_dim,
756
                        flex_bounds or [],
757
                    )
758
                    indented_block = "\n".join(f"  {line}" for line in block.splitlines())
×
759
                    set_present_assignment = (
×
760
                        f"if (present({name})) then\n{indented_block}\nend if"
761
                    )
762
                elif is_array and has_default:
1✔
763
                    block = _render_partial_set_block(
1✔
764
                        name,
765
                        len(type_info.dimensions),
766
                        partial_bounds or [],
767
                    )
768
                    indented_block = "\n".join(f"  {line}" for line in block.splitlines())
1✔
769
                    set_present_assignment = (
1✔
770
                        f"if (present({name})) then\n{indented_block}\nend if"
771
                    )
772
                else:
773
                    set_present_assignment = f"if (present({name})) this%{name} = {name}"
1✔
774
            else:
775
                set_present_assignment = None
1✔
776

777
            array_rank = len(type_info.dimensions) if is_array else 0
1✔
778
            element_condition: str | None = None
1✔
779

780
            if is_array and not has_default:
1✔
781
                element_type = _element_type_info(type_info)
1✔
782
                index_args = ", ".join(f"idx({idx})" for idx in range(1, array_rank + 1))
1✔
783
                element_ref = f"this%{name}({index_args})"
1✔
784
                _, element_condition, element_uses_ieee = _sentinel_expressions(
1✔
785
                    element_type,
786
                    var_ref=element_ref,
787
                    len_ref=f"this%{name}",
788
                )
789
                if element_uses_ieee:
1✔
790
                    requires_ieee = True
1✔
791

792
            if has_default or type_info.category == "boolean":
1✔
793
                presence_cases.append(
1✔
794
                    {
795
                        "name": name,
796
                        "display_name": display_name,
797
                        "always_true": True,
798
                        "sentinel_condition": None,
799
                        "is_array": is_array,
800
                        "rank": array_rank,
801
                        "element_condition": element_condition,
802
                    }
803
                )
804
            else:
805
                if set_sentinel_condition is None:
1✔
806
                    raise ValueError(f"missing sentinel condition for '{display_name}'")
×
807
                presence_cases.append(
1✔
808
                    {
809
                        "name": name,
810
                        "display_name": display_name,
811
                        "always_true": False,
812
                        "sentinel_condition": set_sentinel_condition,
813
                        "is_array": is_array,
814
                        "rank": array_rank,
815
                        "element_condition": element_condition,
816
                    }
817
                )
818

819
            fields.append(
1✔
820
                FieldSpec(
821
                    order=index,
822
                    name=name,
823
                    title=title,
824
                    description=description,
825
                    declaration=declaration_with_doc,
826
                    local_declaration=local_decl,
827
                    required=is_required,
828
                    sentinel_assignment=sentinel_assignment,
829
                    sentinel_check=(
830
                        f"if ({sentinel_condition}) error stop "
831
                        f"\"{module_name}%from_file: '{name}' is required\""
832
                        if sentinel_condition and is_required
833
                        else None
834
                    ),
835
                    default_assignment=default_assignment,
836
                    set_default_assignment=set_default_assignment,
837
                    set_present_assignment=set_present_assignment,
838
                    argument_declaration=argument_decl,
839
                    type_category=type_info.category,
840
                )
841
            )
842

843
    except ValueError as exc:
1✔
844
        if current_property is None:
1✔
845
            raise
×
846
        msg = str(exc)
1✔
847
        if f"property '{current_property}'" in msg:
1✔
848
            raise
×
849
        raise ValueError(f"property '{current_property}': {msg}") from exc
1✔
850
    if uses_partly_set and "NML_ERR_PARTLY_SET" not in helper_imports:
1✔
851
        helper_imports.append("NML_ERR_PARTLY_SET")
1✔
852
    required_flex_names = {entry["name"] for entry in flex_arrays if entry["required"]}
1✔
853
    required_scalar_validations: list[str] = []
1✔
854
    for name in required_fields:
1✔
855
        if name in required_scalar_names:
1✔
856
            required_scalar_validations.append(property_name_map[name])
1✔
857
        elif name not in required_array_by_name and name not in required_flex_names:
1✔
858
            required_scalar_validations.append(property_name_map[name])
×
859
    required_array_validations = [
1✔
860
        required_array_by_name[name]
861
        for name in required_fields
862
        if name in required_array_by_name
863
    ]
864
    namelist_vars = [field.name for field in fields]
1✔
865
    required_fields_specs = [field for field in fields if field.required]
1✔
866
    optional_fields_specs = [field for field in fields if not field.required]
1✔
867

868
    resolved_kind_module = kind_module or "iso_fortran_env"
1✔
869
    if not isinstance(resolved_kind_module, str) or not resolved_kind_module:
1✔
870
        raise ValueError("kind module must be a non-empty string")
×
871

872
    context = {
1✔
873
        "module_name": module_name,
874
        "type_name": type_name,
875
        "type_prefix": module_name,
876
        "doc_class": doc_class,
877
        "brief_text": brief_text,
878
        "details_text": details_text,
879
        "module_doc": module_doc,
880
        "namelist_name": namelist_name,
881
        "fields": fields,
882
        "namelist_vars": namelist_vars,
883
        "sentinel_assignments": sentinel_assignments,
884
        "default_assignments": default_assignments,
885
        "default_parameters": default_parameters,
886
        "enum_parameters": enum_parameters,
887
        "bounds_parameters": bounds_parameters,
888
        "local_init_assignments": local_init_assignments,
889
        "required_scalar_validations": required_scalar_validations,
890
        "required_array_validations": required_array_validations,
891
        "flex_arrays": flex_arrays,
892
        "assignments": [f"this%{field.name} = {field.name}" for field in fields],
893
        "argument_list": [field.name for field in required_fields_specs + optional_fields_specs],
894
        "required_argument_declarations": [
895
            field.argument_declaration for field in required_fields_specs
896
        ],
897
        "optional_argument_declarations": [
898
            field.argument_declaration for field in optional_fields_specs
899
        ],
900
        "set_required_assignments": set_required_assignments,
901
        "set_optional_defaults": set_optional_defaults,
902
        "set_optional_present": [
903
            field.set_present_assignment
904
            for field in optional_fields_specs
905
            if field.set_present_assignment
906
        ],
907
        "enum_functions": enum_functions,
908
        "enum_checks": enum_checks,
909
        "bounds_functions": bounds_functions,
910
        "bounds_checks": bounds_checks,
911
        "kind_module": resolved_kind_module,
912
        "kind_imports": _resolve_kind_imports(
913
            kind_ids,
914
            kind_map=kind_map,
915
            kind_allowlist=kind_allowlist,
916
        ),
917
        "use_ieee": requires_ieee,
918
        "helper_module": helper_module,
919
        "helper_imports": helper_imports,
920
        "presence_cases": presence_cases,
921
        "flex_bound_vars": _sort_bound_vars(flex_bound_vars),
922
        "f2py_handle_helpers": f2py_handle_helpers,
923
    }
924

925
    return context
1✔
926

927

928
def _ordered_unique(values: Iterable[Any]) -> list[Any]:
1✔
929
    seen: set[Any] = set()
1✔
930
    ordered: list[Any] = []
1✔
931
    for value in values:
1✔
932
        if value not in seen:
1✔
933
            seen.add(value)
1✔
934
            ordered.append(value)
1✔
935
    return ordered
1✔
936

937

938
def _resolve_kind_imports(
1✔
939
    kind_ids: list[str],
940
    *,
941
    kind_map: dict[str, str] | None,
942
    kind_allowlist: Iterable[str] | None,
943
) -> list[str]:
944
    allowlist = set(kind_allowlist) if kind_allowlist is not None else None
1✔
945
    imports: list[str] = []
1✔
946
    target_aliases: dict[str, str] = {}
1✔
947
    for kind_id in _ordered_unique(kind_ids):
1✔
948
        target = kind_id
1✔
949
        if kind_map is not None and kind_id in kind_map:
1✔
950
            mapped = kind_map[kind_id]
1✔
951
            if not isinstance(mapped, str):
1✔
952
                raise ValueError(f"kind map target for '{kind_id}' must be a string")
×
953
            target = mapped
1✔
954
        elif allowlist is not None and kind_id not in allowlist:
1✔
955
            raise ValueError(f"kind '{kind_id}' not present in kind map or kind module list")
×
956

957
        if allowlist is not None and target not in allowlist:
1✔
958
            raise ValueError(
×
959
                f"kind map target '{target}' for '{kind_id}' not present in kind module list"
960
            )
961

962
        if target != kind_id:
1✔
963
            existing = target_aliases.get(target)
1✔
964
            if existing is not None and existing != kind_id:
1✔
965
                raise ValueError(
×
966
                    f"kind map target '{target}' is shared by '{existing}' and '{kind_id}'"
967
                )
968
            target_aliases[target] = kind_id
1✔
969
            imports.append(f"{kind_id}=>{target}")
1✔
970
        else:
971
            imports.append(kind_id)
1✔
972
    return imports
1✔
973

974

975
def _field_type_info(
1✔
976
    prop: dict[str, Any],
977
    constants: dict[str, int | float] | None,
978
) -> FieldTypeInfo:
979
    prop_type = prop.get("type")
1✔
980
    if prop_type == "array":
1✔
981
        dimensions: list[str] = []
1✔
982
        current = prop
1✔
983
        while current.get("type") == "array":
1✔
984
            dimensions.extend(_extract_dimensions(current))
1✔
985
            items = current.get("items")
1✔
986
            if not isinstance(items, dict):
1✔
987
                raise ValueError("array property must define 'items'")
×
988
            if items.get("type") == "array":
1✔
989
                raise ValueError("nested array properties are not supported; use x-fortran-shape")
1✔
990
            current = items
1✔
991
        scalar = _scalar_type_info(current, constants)
1✔
992
        return FieldTypeInfo(
1✔
993
            type_spec=scalar.type_spec,
994
            arg_type_spec=scalar.arg_type_spec,
995
            dimensions=dimensions,
996
            kind=scalar.kind,
997
            category="array",
998
            length_expr=scalar.length_expr,
999
            element_category=scalar.category,
1000
        )
1001

1002
    scalar = _scalar_type_info(prop, constants)
1✔
1003
    return FieldTypeInfo(
1✔
1004
        type_spec=scalar.type_spec,
1005
        arg_type_spec=scalar.arg_type_spec,
1006
        dimensions=[],
1007
        kind=scalar.kind,
1008
        category=scalar.category,
1009
        length_expr=scalar.length_expr,
1010
        element_category=None,
1011
    )
1012

1013

1014
def _element_type_info(type_info: FieldTypeInfo) -> FieldTypeInfo:
1✔
1015
    if type_info.category != "array":
1✔
1016
        raise ValueError("element type info requires an array field")
×
1017
    element_category = type_info.element_category
1✔
1018
    if element_category is None:
1✔
1019
        raise ValueError("array field missing element category")
×
1020
    return FieldTypeInfo(
1✔
1021
        type_spec=type_info.type_spec,
1022
        arg_type_spec=type_info.type_spec,
1023
        dimensions=[],
1024
        kind=type_info.kind,
1025
        category=element_category,
1026
        length_expr=type_info.length_expr,
1027
        element_category=None,
1028
    )
1029

1030

1031
def _enum_category(type_info: FieldTypeInfo) -> str:
1✔
1032
    if type_info.category == "array":
1✔
1033
        if type_info.element_category is None:
1✔
1034
            raise ValueError("array field missing element category")
×
1035
        return type_info.element_category
1✔
1036
    return type_info.category
1✔
1037

1038

1039
def _enum_arg_type_spec(type_info: FieldTypeInfo) -> str:
1✔
1040
    category = _enum_category(type_info)
1✔
1041
    if category == "string":
1✔
1042
        return "character(len=*)"
1✔
1043
    return type_info.type_spec
1✔
1044

1045

1046
def _scalar_type_info(
1✔
1047
    prop: dict[str, Any],
1048
    constants: dict[str, int | float] | None,
1049
) -> ScalarTypeInfo:
1050
    prop_type = prop.get("type")
1✔
1051
    if prop_type == "string":
1✔
1052
        length = prop.get("x-fortran-len")
1✔
1053
        if isinstance(length, bool):
1✔
1054
            raise ValueError("string property must define integer 'x-fortran-len'")
×
1055
        if isinstance(length, int):
1✔
1056
            if length <= 0:
1✔
1057
                raise ValueError("string length must be positive")
×
1058
            length_expr = str(length)
1✔
1059
        elif isinstance(length, str):
1✔
1060
            length_expr = length.strip()
1✔
1061
            if not length_expr:
1✔
1062
                raise ValueError("string length must be a non-empty value")
×
1063
            _validate_length_token(length_expr)
1✔
1064
            if _is_int_literal(length_expr):
1✔
1065
                if int(length_expr) <= 0:
×
1066
                    raise ValueError("string length must be positive")
×
1067
            else:
1068
                if constants is None or length_expr not in constants:
1✔
1069
                    raise ValueError(
×
1070
                        f"string length constant '{length_expr}' is not defined in config"
1071
                    )
1072
                value = constants[length_expr]
1✔
1073
                if isinstance(value, bool) or not isinstance(value, int):
1✔
1074
                    raise ValueError(f"string length constant '{length_expr}' must be an integer")
×
1075
                if value <= 0:
1✔
1076
                    raise ValueError(f"string length constant '{length_expr}' must be positive")
×
1077
        else:
1078
            raise ValueError("string property must define integer 'x-fortran-len'")
×
1079
        return ScalarTypeInfo(
1✔
1080
            type_spec=f"character(len={length_expr})",
1081
            arg_type_spec="character(len=*)",
1082
            kind=None,
1083
            category="string",
1084
            length_expr=length_expr,
1085
        )
1086
    if prop_type == "integer":
1✔
1087
        kind = prop.get("x-fortran-kind")
1✔
1088
        if kind is None:
1✔
1089
            return ScalarTypeInfo(
1✔
1090
                type_spec="integer",
1091
                arg_type_spec="integer",
1092
                kind=None,
1093
                category="integer",
1094
            )
1095
        if not isinstance(kind, str) or not kind.strip():
1✔
1096
            raise ValueError("integer property 'x-fortran-kind' must be a non-empty string")
×
1097
        return ScalarTypeInfo(
1✔
1098
            type_spec=f"integer({kind})",
1099
            arg_type_spec=f"integer({kind})",
1100
            kind=kind,
1101
            category="integer",
1102
        )
1103
    if prop_type == "number":
1✔
1104
        kind = prop.get("x-fortran-kind")
1✔
1105
        if kind is None:
1✔
1106
            return ScalarTypeInfo(
1✔
1107
                type_spec="real",
1108
                arg_type_spec="real",
1109
                kind=None,
1110
                category="real",
1111
            )
1112
        if not isinstance(kind, str) or not kind.strip():
1✔
1113
            raise ValueError("number property 'x-fortran-kind' must be a non-empty string")
×
1114
        return ScalarTypeInfo(
1✔
1115
            type_spec=f"real({kind})",
1116
            arg_type_spec=f"real({kind})",
1117
            kind=kind,
1118
            category="real",
1119
        )
1120
    if prop_type == "boolean":
1✔
1121
        return ScalarTypeInfo(
1✔
1122
            type_spec="logical",
1123
            arg_type_spec="logical",
1124
            kind=None,
1125
            category="boolean",
1126
        )
1127
    raise ValueError(f"unsupported property type '{prop_type}'")
×
1128

1129

1130
def _extract_dimensions(prop: dict[str, Any]) -> list[str]:
1✔
1131
    shape = prop.get("x-fortran-shape")
1✔
1132
    if isinstance(shape, bool):
1✔
1133
        raise ValueError("array property 'x-fortran-shape' must not be a boolean")
×
1134
    if isinstance(shape, int):
1✔
1135
        return [str(shape)]
1✔
1136
    if isinstance(shape, str):
1✔
1137
        dim = shape.strip()
1✔
1138
        if not dim:
1✔
1139
            raise ValueError("array property 'x-fortran-shape' entries must be non-empty")
×
1140
        _validate_dimension_token(dim)
1✔
1141
        return [dim]
1✔
1142
    if isinstance(shape, list):
1✔
1143
        dimensions: list[str] = []
1✔
1144
        for dim in shape:
1✔
1145
            if isinstance(dim, bool):
1✔
1146
                raise ValueError("array property 'x-fortran-shape' must not include booleans")
×
1147
            if isinstance(dim, int):
1✔
1148
                dim_literal = str(dim)
1✔
1149
            elif isinstance(dim, str):
1✔
1150
                dim_literal = dim.strip()
1✔
1151
                if not dim_literal:
1✔
1152
                    raise ValueError("array property 'x-fortran-shape' entries must be non-empty")
×
1153
            else:
1154
                raise ValueError("array property 'x-fortran-shape' must be an int, string, or list")
×
1155
            _validate_dimension_token(dim_literal)
1✔
1156
            dimensions.append(dim_literal)
1✔
1157
        return dimensions
1✔
1158
    if shape is None:
1✔
1159
        raise ValueError("array property must define 'x-fortran-shape'")
1✔
1160
    raise ValueError("array property 'x-fortran-shape' must be an int, string, or list")
×
1161

1162

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

1165

1166
def _is_int_literal(value: str) -> bool:
1✔
1167
    try:
1✔
1168
        int(value)
1✔
1169
    except ValueError:
1✔
1170
        return False
1✔
1171
    return True
1✔
1172

1173

1174
def _validate_dimension_token(dim: str) -> None:
1✔
1175
    if dim == ":":
1✔
1176
        return
×
1177
    if _is_int_literal(dim):
1✔
1178
        return
1✔
1179
    if _FORTRAN_IDENTIFIER.match(dim):
1✔
1180
        return
1✔
1181
    raise ValueError("array property 'x-fortran-shape' entries must be ints or identifiers")
×
1182

1183

1184
def _validate_length_token(length_expr: str) -> None:
1✔
1185
    if _is_int_literal(length_expr):
1✔
1186
        return
×
1187
    if _FORTRAN_IDENTIFIER.match(length_expr):
1✔
1188
        return
1✔
1189
    raise ValueError("string length must be an integer literal or identifier")
×
1190

1191

1192
def _parse_flex_dim(prop: dict[str, Any], type_info: FieldTypeInfo) -> int:
1✔
1193
    flex_raw = prop.get("x-fortran-flex-tail-dims")
1✔
1194
    if flex_raw is None:
1✔
1195
        flex_value = 0
1✔
1196
    else:
1197
        if isinstance(flex_raw, bool) or not isinstance(flex_raw, int):
1✔
1198
            raise ValueError("x-fortran-flex-tail-dims must be an integer")
×
1199
        flex_value = flex_raw
1✔
1200
    if flex_value < 0:
1✔
1201
        raise ValueError("x-fortran-flex-tail-dims must be >= 0")
×
1202
    if flex_value == 0:
1✔
1203
        return 0
1✔
1204
    if type_info.category != "array":
1✔
1205
        raise ValueError("x-fortran-flex-tail-dims is only supported for arrays")
1✔
1206
    if flex_value > len(type_info.dimensions):
1✔
1207
        raise ValueError("x-fortran-flex-tail-dims must not exceed array rank")
1✔
1208
    if any(dim == ":" for dim in type_info.dimensions):
1✔
1209
        raise ValueError(
×
1210
            "x-fortran-flex-tail-dims does not support deferred-size dimensions"
1211
        )
1212
    return flex_value
1✔
1213

1214

1215
def _collect_dimension_constants(
1✔
1216
    dimensions: list[str],
1217
    constants: dict[str, int | float] | None,
1218
) -> list[str]:
1219
    used: list[str] = []
1✔
1220
    for dim in dimensions:
1✔
1221
        if dim == ":" or _is_int_literal(dim):
1✔
1222
            continue
1✔
1223
        if not _FORTRAN_IDENTIFIER.match(dim):
1✔
1224
            raise ValueError("array property 'x-fortran-shape' entries must be ints or identifiers")
×
1225
        if constants is None or dim not in constants:
1✔
1226
            raise ValueError(f"dimension constant '{dim}' is not defined in config")
1✔
1227
        value = constants[dim]
1✔
1228
        if isinstance(value, bool) or not isinstance(value, int):
1✔
1229
            raise ValueError(f"dimension constant '{dim}' must be an integer")
×
1230
        if dim not in used:
1✔
1231
            used.append(dim)
1✔
1232
    return used
1✔
1233

1234

1235
def _render_declaration(type_spec: str, dimensions: list[str], name: str) -> str:
1✔
1236
    parts = [type_spec]
1✔
1237
    if dimensions:
1✔
1238
        dims = ", ".join(dimensions)
1✔
1239
        parts.append(f"dimension({dims})")
1✔
1240
    return f"{', '.join(parts)} :: {name}"
1✔
1241

1242

1243
def _render_argument_declaration(
1✔
1244
    *,
1245
    name: str,
1246
    type_info: FieldTypeInfo,
1247
    is_required: bool,
1248
    dimensions: list[str] | None = None,
1249
    doc: str | None = None,
1250
) -> str:
1251
    intent = "intent(in)"
1✔
1252
    parts = [type_info.arg_type_spec]
1✔
1253
    arg_dimensions = dimensions if dimensions is not None else type_info.dimensions
1✔
1254
    if arg_dimensions:
1✔
1255
        dims = ", ".join(arg_dimensions)
1✔
1256
        parts.append(f"dimension({dims})")
1✔
1257
    if not is_required:
1✔
1258
        parts.append(intent)
1✔
1259
        parts.append("optional")
1✔
1260
        decl = f"{', '.join(parts[:-1])}, {parts[-1]} :: {name}"
1✔
1261
    else:
1262
        parts.append(intent)
1✔
1263
        decl = f"{', '.join(parts)} :: {name}"
1✔
1264
    if doc:
1✔
1265
        decl = f"{decl} !< {doc}"
1✔
1266
    return decl
1✔
1267

1268

1269
def _sentinel_comment(type_info: FieldTypeInfo, *, required: bool) -> str:
1✔
1270
    label = "required" if required else "optional"
1✔
1271
    category = type_info.category
1✔
1272
    if category == "array":
1✔
1273
        element = type_info.element_category or "array"
1✔
1274
        return f" ! sentinel for {label} {element} array"
1✔
1275
    if category == "string":
1✔
1276
        if required:
1✔
1277
            return " ! NULL string as sentinel for required string"
1✔
1278
        return " ! sentinel for optional string"
1✔
1279
    if category == "integer":
1✔
1280
        return f" ! sentinel for {label} integer"
1✔
1281
    if category == "real":
1✔
1282
        return f" ! sentinel for {label} real"
1✔
1283
    return ""
×
1284

1285

1286
def _sentinel_expressions(
1✔
1287
    type_info: FieldTypeInfo,
1288
    *,
1289
    var_ref: str,
1290
    len_ref: str | None = None,
1291
) -> tuple[str, str, bool]:
1292
    category = type_info.category
1✔
1293
    if category == "array":
1✔
1294
        element = type_info.element_category
1✔
1295
        if element == "string":
1✔
1296
            length_ref = len_ref or var_ref
1✔
1297
            value_expr = f"repeat(achar(0), len({length_ref}))"
1✔
1298
            return value_expr, f"all({var_ref} == {value_expr})", False
1✔
1299
        if element == "integer":
1✔
1300
            return f"-huge({var_ref})", f"all({var_ref} == -huge({var_ref}))", False
1✔
1301
        if element == "real":
1✔
1302
            return (
1✔
1303
                f"ieee_value({var_ref}, ieee_quiet_nan)",
1304
                f"all(ieee_is_nan({var_ref}))",
1305
                True,
1306
            )
1307
        if element == "boolean":
×
1308
            raise ValueError("boolean arrays cannot use sentinels")
×
1309
        raise ValueError(f"unsupported sentinel array element '{element}'")
×
1310
    if category == "string":
1✔
1311
        length_ref = len_ref or var_ref
1✔
1312
        value_expr = f"repeat(achar(0), len({length_ref}))"
1✔
1313
        return value_expr, f"{var_ref} == {value_expr}", False
1✔
1314
    if category == "integer":
1✔
1315
        return f"-huge({var_ref})", f"{var_ref} == -huge({var_ref})", False
1✔
1316
    if category == "real":
1✔
1317
        return (
1✔
1318
            f"ieee_value({var_ref}, ieee_quiet_nan)",
1319
            f"ieee_is_nan({var_ref})",
1320
            True,
1321
        )
1322
    if category == "boolean":
×
1323
        raise ValueError("boolean values cannot use sentinels")
×
1324
    raise ValueError(f"unsupported sentinel category '{category}'")
×
1325

1326

1327
def _element_missing_expression(
1✔
1328
    category: str,
1329
    *,
1330
    var_ref: str,
1331
    len_ref: str | None = None,
1332
) -> tuple[str, bool]:
1333
    if category == "string":
1✔
1334
        length_ref = len_ref or var_ref
×
1335
        return f"{var_ref} == repeat(achar(0), len({length_ref}))", False
×
1336
    if category == "integer":
1✔
1337
        return f"{var_ref} == -huge({var_ref})", False
1✔
1338
    if category == "real":
1✔
1339
        return f"ieee_is_nan({var_ref})", True
1✔
1340
    raise ValueError(f"unsupported missing category '{category}'")
×
1341

1342

1343
def _array_missing_conditions(
1✔
1344
    element_category: str,
1345
    *,
1346
    var_ref: str,
1347
    len_ref: str | None = None,
1348
) -> tuple[str, str, bool]:
1349
    missing_expr, uses_ieee = _element_missing_expression(
1✔
1350
        element_category, var_ref=var_ref, len_ref=len_ref
1351
    )
1352
    return f"all({missing_expr})", f"any({missing_expr})", uses_ieee
1✔
1353

1354

1355
def _slice_ref(name: str, rank: int, dim: int, index_var: str) -> str:
1✔
1356
    dims = [":" for _ in range(rank)]
1✔
1357
    dims[dim - 1] = index_var
1✔
1358
    return f"this%{name}({', '.join(dims)})"
1✔
1359

1360

1361
def _flex_bound_vars(dim: int) -> tuple[str, str]:
1✔
1362
    return f"lb_{dim}", f"ub_{dim}"
1✔
1363

1364

1365
def _slice_ref_bounds(
1✔
1366
    name: str,
1367
    rank: int,
1368
    flex_dims: list[int],
1369
    lb_vars: dict[int, str],
1370
    ub_vars: dict[int, str],
1371
) -> str:
1372
    dims: list[str] = []
1✔
1373
    for dim in range(1, rank + 1):
1✔
1374
        if dim in flex_dims:
1✔
1375
            dims.append(f"{lb_vars[dim]}:{ub_vars[dim]}")
1✔
1376
        else:
1377
            dims.append(":")
1✔
1378
    return f"this%{name}({', '.join(dims)})"
1✔
1379

1380

1381
def _render_flex_set_block(
1✔
1382
    name: str,
1383
    rank: int,
1384
    flex_dim_count: int,
1385
    bounds: list[dict[str, Any]],
1386
) -> str:
1387
    flex_dims = list(range(rank - flex_dim_count + 1, rank + 1))
1✔
1388
    lb_vars = {entry["dim"]: entry["lb_var"] for entry in bounds}
1✔
1389
    ub_vars = {entry["dim"]: entry["ub_var"] for entry in bounds}
1✔
1390
    lines: list[str] = []
1✔
1391
    for dim in range(1, rank - flex_dim_count + 1):
1✔
1392
        lines.append(f"if (size({name}, {dim}) /= size(this%{name}, {dim})) then")
1✔
1393
        lines.append("  status = NML_ERR_INVALID_INDEX")
1✔
1394
        lines.append(
1✔
1395
            f"  if (present(errmsg)) errmsg = \"dimension {dim} mismatch for '{name}'\""
1396
        )
1397
        lines.append("  return")
1✔
1398
        lines.append("end if")
1✔
1399
    for entry in bounds:
1✔
1400
        dim = entry["dim"]
1✔
1401
        lb_var = entry["lb_var"]
1✔
1402
        ub_var = entry["ub_var"]
1✔
1403
        lines.append(f"if (size({name}, {dim}) > size(this%{name}, {dim})) then")
1✔
1404
        lines.append("  status = NML_ERR_INVALID_INDEX")
1✔
1405
        lines.append(
1✔
1406
            f"  if (present(errmsg)) errmsg = \"dimension {dim} exceeds bounds for '{name}'\""
1407
        )
1408
        lines.append("  return")
1✔
1409
        lines.append("end if")
1✔
1410
        lines.append(f"{lb_var} = lbound(this%{name}, {dim})")
1✔
1411
        lines.append(f"{ub_var} = {lb_var} + size({name}, {dim}) - 1")
1✔
1412
    lines.append(f"{_slice_ref_bounds(name, rank, flex_dims, lb_vars, ub_vars)} = {name}")
1✔
1413
    return "\n".join(lines)
1✔
1414

1415

1416
def _render_partial_set_block(name: str, rank: int, bounds: list[dict[str, Any]]) -> str:
1✔
1417
    lb_vars = {entry["dim"]: entry["lb_var"] for entry in bounds}
1✔
1418
    ub_vars = {entry["dim"]: entry["ub_var"] for entry in bounds}
1✔
1419
    dims_all = [entry["dim"] for entry in bounds]
1✔
1420
    lines: list[str] = []
1✔
1421
    for entry in bounds:
1✔
1422
        dim = entry["dim"]
1✔
1423
        lb_var = entry["lb_var"]
1✔
1424
        ub_var = entry["ub_var"]
1✔
1425
        lines.append(f"if (size({name}, {dim}) > size(this%{name}, {dim})) then")
1✔
1426
        lines.append("  status = NML_ERR_INVALID_INDEX")
1✔
1427
        lines.append(
1✔
1428
            f"  if (present(errmsg)) errmsg = \"dimension {dim} exceeds bounds for '{name}'\""
1429
        )
1430
        lines.append("  return")
1✔
1431
        lines.append("end if")
1✔
1432
        lines.append(f"{lb_var} = lbound(this%{name}, {dim})")
1✔
1433
        lines.append(f"{ub_var} = {lb_var} + size({name}, {dim}) - 1")
1✔
1434
    lines.append(f"{_slice_ref_bounds(name, rank, dims_all, lb_vars, ub_vars)} = {name}")
1✔
1435
    return "\n".join(lines)
1✔
1436

1437

1438
def _sort_bound_vars(values: set[str]) -> list[str]:
1✔
1439
    def sort_key(name: str) -> tuple[str, int]:
1✔
1440
        prefix, _, suffix = name.partition("_")
1✔
1441
        try:
1✔
1442
            return prefix, int(suffix)
1✔
1443
        except ValueError:
×
1444
            return prefix, 0
×
1445

1446
    return sorted(values, key=sort_key)
1✔
1447

1448

1449
def _format_reshape_assignment(name: str, arguments: list[str]) -> str:
1✔
1450
    lines = [f"this%{name} = reshape( &"]
1✔
1451
    for index, arg in enumerate(arguments):
1✔
1452
        suffix = ", &" if index < len(arguments) - 1 else ")"
1✔
1453
        lines.append(f"  {arg}{suffix}")
1✔
1454
    return "\n".join(lines)
1✔
1455

1456

1457
def _format_default(
1✔
1458
    value: Any,
1459
    type_info: FieldTypeInfo,
1460
    prop: dict[str, Any],
1461
    constants: dict[str, int | float] | None = None,
1462
) -> str:
1463
    if type_info.category == "array":
1✔
1464
        if not isinstance(value, list):
×
1465
            raise ValueError("array default must be a list")
×
1466
        parsed_dims = _parse_default_dimensions(type_info.dimensions, constants)
×
1467
        array_default = _prepare_array_default(value, parsed_dims, prop)
×
1468
        elements = [
×
1469
            _format_scalar_default(element, type_info.kind, type_info.element_category)
1470
            for element in array_default.source_values
1471
        ]
1472
        if (
×
1473
            len(type_info.dimensions) == 1
1474
            and array_default.order_values is None
1475
            and array_default.pad_values is None
1476
        ):
1477
            return f"[{', '.join(elements)}]"
×
1478

1479
        shape_literal = ", ".join(type_info.dimensions)
×
1480
        arguments = [f"[{', '.join(elements)}]", f"shape=[{shape_literal}]"]
×
1481

1482
        if array_default.order_values is not None:
×
1483
            order_literal = ", ".join(str(index) for index in array_default.order_values)
×
1484
            arguments.append(f"order=[{order_literal}]")
×
1485

1486
        if array_default.pad_values is not None:
×
1487
            pad_elements = [
×
1488
                _format_scalar_default(element, type_info.kind, type_info.element_category)
1489
                for element in array_default.pad_values
1490
            ]
1491
            arguments.append(f"pad=[{', '.join(pad_elements)}]")
×
1492

1493
        return f"reshape({', '.join(arguments)})"
×
1494
    return _format_scalar_default(value, type_info.kind, type_info.category)
1✔
1495

1496

1497
def _format_scalar_default(value: Any, kind: str | None, category: str | None) -> str:
1✔
1498
    if category == "integer":
1✔
1499
        if not isinstance(value, int):
1✔
1500
            raise ValueError("integer default must be an int")
×
1501
        suffix = f"_{kind}" if kind else ""
1✔
1502
        return f"{value}{suffix}"
1✔
1503
    if category == "real":
1✔
1504
        number = float(value)
1✔
1505
        literal = repr(number)
1✔
1506
        if literal.lower() == "nan":
1✔
1507
            raise ValueError("NaN defaults are not supported")
×
1508
        if "E" in literal:
1✔
1509
            literal = literal.replace("E", "e")
×
1510
        if "." not in literal and "e" not in literal:
1✔
1511
            literal = f"{literal}.0"
×
1512
        suffix = f"_{kind}" if kind else ""
1✔
1513
        return f"{literal}{suffix}"
1✔
1514
    if category == "boolean":
1✔
1515
        if not isinstance(value, bool):
1✔
1516
            raise ValueError("boolean default must be a bool")
×
1517
        return ".true." if value else ".false."
1✔
1518
    if category == "string":
1✔
1519
        if not isinstance(value, str):
1✔
1520
            raise ValueError("string default must be a str")
×
1521
        escaped = value.replace('"', '""')
1✔
1522
        return f'"{escaped}"'
1✔
1523
    raise ValueError(f"unsupported default category '{category}'")
×
1524

1525

1526
def _parse_default_dimensions(
1✔
1527
    dimensions: list[str],
1528
    constants: dict[str, int | float] | None,
1529
) -> list[int]:
1530
    if not dimensions:
1✔
1531
        raise ValueError("array property missing dimensions")
×
1532
    parsed: list[int] = []
1✔
1533
    for dim in dimensions:
1✔
1534
        if dim == ":":
1✔
1535
            raise ValueError("defaults not supported for deferred-size dimensions")
×
1536
        try:
1✔
1537
            parsed.append(int(dim))
1✔
1538
        except (TypeError, ValueError) as err:  # pragma: no cover - defensive
1539
            if constants is None or dim not in constants:
1540
                raise ValueError(
1541
                    "array default dimensions must be integer literals or defined constants"
1542
                ) from err
1543
            value = constants[dim]
1544
            if isinstance(value, bool) or not isinstance(value, int):
1545
                raise ValueError("array default dimension constants must be integers") from err
1546
            parsed.append(value)
1547
    return parsed
1✔
1548

1549

1550
def _prepare_array_default(
1✔
1551
    value: list[Any],
1552
    dims: list[int],
1553
    prop: dict[str, Any],
1554
) -> ArrayDefaultSpec:
1555
    default_values = _ensure_flat_scalar_list(value, "array default")
1✔
1556
    if not default_values:
1✔
1557
        raise ValueError("array default must contain at least one value")
×
1558

1559
    total_size = math.prod(dims)
1✔
1560
    if len(default_values) > total_size:
1✔
1561
        raise ValueError("array default longer than declared x-fortran-shape")
×
1562

1563
    order_raw = prop.get("x-fortran-default-order", "F")
1✔
1564
    if not isinstance(order_raw, str):
1✔
1565
        raise ValueError("array default order must be 'F' or 'C'")
×
1566
    order = order_raw.upper()
1✔
1567
    if order not in {"F", "C"}:
1✔
1568
        raise ValueError("array default order must be 'F' or 'C'")
×
1569

1570
    repeat_raw = prop.get("x-fortran-default-repeat", False)
1✔
1571
    if not isinstance(repeat_raw, bool):
1✔
1572
        raise ValueError("array default repeat must be a boolean")
×
1573
    repeat = bool(repeat_raw)
1✔
1574

1575
    pad_raw = prop.get("x-fortran-default-pad")
1✔
1576
    pad_values: list[Any] | None = None
1✔
1577
    if pad_raw is not None:
1✔
1578
        if repeat:
1✔
1579
            raise ValueError("array default cannot set both pad and repeat")
×
1580
        if not isinstance(pad_raw, list):
1✔
1581
            pad_raw = [pad_raw]
1✔
1582
        pad_values = _ensure_flat_scalar_list(pad_raw, "array default pad")
1✔
1583
        if not pad_values:
1✔
1584
            raise ValueError("array default pad must contain at least one value")
×
1585

1586
    if len(default_values) < total_size and pad_values is None and not repeat:
1✔
1587
        raise ValueError(
1✔
1588
            "array default shorter than declared x-fortran-shape without pad or repeat"
1589
        )
1590

1591
    if repeat:
1✔
1592
        pad_values = list(default_values)
1✔
1593
        if not pad_values:
1✔
1594
            raise ValueError("array default repeat requires at least one value")
×
1595

1596
    order_values: list[int] | None = None
1✔
1597
    if order == "C" and len(dims) > 1:
1✔
1598
        rank = len(dims)
1✔
1599
        order_values = list(range(rank, 0, -1))
1✔
1600

1601
    return ArrayDefaultSpec(
1✔
1602
        source_values=list(default_values),
1603
        pad_values=pad_values,
1604
        order_values=order_values,
1605
    )
1606

1607

1608
def _ensure_flat_scalar_list(values: list[Any], description: str) -> list[Any]:
1✔
1609
    normalized: list[Any] = []
1✔
1610
    for element in values:
1✔
1611
        if isinstance(element, list):
1✔
1612
            raise ValueError(f"{description} must be a flat list")
×
1613
        normalized.append(element)
1✔
1614
    return normalized
1✔
1615

1616

1617
def _enum_values(
1✔
1618
    prop: dict[str, Any],
1619
    type_info: FieldTypeInfo,
1620
    constants: dict[str, int | float] | None,
1621
) -> list[Any] | None:
1622
    if type_info.category == "array":
1✔
1623
        enum_raw = _array_items_enum(prop)
1✔
1624
    else:
1625
        enum_raw = prop.get("enum")
1✔
1626
    if enum_raw is None:
1✔
1627
        return None
1✔
1628
    if not isinstance(enum_raw, list) or not enum_raw:
1✔
1629
        raise ValueError("property enum must be a non-empty list")
×
1630
    enum_values = _ensure_flat_scalar_list(enum_raw, "enum")
1✔
1631
    category = _enum_category(type_info)
1✔
1632
    if category not in {"integer", "string"}:
1✔
1633
        raise ValueError("enum only supported for integer or string values")
×
1634
    for value in enum_values:
1✔
1635
        _validate_enum_scalar(value, category, "enum")
1✔
1636
    _validate_enum_defaults(prop, type_info, enum_values, category, constants)
1✔
1637
    _validate_enum_examples(prop, type_info, enum_values, category)
1✔
1638
    return enum_values
1✔
1639

1640

1641
def _bounds_spec(
1✔
1642
    prop: dict[str, Any],
1643
    type_info: FieldTypeInfo,
1644
) -> dict[str, Any] | None:
1645
    if type_info.category == "array":
1✔
1646
        bounds_prop = _array_items_bounds(prop)
1✔
1647
        category = type_info.element_category
1✔
1648
    else:
1649
        bounds_prop = prop
1✔
1650
        category = type_info.category
1✔
1651

1652
    min_value, min_exclusive = _extract_bound_value(
1✔
1653
        bounds_prop, "minimum", "exclusiveMinimum"
1654
    )
1655
    max_value, max_exclusive = _extract_bound_value(
1✔
1656
        bounds_prop, "maximum", "exclusiveMaximum"
1657
    )
1658

1659
    if min_value is None and max_value is None:
1✔
1660
        return None
1✔
1661

1662
    if category not in {"integer", "real"}:
1✔
1663
        raise ValueError("bounds only supported for integer or real values")
×
1664

1665
    if min_value is not None:
1✔
1666
        _validate_bound_scalar(min_value, category, "minimum")
1✔
1667
    if max_value is not None:
1✔
1668
        _validate_bound_scalar(max_value, category, "maximum")
1✔
1669

1670
    if min_value is not None and max_value is not None:
1✔
1671
        min_comp = float(min_value) if category == "real" else int(min_value)
1✔
1672
        max_comp = float(max_value) if category == "real" else int(max_value)
1✔
1673
        if min_exclusive or max_exclusive:
1✔
1674
            if min_comp >= max_comp:
1✔
1675
                raise ValueError("minimum must be less than maximum for exclusive bounds")
×
1676
        else:
1677
            if min_comp > max_comp:
×
1678
                raise ValueError("minimum must be <= maximum")
×
1679

1680
    return {
1✔
1681
        "min_value": min_value,
1682
        "min_exclusive": min_exclusive,
1683
        "max_value": max_value,
1684
        "max_exclusive": max_exclusive,
1685
        "category": category,
1686
    }
1687

1688

1689
def _extract_bound_value(
1✔
1690
    prop: dict[str, Any],
1691
    inclusive_key: str,
1692
    exclusive_key: str,
1693
) -> tuple[Any | None, bool]:
1694
    has_inclusive = inclusive_key in prop
1✔
1695
    has_exclusive = exclusive_key in prop
1✔
1696
    if has_inclusive and has_exclusive:
1✔
1697
        raise ValueError(
×
1698
            f"property must not define both '{inclusive_key}' and '{exclusive_key}'"
1699
        )
1700
    if has_exclusive:
1✔
1701
        value = prop.get(exclusive_key)
1✔
1702
        if value is None:
1✔
1703
            raise ValueError(f"{exclusive_key} must be a number")
×
1704
        return value, True
1✔
1705
    if has_inclusive:
1✔
1706
        value = prop.get(inclusive_key)
1✔
1707
        if value is None:
1✔
1708
            raise ValueError(f"{inclusive_key} must be a number")
×
1709
        return value, False
1✔
1710
    return None, False
1✔
1711

1712

1713
def _validate_bound_scalar(value: Any, category: str, label: str) -> None:
1✔
1714
    if category == "integer":
1✔
1715
        if isinstance(value, bool) or not isinstance(value, int):
1✔
1716
            raise ValueError(f"{label} must be an integer")
×
1717
        return
1✔
1718
    if category == "real":
1✔
1719
        if isinstance(value, bool) or not isinstance(value, (int, float)):
1✔
1720
            raise ValueError(f"{label} must be a number")
×
1721
        if math.isinf(float(value)):
1✔
1722
            raise ValueError(f"{label} must not be infinite")
×
1723
        if math.isnan(float(value)):
1✔
1724
            raise ValueError(f"{label} must not be NaN")
×
1725
        return
1✔
1726
    raise ValueError("bounds only supported for integer or real values")
×
1727

1728

1729
def _array_default_value(prop: dict[str, Any]) -> tuple[Any, bool] | None:
1✔
1730
    if prop.get("type") != "array":
1✔
1731
        raise ValueError("array default lookup requires array properties")
×
1732

1733
    default_defined = "default" in prop
1✔
1734
    items_default = _array_items_default(prop)
1✔
1735
    items_defined = "default" in items_default
1✔
1736
    items_value = items_default.get("default") if items_defined else None
1✔
1737

1738
    if default_defined and items_defined:
1✔
1739
        raise ValueError("array default must be defined on property or items, not both")
×
1740

1741
    if items_defined:
1✔
1742
        for key in ("x-fortran-default-order", "x-fortran-default-repeat", "x-fortran-default-pad"):
1✔
1743
            if key in prop:
1✔
1744
                raise ValueError("array items default must not use x-fortran-default-* options")
×
1745
        if isinstance(items_value, list):
1✔
1746
            raise ValueError("array items default must be a scalar")
×
1747
        return items_value, True
1✔
1748

1749
    if default_defined:
1✔
1750
        default_value = prop.get("default")
1✔
1751
        if not isinstance(default_value, list):
1✔
1752
            raise ValueError("array default must be a list")
1✔
1753
        return default_value, False
1✔
1754
    return None
1✔
1755

1756

1757
def _array_items_enum(prop: dict[str, Any]) -> list[Any] | None:
1✔
1758
    current = prop
1✔
1759
    while current.get("type") == "array":
1✔
1760
        if "enum" in current:
1✔
1761
            raise ValueError("array enum must be defined on items")
1✔
1762
        items = current.get("items")
1✔
1763
        if not isinstance(items, dict):
1✔
1764
            raise ValueError("array property must define 'items'")
×
1765
        current = items
1✔
1766
    return current.get("enum")
1✔
1767

1768

1769
def _array_items_default(prop: dict[str, Any]) -> dict[str, Any]:
1✔
1770
    current = prop
1✔
1771
    while current.get("type") == "array":
1✔
1772
        items = current.get("items")
1✔
1773
        if not isinstance(items, dict):
1✔
1774
            raise ValueError("array property must define 'items'")
×
1775
        if items.get("type") == "array":
1✔
1776
            raise ValueError("nested array properties are not supported; use x-fortran-shape")
×
1777
        current = items
1✔
1778
    return current
1✔
1779

1780

1781
def _array_items_bounds(prop: dict[str, Any]) -> dict[str, Any]:
1✔
1782
    current = prop
1✔
1783
    while current.get("type") == "array":
1✔
1784
        for key in ("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"):
1✔
1785
            if key in current:
1✔
1786
                raise ValueError("array bounds must be defined on items")
×
1787
        items = current.get("items")
1✔
1788
        if not isinstance(items, dict):
1✔
1789
            raise ValueError("array property must define 'items'")
×
1790
        if items.get("type") == "array":
1✔
1791
            raise ValueError("nested array properties are not supported; use x-fortran-shape")
×
1792
        current = items
1✔
1793
    return current
1✔
1794

1795

1796
def _validate_enum_scalar(value: Any, category: str, label: str) -> None:
1✔
1797
    if category == "integer":
1✔
1798
        if isinstance(value, bool) or not isinstance(value, int):
1✔
1799
            raise ValueError(f"{label} values must be integers")
×
1800
        return
1✔
1801
    if category == "string":
1✔
1802
        if not isinstance(value, str):
1✔
1803
            raise ValueError(f"{label} values must be strings")
×
1804
        return
1✔
1805
    raise ValueError("enum only supported for integer or string values")
×
1806

1807

1808
def _ensure_enum_member(value: Any, enum_values: list[Any], category: str, label: str) -> None:
1✔
1809
    _validate_enum_scalar(value, category, label)
×
1810
    if value not in enum_values:
×
1811
        raise ValueError(f"{label} value must be one of enum values")
×
1812

1813

1814
def _validate_enum_defaults(
1✔
1815
    prop: dict[str, Any],
1816
    type_info: FieldTypeInfo,
1817
    enum_values: list[Any],
1818
    category: str,
1819
    constants: dict[str, int | float] | None,
1820
) -> None:
1821
    if type_info.category == "array":
1✔
1822
        array_default = _array_default_value(prop)
1✔
1823
        if array_default is None:
1✔
1824
            return
1✔
1825
        default_value, default_from_items = array_default
×
1826
        if default_from_items and isinstance(default_value, list):
×
1827
            raise ValueError("array items default must be a scalar")
×
1828
        if isinstance(default_value, list):
×
1829
            values = _ensure_flat_scalar_list(default_value, "array default")
×
1830
            _parse_default_dimensions(type_info.dimensions, constants)
×
1831
            for value in values:
×
1832
                _ensure_enum_member(value, enum_values, category, "default")
×
1833
        else:
1834
            _ensure_enum_member(default_value, enum_values, category, "default")
×
1835
        pad_raw = prop.get("x-fortran-default-pad")
×
1836
        if pad_raw is not None:
×
1837
            pad_values = pad_raw if isinstance(pad_raw, list) else [pad_raw]
×
1838
            pad_values = _ensure_flat_scalar_list(pad_values, "array default pad")
×
1839
            for value in pad_values:
×
1840
                _ensure_enum_member(value, enum_values, category, "pad")
×
1841
        return
×
1842
    if "default" not in prop:
1✔
1843
        return
1✔
1844
    default_value = prop["default"]
×
1845
    if isinstance(default_value, list):
×
1846
        raise ValueError("scalar default must not be a list")
×
1847
    _ensure_enum_member(default_value, enum_values, category, "default")
×
1848

1849

1850
def _validate_enum_examples(
1✔
1851
    prop: dict[str, Any],
1852
    type_info: FieldTypeInfo,
1853
    enum_values: list[Any],
1854
    category: str,
1855
) -> None:
1856
    examples = prop.get("examples")
1✔
1857
    if examples is None:
1✔
1858
        return
1✔
1859
    if not isinstance(examples, list):
×
1860
        raise ValueError("property examples must be a list")
×
1861
    for example in examples:
×
1862
        if type_info.category == "array":
×
1863
            if isinstance(example, list):
×
1864
                values = _ensure_flat_scalar_list(example, "array examples")
×
1865
                for value in values:
×
1866
                    _ensure_enum_member(value, enum_values, category, "example")
×
1867
            else:
1868
                _ensure_enum_member(example, enum_values, category, "example")
×
1869
        else:
1870
            if isinstance(example, list):
×
1871
                raise ValueError("scalar examples must not be lists")
×
1872
            _ensure_enum_member(example, enum_values, category, "example")
×
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