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

MuellerSeb / nml-tools / 26599959004

28 May 2026 08:20PM UTC coverage: 83.722% (+0.1%) from 83.612%
26599959004

Pull #34

github

MuellerSeb
Cover reserved identifier validation branches

Add focused tests for reserved separator validation in schema traversal, f2py kind collection, and shared config normalization.

These tests exercise the new validation paths added for generated-name collision prevention and recover the branch coverage lost by the recent stricter checks.
Pull Request #34: Use explicit defaults for runtime dimensions

102 of 109 new or added lines in 6 files covered. (93.58%)

14 existing lines in 4 files now uncovered.

3806 of 4546 relevant lines covered (83.72%)

0.84 hits per line

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

94.58
/src/nml_tools/codegen_f2py.py
1
"""f2py and Python wrapper code generation."""
2

3
from __future__ import annotations
1✔
4

5
import hashlib
1✔
6
import keyword
1✔
7
import re
1✔
8
from dataclasses import dataclass
1✔
9
from pathlib import Path
1✔
10
from typing import Any, Iterable, cast
1✔
11

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

14
from ._utils import (
1✔
15
    normalize_constant_values,
16
    normalize_runtime_dimensions,
17
    reject_constant_dimension_overlap,
18
    strip_trailing_whitespace,
19
    validate_user_fortran_identifier,
20
)
21
from .codegen_fortran import (
1✔
22
    FieldSpec,
23
    FieldTypeInfo,
24
    _build_context,
25
    _derived_schema,
26
    _derived_type_name,
27
    _field_type_info,
28
    _reject_runtime_dimension_lengths,
29
)
30

31
_TEMPLATE_ENV = Environment(
1✔
32
    loader=FileSystemLoader(Path(__file__).resolve().parent / "templates"),
33
    trim_blocks=True,
34
    lstrip_blocks=False,
35
    keep_trailing_newline=True,
36
    undefined=StrictUndefined,
37
)
38

39

40
@dataclass
1✔
41
class F2pyDerivedLeafSpec:
1✔
42
    """An intrinsic ABI leaf flattened from a derived Python argument."""
43

44
    name: str
1✔
45
    encoded_name: str
1✔
46
    has_name: str
1✔
47
    rank: int
1✔
48
    numpy_dtype: str | None
1✔
49
    dummy_value: str
1✔
50

51

52
@dataclass
1✔
53
class F2pyArgumentSpec:
1✔
54
    """Python wrapper argument metadata."""
55

56
    name: str
1✔
57
    title: str
1✔
58
    required: bool
1✔
59
    rank: int
1✔
60
    numpy_dtype: str | None
1✔
61
    dummy_value: str
1✔
62
    doc_type: str
1✔
63
    requirement: str
1✔
64
    has_flag: str | None = None
1✔
65
    fixed_shape: list[int] | None = None
1✔
66
    python_name: str | None = None
1✔
67
    derived_leaves: list[F2pyDerivedLeafSpec] | None = None
1✔
68
    derived_type_name: str | None = None
1✔
69

70

71
@dataclass
1✔
72
class F2pyArrayDimensionSpec:
1✔
73
    """Dimension arguments for a f2py-visible array dummy."""
74

75
    field_name: str
1✔
76
    names: list[str]
1✔
77

78

79
@dataclass
1✔
80
class F2pyNamelistSpec:
1✔
81
    """Metadata needed for f2py wrapper generation."""
82

83
    namelist_name: str
1✔
84
    brief: str
1✔
85
    details: str
1✔
86
    details_lines: list[str]
1✔
87
    module_name: str
1✔
88
    type_name: str
1✔
89
    helper_module: str
1✔
90
    kind_module: str
1✔
91
    kind_imports: list[str]
1✔
92
    f2py_module_name: str
1✔
93
    resolve_handle_name: str
1✔
94
    handle_ctype: str
1✔
95
    errmsg_len: int
1✔
96
    argument_list: list[str]
1✔
97
    argument_declarations: list[str]
1✔
98
    bridge_declarations: list[str]
1✔
99
    bridge_assignments: list[str]
1✔
100
    set_call_arguments: list[str]
1✔
101
    set_dims_argument_list: list[str]
1✔
102
    set_dims_argument_declarations: list[str]
1✔
103
    set_dims_bridge_declarations: list[str]
1✔
104
    set_dims_bridge_assignments: list[str]
1✔
105
    set_dims_call_arguments: list[str]
1✔
106
    set_dims_args: list[F2pyArgumentSpec]
1✔
107
    array_dimensions: list[F2pyArrayDimensionSpec]
1✔
108
    required_args: list[F2pyArgumentSpec]
1✔
109
    optional_args: list[F2pyArgumentSpec]
1✔
110
    all_args: list[F2pyArgumentSpec]
1✔
111
    derived_type_names: list[str]
1✔
112

113

114
@dataclass
1✔
115
class PythonWrapperSpec:
1✔
116
    """Metadata needed for Python wrapper generation."""
117

118
    class_name: str
1✔
119
    namelist_name: str
1✔
120
    brief: str
1✔
121
    f2py_module_name: str
1✔
122
    extension_module: str
1✔
123
    required_args: list[F2pyArgumentSpec]
1✔
124
    optional_args: list[F2pyArgumentSpec]
1✔
125
    all_args: list[F2pyArgumentSpec]
1✔
126
    set_dims_args: list[F2pyArgumentSpec]
1✔
127

128

129
@dataclass
1✔
130
class F2pyKindUsage:
1✔
131
    """Kind aliases used by f2py wrapper dummy arguments."""
132

133
    real: set[str]
1✔
134
    integer: set[str]
1✔
135

136

137
@dataclass
1✔
138
class F2pyCTypeMap:
1✔
139
    """Explicit C type mapping for f2py kinds."""
140

141
    real: dict[str, str]
1✔
142
    integer: dict[str, str]
1✔
143

144

145
def generate_f2py_wrappers(
1✔
146
    schemas: Iterable[dict[str, Any]],
147
    output: str | Path,
148
    *,
149
    helper_module: str = "nml_helper",
150
    kind_module: str | None = None,
151
    kind_map: dict[str, str] | None = None,
152
    kind_allowlist: Iterable[str] | None = None,
153
    constants: dict[str, int] | None = None,
154
    dimensions: dict[str, int] | None = None,
155
    errmsg_len: int = 1024,
156
) -> None:
157
    """Generate f2py-facing Fortran wrappers for *schemas* at *output*."""
158
    output_path = Path(output)
1✔
159
    rendered = render_f2py_wrappers(
1✔
160
        schemas,
161
        file_name=output_path.name,
162
        helper_module=helper_module,
163
        kind_module=kind_module,
164
        kind_map=kind_map,
165
        kind_allowlist=kind_allowlist,
166
        constants=constants,
167
        dimensions=dimensions,
168
        errmsg_len=errmsg_len,
169
    )
170
    output_path.parent.mkdir(parents=True, exist_ok=True)
1✔
171
    output_path.write_text(rendered, encoding="ascii")
1✔
172

173

174
def render_f2py_wrappers(
1✔
175
    schemas: Iterable[dict[str, Any]],
176
    *,
177
    file_name: str,
178
    helper_module: str = "nml_helper",
179
    kind_module: str | None = None,
180
    kind_map: dict[str, str] | None = None,
181
    kind_allowlist: Iterable[str] | None = None,
182
    constants: dict[str, int] | None = None,
183
    dimensions: dict[str, int] | None = None,
184
    errmsg_len: int = 1024,
185
) -> str:
186
    """Render f2py-facing Fortran wrappers for *schemas*."""
187
    specs = [
1✔
188
        build_f2py_namelist_spec(
189
            schema,
190
            helper_module=helper_module,
191
            kind_module=kind_module,
192
            kind_map=kind_map,
193
            kind_allowlist=kind_allowlist,
194
            constants=constants,
195
            dimensions=dimensions,
196
            errmsg_len=errmsg_len,
197
        )
198
        for schema in schemas
199
    ]
200
    rendered = _TEMPLATE_ENV.get_template("f2py_wrappers.f90.j2").render(
1✔
201
        {"file_name": file_name, "specs": specs}
202
    )
203
    return strip_trailing_whitespace(rendered)
1✔
204

205

206
def generate_python_wrappers(
1✔
207
    specs: Iterable[tuple[F2pyNamelistSpec, str]],
208
    output: str | Path,
209
    *,
210
    py_style: str = "numpy",
211
) -> None:
212
    """Generate Python wrapper classes for f2py namelist *specs* at *output*."""
213
    rendered = render_python_wrappers(specs, py_style=py_style)
1✔
214
    output_path = Path(output)
1✔
215
    output_path.parent.mkdir(parents=True, exist_ok=True)
1✔
216
    output_path.write_text(rendered, encoding="ascii")
1✔
217

218

219
def render_python_wrappers(
1✔
220
    specs: Iterable[tuple[F2pyNamelistSpec, str]],
221
    *,
222
    py_style: str = "numpy",
223
) -> str:
224
    """Render Python wrapper classes for f2py namelist *specs*."""
225
    if py_style not in {"numpy", "doxygen"}:
1✔
226
        raise ValueError("python documentation style must be 'numpy' or 'doxygen'")
1✔
227
    spec_entries = list(specs)
1✔
228
    extension_modules: set[str] = set()
1✔
229
    for _, extension_module in spec_entries:
1✔
230
        _validate_python_module_name(extension_module)
1✔
231
        extension_modules.add(extension_module)
1✔
232
    classes: list[PythonWrapperSpec] = []
1✔
233
    for spec, extension_module in spec_entries:
1✔
234
        classes.append(
1✔
235
            PythonWrapperSpec(
236
                class_name=_class_name(spec.namelist_name),
237
                namelist_name=spec.namelist_name,
238
                brief=spec.brief,
239
                f2py_module_name=spec.f2py_module_name,
240
                extension_module=extension_module,
241
                required_args=spec.required_args,
242
                optional_args=spec.optional_args,
243
                all_args=spec.all_args,
244
                set_dims_args=spec.set_dims_args,
245
            )
246
        )
247
    rendered = _TEMPLATE_ENV.get_template("python_wrappers.py.j2").render(
1✔
248
        {
249
            "imports": sorted(extension_modules),
250
            "classes": classes,
251
            "py_style": py_style,
252
            "uses_derived": any(
253
                arg.derived_leaves is not None
254
                for cls in classes
255
                for arg in cls.all_args
256
            ),
257
        }
258
    )
259
    return strip_trailing_whitespace(rendered)
1✔
260

261

262
def generate_f2cmap(
1✔
263
    output: str | Path,
264
    usage: F2pyKindUsage,
265
    c_types: F2pyCTypeMap,
266
) -> None:
267
    """Generate a .f2py_f2cmap file for the explicitly mapped *usage*."""
268
    rendered = render_f2cmap(usage, c_types)
1✔
269
    output_path = Path(output)
1✔
270
    output_path.parent.mkdir(parents=True, exist_ok=True)
1✔
271
    output_path.write_text(rendered, encoding="ascii")
1✔
272

273

274
def render_f2cmap(
1✔
275
    usage: F2pyKindUsage,
276
    c_types: F2pyCTypeMap,
277
) -> str:
278
    """Render a .f2py_f2cmap file for the explicitly mapped *usage*."""
279
    missing_real = sorted(usage.real - set(c_types.real))
1✔
280
    missing_integer = sorted(usage.integer - set(c_types.integer))
1✔
281
    if missing_real:
1✔
282
        raise ValueError("missing f2py real C type mappings: " + ", ".join(missing_real))
1✔
283
    if missing_integer:
1✔
284
        raise ValueError(
×
285
            "missing f2py integer C type mappings: " + ", ".join(missing_integer)
286
        )
287

288
    integer_map = dict(c_types.integer)
1✔
289
    integer_map.setdefault("c_intptr_t", "long_long")
1✔
290
    real_items = ", ".join(
1✔
291
        f"{name}={c_types.real[name]!r}" for name in sorted(usage.real)
292
    )
293
    integer_items = ", ".join(
1✔
294
        f"{name}={integer_map[name]!r}" for name in sorted(usage.integer | {"c_intptr_t"})
295
    )
296
    return f"dict(real=dict({real_items}), integer=dict({integer_items}))\n"
1✔
297

298

299
def collect_f2py_kind_usage(
1✔
300
    schemas: Iterable[dict[str, Any]],
301
    *,
302
    constants: dict[str, int] | None = None,
303
    dimensions: dict[str, int] | None = None,
304
) -> F2pyKindUsage:
305
    """Collect schema kind aliases used in f2py wrapper arguments."""
306
    usage = F2pyKindUsage(real=set(), integer=set())
1✔
307
    runtime_dimension_values = normalize_runtime_dimensions(dimensions)
1✔
308
    for schema in schemas:
1✔
309
        properties = _normalized_properties(schema)
1✔
310
        field_infos = _iter_field_type_infos(schema, constants, dimensions)
1✔
311
        expanded: list[FieldTypeInfo] = []
1✔
312
        for name, type_info in field_infos:
1✔
313
            derived = _derived_schema(properties[name])
1✔
314
            if derived is None:
1✔
315
                expanded.append(type_info)
1✔
316
                continue
1✔
317
            components = derived.get("properties")
1✔
318
            if not isinstance(components, dict):
1✔
319
                continue
×
320
            for component in components.values():
1✔
321
                if isinstance(component, dict):
1✔
322
                    _reject_runtime_dimension_lengths(component, runtime_dimension_values)
1✔
323
                    expanded.append(
1✔
324
                        _field_type_info(component, normalize_constant_values(constants))
325
                    )
326
        for type_info in expanded:
1✔
327
            category = (
1✔
328
                type_info.element_category
329
                if type_info.category == "array"
330
                else type_info.category
331
            )
332
            if type_info.kind is None:
1✔
333
                continue
1✔
334
            if category == "real":
1✔
335
                usage.real.add(type_info.kind)
1✔
336
            elif category == "integer":
1✔
337
                usage.integer.add(type_info.kind)
1✔
338
    return usage
1✔
339

340

341
def merge_f2py_kind_usage(usages: Iterable[F2pyKindUsage]) -> F2pyKindUsage:
1✔
342
    """Merge multiple f2py kind usage objects."""
343
    merged = F2pyKindUsage(real=set(), integer=set())
1✔
344
    for usage in usages:
1✔
345
        merged.real.update(usage.real)
1✔
346
        merged.integer.update(usage.integer)
1✔
347
    return merged
1✔
348

349

350
def build_f2py_namelist_spec(
1✔
351
    schema: dict[str, Any],
352
    *,
353
    helper_module: str = "nml_helper",
354
    kind_module: str | None = None,
355
    kind_map: dict[str, str] | None = None,
356
    kind_allowlist: Iterable[str] | None = None,
357
    constants: dict[str, int] | None = None,
358
    dimensions: dict[str, int] | None = None,
359
    errmsg_len: int = 1024,
360
) -> F2pyNamelistSpec:
361
    """Build f2py wrapper metadata for one namelist schema."""
362
    context = _build_context(
1✔
363
        schema,
364
        helper_module=helper_module,
365
        kind_module=kind_module,
366
        kind_map=kind_map,
367
        kind_allowlist=kind_allowlist,
368
        constants=constants,
369
        dimensions=dimensions,
370
        module_doc=None,
371
    )
372
    fields = cast("list[FieldSpec]", context["fields"])
1✔
373
    type_infos = {
1✔
374
        name: type_info
375
        for name, type_info in _iter_field_type_infos(schema, constants, dimensions)
376
    }
377
    required_args: list[F2pyArgumentSpec] = []
1✔
378
    optional_args: list[F2pyArgumentSpec] = []
1✔
379
    argument_list: list[str] = []
1✔
380
    argument_declarations: list[str] = []
1✔
381
    bridge_declarations: list[str] = []
1✔
382
    bridge_assignments: list[str] = []
1✔
383
    set_call_arguments: list[str] = []
1✔
384
    set_dims_argument_list: list[str] = []
1✔
385
    set_dims_argument_declarations: list[str] = []
1✔
386
    set_dims_bridge_declarations: list[str] = []
1✔
387
    set_dims_bridge_assignments: list[str] = []
1✔
388
    set_dims_call_arguments: list[str] = []
1✔
389
    set_dims_args: list[F2pyArgumentSpec] = []
1✔
390
    array_dimensions: list[F2pyArrayDimensionSpec] = []
1✔
391
    derived_type_names: list[str] = []
1✔
392

393
    field_argument_names: set[str] = {"handle", "status", "errmsg"}
1✔
394
    field_argument_names.update(field.name.lower() for field in fields)
1✔
395

396
    argument_names_in_use: set[str] = set(field_argument_names)
1✔
397
    bridge_names_in_use: set[str] = set(field_argument_names) | {
1✔
398
        "handle",
399
        "status",
400
        "errmsg",
401
        "this",
402
    }
403

404
    for field in fields:
1✔
405
        type_info = type_infos[field.name]
1✔
406
        rank = len(type_info.dimensions) if type_info.category == "array" else 0
1✔
407
        prop = _normalized_properties(schema)[field.name]
1✔
408
        derived = _derived_schema(prop)
1✔
409
        if derived is not None:
1✔
410
            dim_names: list[str] = []
1✔
411
            if rank:
1✔
412
                for dim_name in _array_dimension_argument_names(field.name, rank):
1✔
413
                    generated_dim_name = _unique_generated_name(dim_name, argument_names_in_use)
1✔
414
                    argument_names_in_use.add(generated_dim_name.lower())
1✔
415
                    dim_names.append(generated_dim_name)
1✔
416
            derived_type_name = _derived_type_name(derived)
1✔
417
            if derived_type_name.lower() not in {
1✔
418
                name.lower() for name in derived_type_names
419
            }:
420
                derived_type_names.append(derived_type_name)
1✔
421
            leaves = _f2py_derived_leaves(
1✔
422
                field.name,
423
                derived,
424
                type_info,
425
                constants,
426
                argument_names_in_use,
427
            )
428
            outer_has_flag: str | None = None
1✔
429
            if not field.required:
1✔
430
                outer_has_flag = _unique_generated_name(
1✔
431
                    f"has__{field.name}", argument_names_in_use
432
                )
433
                argument_names_in_use.add(outer_has_flag.lower())
1✔
434
                argument_list.append(outer_has_flag)
1✔
435
                argument_declarations.append(
1✔
436
                    f"logical, intent(in) :: {outer_has_flag} "
437
                    f"!< whether {field.name} was provided"
438
                )
439
            spec = F2pyArgumentSpec(
1✔
440
                name=field.name,
441
                title=_one_line(field.title),
442
                required=field.required,
443
                rank=rank,
444
                numpy_dtype=None,
445
                dummy_value="None",
446
                doc_type=("sequence of mappings" if rank else "mapping"),
447
                requirement="required" if field.required else "optional",
448
                has_flag=outer_has_flag,
449
                derived_leaves=leaves,
450
                derived_type_name=derived_type_name,
451
            )
452
            if field.required:
1✔
453
                required_args.append(spec)
1✔
454
            else:
455
                optional_args.append(spec)
1✔
456
            if rank:
1✔
457
                argument_list.extend(dim_names)
1✔
458
                argument_declarations.extend(
1✔
459
                    f"integer, intent(in) :: {dim_name} !< extent for {field.name}"
460
                    for dim_name in dim_names
461
                )
462
                array_dimensions.append(
1✔
463
                    F2pyArrayDimensionSpec(field_name=field.name, names=dim_names)
464
                )
465
            for leaf in leaves:
1✔
466
                argument_list.append(leaf.encoded_name)
1✔
467
                if rank:
1✔
468
                    dims = ", ".join(dim_names)
1✔
469
                    leaf_type = _field_type_info(
1✔
470
                        cast("dict[str, Any]", derived["properties"][leaf.name]),
471
                        normalize_constant_values(constants),
472
                    )
473
                    argument_declarations.append(
1✔
474
                        f"{leaf_type.arg_type_spec}, dimension({dims}), intent(in) :: "
475
                        f"{leaf.encoded_name} !< {field.name}%{leaf.name}"
476
                    )
477
                    argument_list.append(leaf.has_name)
1✔
478
                    argument_declarations.append(
1✔
479
                        f"logical, dimension({dims}), intent(in) :: {leaf.has_name} "
480
                        f"!< provided mask for {field.name}%{leaf.name}"
481
                    )
482
                else:
483
                    leaf_type = _field_type_info(
1✔
484
                        cast("dict[str, Any]", derived["properties"][leaf.name]),
485
                        normalize_constant_values(constants),
486
                    )
487
                    argument_declarations.append(
1✔
488
                        f"{leaf_type.arg_type_spec}, intent(in) :: {leaf.encoded_name} "
489
                        f"!< {field.name}%{leaf.name}"
490
                    )
491
                    argument_list.append(leaf.has_name)
1✔
492
                    argument_declarations.append(
1✔
493
                        f"logical, intent(in) :: {leaf.has_name} "
494
                        f"!< whether {field.name}%{leaf.name} was provided"
495
                    )
496
            bridge_names_in_use.update(argument_names_in_use)
1✔
497
            maybe_name = _unique_generated_name(
1✔
498
                _maybe_bridge_name(field.name), bridge_names_in_use
499
            )
500
            bridge_names_in_use.add(maybe_name.lower())
1✔
501
            bridge_declarations.extend(
1✔
502
                _derived_bridge_declarations(maybe_name, derived_type_name, rank, field.required)
503
            )
504
            bridge_assignments.extend(
1✔
505
                _derived_bridge_assignments(
506
                    field.name,
507
                    maybe_name,
508
                    leaves,
509
                    rank,
510
                    field.required,
511
                    outer_has_flag,
512
                    dim_names,
513
                    init_allocates_array=field.runtime_sized_array,
514
                )
515
            )
516
            set_call_arguments.append(f"{field.name}={maybe_name}")
1✔
517
            continue
1✔
518
        has_flag: str | None = None
1✔
519
        dim_names = []
1✔
520
        if rank:
1✔
521
            for dim_name in _array_dimension_argument_names(field.name, rank):
1✔
522
                generated_dim_name = _unique_generated_name(dim_name, argument_names_in_use)
1✔
523
                argument_names_in_use.add(generated_dim_name.lower())
1✔
524
                dim_names.append(generated_dim_name)
1✔
525
        if not field.required:
1✔
526
            has_base = f"has__{field.name}"
1✔
527
            has_flag = _unique_generated_name(has_base, argument_names_in_use)
1✔
528
            argument_names_in_use.add(has_flag.lower())
1✔
529
        spec = F2pyArgumentSpec(
1✔
530
            name=field.name,
531
            title=_one_line(field.title),
532
            required=field.required,
533
            rank=rank,
534
            numpy_dtype=_numpy_dtype(type_info),
535
            dummy_value=_python_dummy_value(type_info),
536
            doc_type=_python_doc_type(type_info),
537
            requirement="required" if field.required else "optional",
538
            has_flag=has_flag,
539
        )
540
        if field.required:
1✔
541
            required_args.append(spec)
1✔
542
        else:
543
            optional_args.append(spec)
1✔
544
        field_arguments, field_declarations = _f2py_field_arguments(
1✔
545
            field, type_info, dim_names=dim_names
546
        )
547
        argument_list.extend(field_arguments)
1✔
548
        argument_declarations.extend(field_declarations)
1✔
549
        if rank > 0:
1✔
550
            array_dimensions.append(
1✔
551
                F2pyArrayDimensionSpec(field_name=field.name, names=dim_names)
552
            )
553
        if has_flag is not None:
1✔
554
            argument_list.append(has_flag)
1✔
555
            argument_declarations.append(
1✔
556
                f"logical, intent(in) :: {has_flag} !< whether {field.name} was provided"
557
            )
558
            bridge_names_in_use.update(argument_names_in_use)
1✔
559
            maybe_base = _maybe_bridge_name(field.name)
1✔
560
            maybe_name = _unique_generated_name(maybe_base, bridge_names_in_use)
1✔
561
            bridge_names_in_use.add(maybe_name.lower())
1✔
562
            bridge_declarations.append(
1✔
563
                _optional_bridge_declaration(field.name, type_info, maybe_name)
564
            )
565
            bridge_assignments.append(
1✔
566
                _optional_bridge_assignment(field.name, type_info, has_flag, maybe_name)
567
            )
568
            set_call_arguments.append(f"{field.name}={maybe_name}")
1✔
569
        else:
570
            set_call_arguments.append(f"{field.name}={field.name}")
1✔
571

572
    runtime_dimension_args = cast("list[dict[str, str]]", context["set_dims_arguments"])
1✔
573
    set_dims_argument_names_in_use: set[str] = {
1✔
574
        str(entry["name"]).lower() for entry in runtime_dimension_args
575
    }
576
    set_dims_bridge_names_in_use: set[str] = set(set_dims_argument_names_in_use) | {
1✔
577
        "handle",
578
        "status",
579
        "errmsg",
580
        "this",
581
    }
582

583
    for entry in runtime_dimension_args:
1✔
584
        const_name = entry["name"]
1✔
585
        arg_name = entry["arg_name"]
1✔
586
        python_name = _python_parameter_name(const_name)
1✔
587
        has_base = f"has__{const_name}"
1✔
588
        has_flag = _unique_generated_name(has_base, set_dims_argument_names_in_use)
1✔
589
        set_dims_argument_names_in_use.add(has_flag.lower())
1✔
590
        set_dims_args.append(
1✔
591
            F2pyArgumentSpec(
592
                name=const_name,
593
                title=f"Runtime dimension override for {const_name}",
594
                required=False,
595
                rank=0,
596
                numpy_dtype="int",
597
                dummy_value="0",
598
                doc_type="int",
599
                requirement="optional",
600
                has_flag=has_flag,
601
                fixed_shape=None,
602
                python_name=python_name,
603
            )
604
        )
605
        set_dims_argument_list.append(const_name)
1✔
606
        set_dims_argument_declarations.append(
1✔
607
            f"integer, intent(in) :: {const_name} !< runtime dimension override for {const_name}"
608
        )
609
        set_dims_argument_list.append(has_flag)
1✔
610
        set_dims_argument_declarations.append(
1✔
611
            f"logical, intent(in) :: {has_flag} !< whether {const_name} was provided"
612
        )
613
        maybe_base = _maybe_bridge_name(const_name)
1✔
614
        maybe_name = _unique_generated_name(maybe_base, set_dims_bridge_names_in_use)
1✔
615
        set_dims_bridge_names_in_use.add(maybe_name.lower())
1✔
616
        set_dims_bridge_declarations.append(f"integer, allocatable :: {maybe_name}")
1✔
617
        set_dims_bridge_assignments.append(
1✔
618
            f"if ({has_flag}) then\n"
619
            f"  allocate({maybe_name})\n"
620
            f"  {maybe_name} = {const_name}\n"
621
            "end if"
622
        )
623
        set_dims_call_arguments.append(f"{arg_name}={maybe_name}")
1✔
624

625
    namelist_name = cast("str", context["namelist_name"])
1✔
626
    details = cast("str", context["details_text"])
1✔
627
    return F2pyNamelistSpec(
1✔
628
        namelist_name=namelist_name,
629
        brief=_one_line(cast("str", context["brief_text"])),
630
        details=details,
631
        details_lines=details.splitlines() or [details],
632
        module_name=cast("str", context["module_name"]),
633
        type_name=cast("str", context["type_name"]),
634
        helper_module=helper_module,
635
        kind_module=cast("str", context["kind_module"]),
636
        kind_imports=cast("list[str]", context["kind_imports"]),
637
        f2py_module_name=f"f2py_{namelist_name}",
638
        resolve_handle_name=f"{context['module_name']}_resolve_handle",
639
        handle_ctype="c_intptr_t",
640
        errmsg_len=errmsg_len,
641
        argument_list=argument_list,
642
        argument_declarations=argument_declarations,
643
        bridge_declarations=bridge_declarations,
644
        bridge_assignments=bridge_assignments,
645
        set_call_arguments=set_call_arguments,
646
        set_dims_argument_list=set_dims_argument_list,
647
        set_dims_argument_declarations=set_dims_argument_declarations,
648
        set_dims_bridge_declarations=set_dims_bridge_declarations,
649
        set_dims_bridge_assignments=set_dims_bridge_assignments,
650
        set_dims_call_arguments=set_dims_call_arguments,
651
        set_dims_args=set_dims_args,
652
        array_dimensions=array_dimensions,
653
        required_args=required_args,
654
        optional_args=optional_args,
655
        all_args=required_args + optional_args,
656
        derived_type_names=derived_type_names,
657
    )
658

659

660
def _iter_field_type_infos(
1✔
661
    schema: dict[str, Any],
662
    constants: dict[str, int] | None,
663
    dimensions: dict[str, int] | None = None,
664
) -> list[tuple[str, FieldTypeInfo]]:
665
    properties = _normalized_properties(schema)
1✔
666
    constants = normalize_constant_values(constants)
1✔
667
    runtime_dimension_values = normalize_runtime_dimensions(dimensions)
1✔
668
    reject_constant_dimension_overlap(constants, runtime_dimension_values)
1✔
669
    field_types: list[tuple[str, FieldTypeInfo]] = []
1✔
670
    for name, prop in properties.items():
1✔
671
        _reject_runtime_dimension_lengths(prop, runtime_dimension_values)
1✔
672
        field_types.append((name, _field_type_info(prop, constants)))
1✔
673
    return field_types
1✔
674

675

676
def _normalized_properties(schema: dict[str, Any]) -> dict[str, dict[str, Any]]:
1✔
677
    properties = schema.get("properties")
1✔
678
    if not isinstance(properties, dict):
1✔
679
        raise ValueError("schema must define object 'properties'")
×
680
    normalized: dict[str, dict[str, Any]] = {}
1✔
681
    seen: set[str] = set()
1✔
682
    for raw_name, prop in properties.items():
1✔
683
        if not isinstance(raw_name, str):
1✔
684
            raise ValueError("property names must be strings")
1✔
685
        validate_user_fortran_identifier(raw_name, label=f"property '{raw_name}'")
1✔
686
        if not isinstance(prop, dict):
1✔
687
            raise ValueError(f"property '{raw_name}' must be an object")
1✔
688
        name = raw_name.lower()
1✔
689
        if name in seen:
1✔
690
            raise ValueError(f"duplicate property '{raw_name}'")
1✔
691
        seen.add(name)
1✔
692
        normalized[name] = prop
1✔
693
    return normalized
1✔
694

695

696
def _class_name(namelist_name: str) -> str:
1✔
697
    parts = [part for part in re.split(r"[^0-9A-Za-z]+", namelist_name) if part]
1✔
698
    if not parts:
1✔
699
        return "Namelist"
×
700
    name = "".join(part[:1].upper() + part[1:] for part in parts)
1✔
701
    if name[0].isdigit():
1✔
702
        name = f"Namelist{name}"
×
703
    if keyword.iskeyword(name):
1✔
704
        name = f"{name}Namelist"
×
705
    return name
1✔
706

707

708
def _python_parameter_name(name: str) -> str:
1✔
709
    if not name.isidentifier():
1✔
710
        raise ValueError(f"name '{name}' is not a valid Python identifier")
×
711
    if keyword.iskeyword(name):
1✔
712
        return f"{name}_"
1✔
713
    return name
1✔
714

715

716
def _numpy_dtype(type_info: FieldTypeInfo) -> str | None:
1✔
717
    category = (
1✔
718
        type_info.element_category
719
        if type_info.category == "array"
720
        else type_info.category
721
    )
722
    if category == "real":
1✔
723
        return "float"
1✔
724
    if category == "integer":
1✔
725
        return "int"
1✔
726
    if category == "boolean":
1✔
727
        return "bool"
×
728
    if category == "string":
1✔
729
        return "str"
1✔
730
    return None
×
731

732

733
def _python_dummy_value(type_info: FieldTypeInfo) -> str:
1✔
734
    category = (
1✔
735
        type_info.element_category
736
        if type_info.category == "array"
737
        else type_info.category
738
    )
739
    if category == "real":
1✔
740
        return "0.0"
1✔
741
    if category == "integer":
1✔
742
        return "0"
1✔
743
    if category == "boolean":
1✔
744
        return "False"
×
745
    if category == "string":
1✔
746
        return '""'
1✔
747
    return "None"
×
748

749

750
def _python_doc_type(type_info: FieldTypeInfo) -> str:
1✔
751
    category = (
1✔
752
        type_info.element_category
753
        if type_info.category == "array"
754
        else type_info.category
755
    )
756
    if category == "real":
1✔
757
        type_name = "float"
1✔
758
    elif category == "integer":
1✔
759
        type_name = "int"
1✔
760
    elif category == "boolean":
1✔
761
        type_name = "bool"
×
762
    elif category == "string":
1✔
763
        type_name = "str"
1✔
764
    else:
765
        type_name = "Any"
×
766
    if type_info.category == "array":
1✔
767
        return f"array_like of {type_name}"
1✔
768
    return type_name
1✔
769

770

771
def _one_line(value: str) -> str:
1✔
772
    return " ".join(value.splitlines()).strip()
1✔
773

774

775
def _array_dimension_argument_names(name: str, rank: int) -> list[str]:
1✔
776
    return [f"{name}__n{idx}" for idx in range(1, rank + 1)]
1✔
777

778

779
def _unique_generated_name(base_name: str, taken_names: set[str]) -> str:
1✔
780
    candidate = _bounded_generated_name(base_name)
1✔
781
    if candidate.lower() not in taken_names:
1✔
782
        return candidate
1✔
UNCOV
783
    index = 1
×
UNCOV
784
    while True:
×
UNCOV
785
        candidate = _bounded_generated_name(f"{base_name}_{index}")
×
UNCOV
786
        if candidate.lower() not in taken_names:
×
UNCOV
787
            return candidate
×
788
        index += 1
×
789

790

791
def _bounded_generated_name(base_name: str) -> str:
1✔
792
    if len(base_name) <= 63:
1✔
793
        return base_name
1✔
794
    suffix = hashlib.sha1(base_name.encode("ascii")).hexdigest()[:10]
1✔
795
    return f"{base_name[:52]}_{suffix}"
1✔
796

797

798
def _maybe_bridge_name(name: str) -> str:
1✔
799
    return f"maybe__{name}"
1✔
800

801

802
def _f2py_field_arguments(
1✔
803
    field: FieldSpec,
804
    type_info: FieldTypeInfo,
805
    *,
806
    dim_names: list[str] | None = None,
807
) -> tuple[list[str], list[str]]:
808
    requirement = "required" if field.required else "optional"
1✔
809
    if type_info.category != "array":
1✔
810
        return [field.name], [
1✔
811
            f"{type_info.arg_type_spec}, intent(in) :: {field.name} "
812
            f"!< {_one_line(field.title)} ({requirement})"
813
        ]
814

815
    if dim_names is None:
1✔
816
        dim_names = _array_dimension_argument_names(field.name, len(type_info.dimensions))
×
817
    dims = ", ".join(dim_names)
1✔
818
    declarations = [
1✔
819
        f"integer, intent(in) :: {dim_name} !< extent for {field.name}"
820
        for dim_name in dim_names
821
    ]
822
    declarations.append(
1✔
823
        f"{type_info.arg_type_spec}, dimension({dims}), intent(in) :: {field.name} "
824
        f"!< {_one_line(field.title)} ({requirement})"
825
    )
826
    return [*dim_names, field.name], declarations
1✔
827

828

829
def _f2py_derived_leaves(
1✔
830
    field_name: str,
831
    derived: dict[str, Any],
832
    type_info: FieldTypeInfo,
833
    constants: dict[str, int] | None,
834
    argument_names_in_use: set[str],
835
) -> list[F2pyDerivedLeafSpec]:
836
    properties = derived.get("properties")
1✔
837
    if not isinstance(properties, dict):
1✔
838
        raise ValueError(f"derived property '{field_name}' must define properties")
×
839
    rank = len(type_info.dimensions) if type_info.category == "array" else 0
1✔
840
    static_constants = normalize_constant_values(constants)
1✔
841
    leaves: list[F2pyDerivedLeafSpec] = []
1✔
842
    for name, prop in properties.items():
1✔
843
        if not isinstance(name, str) or not isinstance(prop, dict):
1✔
844
            raise ValueError(f"derived property '{field_name}' components must be objects")
×
845
        leaf_info = _field_type_info(prop, static_constants)
1✔
846
        encoded_name = _unique_generated_name(f"{field_name}__{name}", argument_names_in_use)
1✔
847
        argument_names_in_use.add(encoded_name.lower())
1✔
848
        has_name = _unique_generated_name(f"has__{encoded_name}", argument_names_in_use)
1✔
849
        argument_names_in_use.add(has_name.lower())
1✔
850
        leaves.append(
1✔
851
            F2pyDerivedLeafSpec(
852
                name=name,
853
                encoded_name=encoded_name,
854
                has_name=has_name,
855
                rank=rank,
856
                numpy_dtype=_numpy_dtype(leaf_info),
857
                dummy_value=_python_dummy_value(leaf_info),
858
            )
859
        )
860
    return leaves
1✔
861

862

863
def _derived_bridge_declarations(
1✔
864
    maybe_name: str,
865
    type_name: str,
866
    rank: int,
867
    required: bool,
868
) -> list[str]:
869
    if rank:
1✔
870
        dims = ", ".join(":" for _ in range(rank))
1✔
871
        return [f"type({type_name}), dimension({dims}), allocatable :: {maybe_name}"]
1✔
872
    if required:
1✔
873
        return [f"type({type_name}) :: {maybe_name}"]
1✔
874
    return [f"type({type_name}), allocatable :: {maybe_name}"]
1✔
875

876

877
def _derived_bridge_assignments(
1✔
878
    name: str,
879
    maybe_name: str,
880
    leaves: list[F2pyDerivedLeafSpec],
881
    rank: int,
882
    required: bool,
883
    outer_has_flag: str | None,
884
    dim_names: list[str],
885
    *,
886
    init_allocates_array: bool,
887
) -> list[str]:
888
    lines: list[str] = []
1✔
889
    gated = not required
1✔
890
    if gated:
1✔
891
        if outer_has_flag is None:
1✔
892
            raise ValueError(f"optional derived property '{name}' is missing presence metadata")
×
893
        lines.append(f"if ({outer_has_flag}) then")
1✔
894
        indent = "  "
1✔
895
        if rank == 0:
1✔
896
            lines.append(f"  allocate({maybe_name})")
1✔
897
    else:
898
        indent = ""
1✔
899
    if rank and not init_allocates_array:
1✔
900
        dims = ", ".join(dim_names)
1✔
901
        lines.append(f"{indent}allocate({maybe_name}({dims}))")
1✔
902
    lines.append(f"{indent}status = this%init_type({name}={maybe_name}, errmsg=errmsg)")
1✔
903
    lines.append(f"{indent}if (status /= NML_OK) return")
1✔
904
    if rank:
1✔
905
        if init_allocates_array:
1✔
906
            for dim_index, dim_name in enumerate(dim_names, start=1):
1✔
907
                lines.extend(
1✔
908
                    [
909
                        f"{indent}if ({dim_name} > size({maybe_name}, {dim_index})) then",
910
                        f"{indent}  status = NML_ERR_INVALID_INDEX",
911
                        f'{indent}  errmsg = "dimension {dim_index} exceeds bounds for \'{name}\'"',
912
                        f"{indent}  return",
913
                        f"{indent}end if",
914
                    ]
915
                )
916
        bounds = ", ".join(f"1:{dim_name}" for dim_name in dim_names)
1✔
917
        for leaf in leaves:
1✔
918
            lines.append(f"{indent}where ({leaf.has_name})")
1✔
919
            lines.append(
1✔
920
                f"{indent}  {maybe_name}({bounds})%{leaf.name} = {leaf.encoded_name}"
921
            )
922
            lines.append(f"{indent}end where")
1✔
923
    else:
924
        for leaf in leaves:
1✔
925
            lines.append(
1✔
926
                f"{indent}if ({leaf.has_name}) "
927
                f"{maybe_name}%{leaf.name} = {leaf.encoded_name}"
928
            )
929
    if gated:
1✔
930
        lines.append("end if")
1✔
931
    return ["\n".join(lines)]
1✔
932

933

934
def _optional_bridge_declaration(name: str, type_info: FieldTypeInfo, maybe_name: str) -> str:
1✔
935
    if type_info.category == "array":
1✔
936
        dims = ", ".join(":" for _ in type_info.dimensions)
1✔
937
        if type_info.element_category == "string":
1✔
938
            return f"character(len=:), dimension({dims}), allocatable :: {maybe_name}"
1✔
939
        return f"{type_info.arg_type_spec}, dimension({dims}), allocatable :: {maybe_name}"
1✔
940
    if type_info.category == "string":
1✔
941
        return f"character(len=:), allocatable :: {maybe_name}"
×
942
    return f"{type_info.arg_type_spec}, allocatable :: {maybe_name}"
1✔
943

944

945
def _optional_bridge_assignment(
1✔
946
    name: str,
947
    type_info: FieldTypeInfo,
948
    has_flag: str,
949
    maybe_name: str,
950
) -> str:
951
    if type_info.category == "array":
1✔
952
        dims = ", ".join(_array_dimension_argument_names(name, len(type_info.dimensions)))
1✔
953
        allocate_stmt = f"allocate({maybe_name}({dims}))"
1✔
954
        if type_info.element_category == "string":
1✔
955
            allocate_stmt = f"allocate(character(len=len({name})) :: {maybe_name}({dims}))"
1✔
956
        return (
1✔
957
            f"if ({has_flag}) then\n"
958
            f"  {allocate_stmt}\n"
959
            f"  {maybe_name} = {name}\n"
960
            "end if"
961
        )
962
    if type_info.category == "string":
1✔
963
        return (
×
964
            f"if ({has_flag}) then\n"
965
            f"  {maybe_name} = {name}\n"
966
            "end if"
967
        )
968
    return (
1✔
969
        f"if ({has_flag}) then\n"
970
        f"  allocate({maybe_name})\n"
971
        f"  {maybe_name} = {name}\n"
972
        "end if"
973
    )
974

975

976
def _validate_python_module_name(module_name: str) -> None:
1✔
977
    if not module_name.isidentifier() or keyword.iskeyword(module_name):
1✔
978
        raise ValueError(
×
979
            f"f2py extension module name '{module_name}' must be a valid Python identifier"
980
        )
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