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

MuellerSeb / nml-tools / 26596599054

28 May 2026 07:11PM UTC coverage: 83.506% (-0.1%) from 83.612%
26596599054

Pull #34

github

MuellerSeb
Isolate invalid  fixture in schema param tests
Pull Request #34: Use explicit defaults for runtime dimensions

94 of 104 new or added lines in 6 files covered. (90.38%)

14 existing lines in 4 files now uncovered.

3792 of 4541 relevant lines covered (83.51%)

0.84 hits per line

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

93.96
/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")
×
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")
×
688
        name = raw_name.lower()
1✔
689
        if name in seen:
1✔
690
            raise ValueError(f"duplicate property '{raw_name}'")
×
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