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

MuellerSeb / nml-tools / 26061440371

18 May 2026 09:26PM UTC coverage: 58.632% (+2.2%) from 56.443%
26061440371

Pull #19

github

MuellerSeb
Use explicit transfer molds for f2py handles
Pull Request #19: F2py wrapper support

252 of 368 new or added lines in 4 files covered. (68.48%)

1 existing line in 1 file now uncovered.

1671 of 2850 relevant lines covered (58.63%)

0.59 hits per line

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

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

3
from __future__ import annotations
1✔
4

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

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

13
from .codegen_fortran import (
1✔
14
    FieldSpec,
15
    FieldTypeInfo,
16
    _array_default_value,
17
    _build_context,
18
    _field_type_info,
19
    _parse_default_dimensions,
20
    _parse_flex_dim,
21
)
22

23
_TEMPLATE_ENV = Environment(
1✔
24
    loader=FileSystemLoader(Path(__file__).resolve().parent / "templates"),
25
    trim_blocks=True,
26
    lstrip_blocks=False,
27
    keep_trailing_newline=True,
28
    undefined=StrictUndefined,
29
)
30

31

32
@dataclass
1✔
33
class F2pyArgumentSpec:
1✔
34
    """Python wrapper argument metadata."""
35

36
    name: str
1✔
37
    title: str
1✔
38
    required: bool
1✔
39
    rank: int
1✔
40
    numpy_dtype: str | None
1✔
41
    dummy_value: str
1✔
42
    doc_type: str
1✔
43
    requirement: str
1✔
44
    has_flag: str | None = None
1✔
45
    fixed_shape: list[int] | None = None
1✔
46

47

48
@dataclass
1✔
49
class F2pyArrayDimensionSpec:
1✔
50
    """Dimension arguments for a f2py-visible array dummy."""
51

52
    field_name: str
1✔
53
    names: list[str]
1✔
54

55

56
@dataclass
1✔
57
class F2pyNamelistSpec:
1✔
58
    """Metadata needed for f2py wrapper generation."""
59

60
    namelist_name: str
1✔
61
    brief: str
1✔
62
    details: str
1✔
63
    details_lines: list[str]
1✔
64
    module_name: str
1✔
65
    type_name: str
1✔
66
    helper_module: str
1✔
67
    kind_module: str
1✔
68
    kind_imports: list[str]
1✔
69
    f2py_module_name: str
1✔
70
    resolve_handle_name: str
1✔
71
    handle_ctype: str
1✔
72
    errmsg_len: int
1✔
73
    argument_list: list[str]
1✔
74
    argument_declarations: list[str]
1✔
75
    bridge_declarations: list[str]
1✔
76
    bridge_assignments: list[str]
1✔
77
    set_call_arguments: list[str]
1✔
78
    array_dimensions: list[F2pyArrayDimensionSpec]
1✔
79
    required_args: list[F2pyArgumentSpec]
1✔
80
    optional_args: list[F2pyArgumentSpec]
1✔
81
    all_args: list[F2pyArgumentSpec]
1✔
82

83

84
@dataclass
1✔
85
class PythonWrapperSpec:
1✔
86
    """Metadata needed for Python wrapper generation."""
87

88
    class_name: str
1✔
89
    namelist_name: str
1✔
90
    brief: str
1✔
91
    f2py_module_name: str
1✔
92
    extension_module: str
1✔
93
    required_args: list[F2pyArgumentSpec]
1✔
94
    optional_args: list[F2pyArgumentSpec]
1✔
95
    all_args: list[F2pyArgumentSpec]
1✔
96

97

98
@dataclass
1✔
99
class F2pyKindUsage:
1✔
100
    """Kind aliases used by f2py wrapper dummy arguments."""
101

102
    real: set[str]
1✔
103
    integer: set[str]
1✔
104

105

106
@dataclass
1✔
107
class F2pyCTypeMap:
1✔
108
    """Explicit C type mapping for f2py kinds."""
109

110
    real: dict[str, str]
1✔
111
    integer: dict[str, str]
1✔
112

113

114
def generate_f2py_wrappers(
1✔
115
    schemas: Iterable[dict[str, Any]],
116
    output: str | Path,
117
    *,
118
    helper_module: str = "nml_helper",
119
    kind_module: str | None = None,
120
    kind_map: dict[str, str] | None = None,
121
    kind_allowlist: Iterable[str] | None = None,
122
    constants: dict[str, int | float] | None = None,
123
    errmsg_len: int = 1024,
124
) -> None:
125
    """Generate f2py-facing Fortran wrappers for *schemas* at *output*."""
126
    specs = [
1✔
127
        build_f2py_namelist_spec(
128
            schema,
129
            helper_module=helper_module,
130
            kind_module=kind_module,
131
            kind_map=kind_map,
132
            kind_allowlist=kind_allowlist,
133
            constants=constants,
134
            errmsg_len=errmsg_len,
135
        )
136
        for schema in schemas
137
    ]
138
    output_path = Path(output)
1✔
139
    rendered = _TEMPLATE_ENV.get_template("f2py_wrappers.f90.j2").render(
1✔
140
        {"file_name": output_path.name, "specs": specs}
141
    )
142
    output_path.parent.mkdir(parents=True, exist_ok=True)
1✔
143
    output_path.write_text(rendered, encoding="ascii")
1✔
144

145

146
def generate_python_wrappers(
1✔
147
    specs: Iterable[tuple[F2pyNamelistSpec, str]],
148
    output: str | Path,
149
    *,
150
    py_style: str = "numpy",
151
) -> None:
152
    """Generate Python wrapper classes for f2py namelist *specs* at *output*."""
153
    if py_style not in {"numpy", "doxygen"}:
1✔
154
        raise ValueError("python documentation style must be 'numpy' or 'doxygen'")
1✔
155
    output_path = Path(output)
1✔
156
    spec_entries = list(specs)
1✔
157
    extension_modules: set[str] = set()
1✔
158
    for _, extension_module in spec_entries:
1✔
159
        _validate_python_module_name(extension_module)
1✔
160
        extension_modules.add(extension_module)
1✔
161
    classes: list[PythonWrapperSpec] = []
1✔
162
    for spec, extension_module in spec_entries:
1✔
163
        classes.append(
1✔
164
            PythonWrapperSpec(
165
                class_name=_class_name(spec.namelist_name),
166
                namelist_name=spec.namelist_name,
167
                brief=spec.brief,
168
                f2py_module_name=spec.f2py_module_name,
169
                extension_module=extension_module,
170
                required_args=spec.required_args,
171
                optional_args=spec.optional_args,
172
                all_args=spec.all_args,
173
            )
174
        )
175
    rendered = _TEMPLATE_ENV.get_template("python_wrappers.py.j2").render(
1✔
176
        {"imports": sorted(extension_modules), "classes": classes, "py_style": py_style}
177
    )
178
    output_path.parent.mkdir(parents=True, exist_ok=True)
1✔
179
    output_path.write_text(rendered, encoding="ascii")
1✔
180

181

182
def generate_f2cmap(
1✔
183
    output: str | Path,
184
    usage: F2pyKindUsage,
185
    c_types: F2pyCTypeMap,
186
) -> None:
187
    """Generate a .f2py_f2cmap file for the explicitly mapped *usage*."""
188
    missing_real = sorted(usage.real - set(c_types.real))
1✔
189
    missing_integer = sorted(usage.integer - set(c_types.integer))
1✔
190
    if missing_real:
1✔
191
        raise ValueError("missing f2py real C type mappings: " + ", ".join(missing_real))
1✔
192
    if missing_integer:
1✔
NEW
193
        raise ValueError(
×
194
            "missing f2py integer C type mappings: " + ", ".join(missing_integer)
195
        )
196

197
    integer_map = dict(c_types.integer)
1✔
198
    integer_map.setdefault("c_intptr_t", "long_long")
1✔
199
    real_items = ", ".join(
1✔
200
        f"{name}={c_types.real[name]!r}" for name in sorted(usage.real)
201
    )
202
    integer_items = ", ".join(
1✔
203
        f"{name}={integer_map[name]!r}" for name in sorted(usage.integer | {"c_intptr_t"})
204
    )
205
    rendered = f"dict(real=dict({real_items}), integer=dict({integer_items}))\n"
1✔
206
    output_path = Path(output)
1✔
207
    output_path.parent.mkdir(parents=True, exist_ok=True)
1✔
208
    output_path.write_text(rendered, encoding="ascii")
1✔
209

210

211
def collect_f2py_kind_usage(
1✔
212
    schemas: Iterable[dict[str, Any]],
213
    *,
214
    constants: dict[str, int | float] | None = None,
215
) -> F2pyKindUsage:
216
    """Collect schema kind aliases used in f2py wrapper arguments."""
217
    usage = F2pyKindUsage(real=set(), integer=set())
1✔
218
    for schema in schemas:
1✔
219
        for _, type_info in _iter_field_type_infos(schema, constants):
1✔
220
            category = (
1✔
221
                type_info.element_category
222
                if type_info.category == "array"
223
                else type_info.category
224
            )
225
            if type_info.kind is None:
1✔
226
                continue
1✔
227
            if category == "real":
1✔
228
                usage.real.add(type_info.kind)
1✔
229
            elif category == "integer":
1✔
230
                usage.integer.add(type_info.kind)
1✔
231
    return usage
1✔
232

233

234
def merge_f2py_kind_usage(usages: Iterable[F2pyKindUsage]) -> F2pyKindUsage:
1✔
235
    """Merge multiple f2py kind usage objects."""
NEW
236
    merged = F2pyKindUsage(real=set(), integer=set())
×
NEW
237
    for usage in usages:
×
NEW
238
        merged.real.update(usage.real)
×
NEW
239
        merged.integer.update(usage.integer)
×
NEW
240
    return merged
×
241

242

243
def build_f2py_namelist_spec(
1✔
244
    schema: dict[str, Any],
245
    *,
246
    helper_module: str = "nml_helper",
247
    kind_module: str | None = None,
248
    kind_map: dict[str, str] | None = None,
249
    kind_allowlist: Iterable[str] | None = None,
250
    constants: dict[str, int | float] | None = None,
251
    errmsg_len: int = 1024,
252
) -> F2pyNamelistSpec:
253
    """Build f2py wrapper metadata for one namelist schema."""
254
    context = _build_context(
1✔
255
        schema,
256
        helper_module=helper_module,
257
        kind_module=kind_module,
258
        kind_map=kind_map,
259
        kind_allowlist=kind_allowlist,
260
        constants=constants,
261
        module_doc=None,
262
    )
263
    fields = cast("list[FieldSpec]", context["fields"])
1✔
264
    type_infos = {
1✔
265
        name: type_info for name, type_info in _iter_field_type_infos(schema, constants)
266
    }
267
    properties = _normalized_properties(schema)
1✔
268
    required_args: list[F2pyArgumentSpec] = []
1✔
269
    optional_args: list[F2pyArgumentSpec] = []
1✔
270
    argument_list: list[str] = []
1✔
271
    argument_declarations: list[str] = []
1✔
272
    bridge_declarations: list[str] = []
1✔
273
    bridge_assignments: list[str] = []
1✔
274
    set_call_arguments: list[str] = []
1✔
275
    array_dimensions: list[F2pyArrayDimensionSpec] = []
1✔
276
    for field in fields:
1✔
277
        type_info = type_infos[field.name]
1✔
278
        prop = properties[field.name]
1✔
279
        rank = len(type_info.dimensions) if type_info.category == "array" else 0
1✔
280
        has_flag = None if field.required else f"has_{field.name}"
1✔
281
        spec = F2pyArgumentSpec(
1✔
282
            name=field.name,
283
            title=_one_line(field.title),
284
            required=field.required,
285
            rank=rank,
286
            numpy_dtype=_numpy_dtype(type_info),
287
            dummy_value=_python_dummy_value(type_info),
288
            doc_type=_python_doc_type(type_info),
289
            requirement="required" if field.required else "optional",
290
            has_flag=has_flag,
291
            fixed_shape=_fixed_python_array_shape(prop, type_info, constants),
292
        )
293
        if field.required:
1✔
294
            required_args.append(spec)
1✔
295
        else:
296
            optional_args.append(spec)
1✔
297
        field_arguments, field_declarations = _f2py_field_arguments(field, type_info)
1✔
298
        argument_list.extend(field_arguments)
1✔
299
        argument_declarations.extend(field_declarations)
1✔
300
        if rank > 0:
1✔
301
            dim_names = _array_dimension_argument_names(field.name, rank)
1✔
302
            array_dimensions.append(
1✔
303
                F2pyArrayDimensionSpec(field_name=field.name, names=dim_names)
304
            )
305
        if has_flag is not None:
1✔
306
            argument_list.append(has_flag)
1✔
307
            argument_declarations.append(
1✔
308
                f"logical, intent(in) :: {has_flag} !< whether {field.name} was provided"
309
            )
310
            bridge_declarations.append(_optional_bridge_declaration(field.name, type_info))
1✔
311
            bridge_assignments.append(_optional_bridge_assignment(field.name, type_info))
1✔
312
            set_call_arguments.append(f"{field.name}=maybe_{field.name}")
1✔
313
        else:
314
            set_call_arguments.append(f"{field.name}={field.name}")
1✔
315

316
    namelist_name = cast("str", context["namelist_name"])
1✔
317
    details = cast("str", context["details_text"])
1✔
318
    return F2pyNamelistSpec(
1✔
319
        namelist_name=namelist_name,
320
        brief=_one_line(cast("str", context["brief_text"])),
321
        details=details,
322
        details_lines=details.splitlines() or [details],
323
        module_name=cast("str", context["module_name"]),
324
        type_name=cast("str", context["type_name"]),
325
        helper_module=helper_module,
326
        kind_module=cast("str", context["kind_module"]),
327
        kind_imports=cast("list[str]", context["kind_imports"]),
328
        f2py_module_name=f"f2py_{namelist_name}",
329
        resolve_handle_name=f"{context['module_name']}_resolve_handle",
330
        handle_ctype="c_intptr_t",
331
        errmsg_len=errmsg_len,
332
        argument_list=argument_list,
333
        argument_declarations=argument_declarations,
334
        bridge_declarations=bridge_declarations,
335
        bridge_assignments=bridge_assignments,
336
        set_call_arguments=set_call_arguments,
337
        array_dimensions=array_dimensions,
338
        required_args=required_args,
339
        optional_args=optional_args,
340
        all_args=required_args + optional_args,
341
    )
342

343

344
def _iter_field_type_infos(
1✔
345
    schema: dict[str, Any],
346
    constants: dict[str, int | float] | None,
347
) -> list[tuple[str, FieldTypeInfo]]:
348
    properties = _normalized_properties(schema)
1✔
349
    return [
1✔
350
        (name, _field_type_info(prop, constants))
351
        for name, prop in properties.items()
352
    ]
353

354

355
def _normalized_properties(schema: dict[str, Any]) -> dict[str, dict[str, Any]]:
1✔
356
    properties = schema.get("properties")
1✔
357
    if not isinstance(properties, dict):
1✔
NEW
358
        raise ValueError("schema must define object 'properties'")
×
359
    normalized: dict[str, dict[str, Any]] = {}
1✔
360
    seen: set[str] = set()
1✔
361
    for raw_name, prop in properties.items():
1✔
362
        if not isinstance(raw_name, str):
1✔
NEW
363
            raise ValueError("property names must be strings")
×
364
        if not isinstance(prop, dict):
1✔
NEW
365
            raise ValueError(f"property '{raw_name}' must be an object")
×
366
        name = raw_name.lower()
1✔
367
        if name in seen:
1✔
NEW
368
            raise ValueError(f"duplicate property '{raw_name}'")
×
369
        seen.add(name)
1✔
370
        normalized[name] = prop
1✔
371
    return normalized
1✔
372

373

374
def _fixed_python_array_shape(
1✔
375
    prop: dict[str, Any],
376
    type_info: FieldTypeInfo,
377
    constants: dict[str, int | float] | None,
378
) -> list[int] | None:
379
    if type_info.category != "array":
1✔
380
        return None
1✔
381
    if _array_default_value(prop) is not None:
1✔
382
        return None
1✔
383
    if _parse_flex_dim(prop, type_info) > 0:
1✔
NEW
384
        return None
×
385
    return _parse_default_dimensions(type_info.dimensions, constants)
1✔
386

387

388
def _class_name(namelist_name: str) -> str:
1✔
389
    parts = [part for part in re.split(r"[^0-9A-Za-z]+", namelist_name) if part]
1✔
390
    if not parts:
1✔
NEW
391
        return "Namelist"
×
392
    name = "".join(part[:1].upper() + part[1:] for part in parts)
1✔
393
    if name[0].isdigit():
1✔
NEW
394
        name = f"Namelist{name}"
×
395
    if keyword.iskeyword(name):
1✔
NEW
396
        name = f"{name}Namelist"
×
397
    return name
1✔
398

399

400
def _numpy_dtype(type_info: FieldTypeInfo) -> str | None:
1✔
401
    category = (
1✔
402
        type_info.element_category
403
        if type_info.category == "array"
404
        else type_info.category
405
    )
406
    if category == "real":
1✔
407
        return "float"
1✔
408
    if category == "integer":
1✔
409
        return "int"
1✔
410
    if category == "boolean":
1✔
NEW
411
        return "bool"
×
412
    if category == "string":
1✔
413
        return "str"
1✔
NEW
414
    return None
×
415

416

417
def _python_dummy_value(type_info: FieldTypeInfo) -> str:
1✔
418
    category = (
1✔
419
        type_info.element_category
420
        if type_info.category == "array"
421
        else type_info.category
422
    )
423
    if category == "real":
1✔
424
        return "0.0"
1✔
425
    if category == "integer":
1✔
426
        return "0"
1✔
427
    if category == "boolean":
1✔
NEW
428
        return "False"
×
429
    if category == "string":
1✔
430
        return '""'
1✔
NEW
431
    return "None"
×
432

433

434
def _python_doc_type(type_info: FieldTypeInfo) -> str:
1✔
435
    category = (
1✔
436
        type_info.element_category
437
        if type_info.category == "array"
438
        else type_info.category
439
    )
440
    if category == "real":
1✔
441
        type_name = "float"
1✔
442
    elif category == "integer":
1✔
443
        type_name = "int"
1✔
444
    elif category == "boolean":
1✔
NEW
445
        type_name = "bool"
×
446
    elif category == "string":
1✔
447
        type_name = "str"
1✔
448
    else:
NEW
449
        type_name = "Any"
×
450
    if type_info.category == "array":
1✔
451
        return f"array_like of {type_name}"
1✔
452
    return type_name
1✔
453

454

455
def _one_line(value: str) -> str:
1✔
456
    return " ".join(value.splitlines()).strip()
1✔
457

458

459
def _array_dimension_argument_names(name: str, rank: int) -> list[str]:
1✔
460
    return [f"{name}_n{idx}" for idx in range(1, rank + 1)]
1✔
461

462

463
def _f2py_field_arguments(
1✔
464
    field: FieldSpec,
465
    type_info: FieldTypeInfo,
466
) -> tuple[list[str], list[str]]:
467
    requirement = "required" if field.required else "optional"
1✔
468
    if type_info.category != "array":
1✔
469
        return [field.name], [
1✔
470
            f"{type_info.arg_type_spec}, intent(in) :: {field.name} "
471
            f"!< {_one_line(field.title)} ({requirement})"
472
        ]
473

474
    dim_names = _array_dimension_argument_names(field.name, len(type_info.dimensions))
1✔
475
    dims = ", ".join(dim_names)
1✔
476
    declarations = [
1✔
477
        f"integer, intent(in) :: {dim_name} !< extent for {field.name}"
478
        for dim_name in dim_names
479
    ]
480
    declarations.append(
1✔
481
        f"{type_info.arg_type_spec}, dimension({dims}), intent(in) :: {field.name} "
482
        f"!< {_one_line(field.title)} ({requirement})"
483
    )
484
    return [*dim_names, field.name], declarations
1✔
485

486

487
def _optional_bridge_declaration(name: str, type_info: FieldTypeInfo) -> str:
1✔
488
    if type_info.category == "array":
1✔
489
        dims = ", ".join(":" for _ in type_info.dimensions)
1✔
490
        if type_info.element_category == "string":
1✔
491
            return f"character(len=:), dimension({dims}), allocatable :: maybe_{name}"
1✔
492
        return f"{type_info.arg_type_spec}, dimension({dims}), allocatable :: maybe_{name}"
1✔
493
    if type_info.category == "string":
1✔
NEW
494
        return f"character(len=:), allocatable :: maybe_{name}"
×
495
    return f"{type_info.arg_type_spec}, allocatable :: maybe_{name}"
1✔
496

497

498
def _optional_bridge_assignment(name: str, type_info: FieldTypeInfo) -> str:
1✔
499
    has_flag = f"has_{name}"
1✔
500
    maybe_name = f"maybe_{name}"
1✔
501
    if type_info.category == "array":
1✔
502
        dims = ", ".join(_array_dimension_argument_names(name, len(type_info.dimensions)))
1✔
503
        allocate_stmt = f"allocate({maybe_name}({dims}))"
1✔
504
        if type_info.element_category == "string":
1✔
505
            allocate_stmt = f"allocate(character(len=len({name})) :: {maybe_name}({dims}))"
1✔
506
        return (
1✔
507
            f"if ({has_flag}) then\n"
508
            f"  {allocate_stmt}\n"
509
            f"  {maybe_name} = {name}\n"
510
            "end if"
511
        )
512
    if type_info.category == "string":
1✔
NEW
513
        return (
×
514
            f"if ({has_flag}) then\n"
515
            f"  {maybe_name} = {name}\n"
516
            "end if"
517
        )
518
    return (
1✔
519
        f"if ({has_flag}) then\n"
520
        f"  allocate({maybe_name})\n"
521
        f"  {maybe_name} = {name}\n"
522
        "end if"
523
    )
524

525

526
def _validate_python_module_name(module_name: str) -> None:
1✔
527
    if not module_name.isidentifier() or keyword.iskeyword(module_name):
1✔
NEW
528
        raise ValueError(
×
529
            f"f2py extension module name '{module_name}' must be a valid Python identifier"
530
        )
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