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

MuellerSeb / nml-tools / 25892847166

15 May 2026 12:07AM UTC coverage: 58.119% (+1.7%) from 56.443%
25892847166

Pull #19

github

MuellerSeb
Add numpy to dev dependencies
Pull Request #19: F2py wrapper support

206 of 309 new or added lines in 4 files covered. (66.67%)

1 existing line in 1 file now uncovered.

1625 of 2796 relevant lines covered (58.12%)

0.58 hits per line

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

91.07
/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
    _build_context,
17
    _field_type_info,
18
)
19

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

28
@dataclass
1✔
29
class F2pyArgumentSpec:
1✔
30
    """Python wrapper argument metadata."""
31

32
    name: str
1✔
33
    required: bool
1✔
34
    rank: int
1✔
35
    numpy_dtype: str | None
1✔
36
    dummy_value: str
1✔
37
    has_flag: str | None = None
1✔
38

39

40
@dataclass
1✔
41
class F2pyArrayDimensionSpec:
1✔
42
    """Dimension arguments for a f2py-visible array dummy."""
43

44
    field_name: str
1✔
45
    names: list[str]
1✔
46

47

48
@dataclass
1✔
49
class F2pyNamelistSpec:
1✔
50
    """Metadata needed for f2py wrapper generation."""
51

52
    namelist_name: str
1✔
53
    module_name: str
1✔
54
    type_name: str
1✔
55
    helper_module: str
1✔
56
    kind_module: str
1✔
57
    kind_imports: list[str]
1✔
58
    f2py_module_name: str
1✔
59
    resolve_handle_name: str
1✔
60
    handle_ctype: str
1✔
61
    errmsg_len: int
1✔
62
    argument_list: list[str]
1✔
63
    argument_declarations: list[str]
1✔
64
    bridge_declarations: list[str]
1✔
65
    bridge_assignments: list[str]
1✔
66
    set_call_arguments: list[str]
1✔
67
    array_dimensions: list[F2pyArrayDimensionSpec]
1✔
68
    required_args: list[F2pyArgumentSpec]
1✔
69
    optional_args: list[F2pyArgumentSpec]
1✔
70
    all_args: list[F2pyArgumentSpec]
1✔
71

72

73
@dataclass
1✔
74
class PythonWrapperSpec:
1✔
75
    """Metadata needed for Python wrapper generation."""
76

77
    class_name: str
1✔
78
    namelist_name: str
1✔
79
    f2py_module_name: str
1✔
80
    extension_module: str
1✔
81
    required_args: list[F2pyArgumentSpec]
1✔
82
    optional_args: list[F2pyArgumentSpec]
1✔
83
    all_args: list[F2pyArgumentSpec]
1✔
84

85

86
@dataclass
1✔
87
class F2pyKindUsage:
1✔
88
    """Kind aliases used by f2py wrapper dummy arguments."""
89

90
    real: set[str]
1✔
91
    integer: set[str]
1✔
92

93

94
@dataclass
1✔
95
class F2pyCTypeMap:
1✔
96
    """Explicit C type mapping for f2py kinds."""
97

98
    real: dict[str, str]
1✔
99
    integer: dict[str, str]
1✔
100

101

102
def generate_f2py_wrappers(
1✔
103
    schemas: Iterable[dict[str, Any]],
104
    output: str | Path,
105
    *,
106
    helper_module: str = "nml_helper",
107
    kind_module: str | None = None,
108
    kind_map: dict[str, str] | None = None,
109
    kind_allowlist: Iterable[str] | None = None,
110
    constants: dict[str, int | float] | None = None,
111
    errmsg_len: int = 1024,
112
) -> None:
113
    """Generate f2py-facing Fortran wrappers for *schemas* at *output*."""
114
    specs = [
1✔
115
        build_f2py_namelist_spec(
116
            schema,
117
            helper_module=helper_module,
118
            kind_module=kind_module,
119
            kind_map=kind_map,
120
            kind_allowlist=kind_allowlist,
121
            constants=constants,
122
            errmsg_len=errmsg_len,
123
        )
124
        for schema in schemas
125
    ]
126
    output_path = Path(output)
1✔
127
    rendered = _TEMPLATE_ENV.get_template("f2py_wrappers.f90.j2").render({"specs": specs})
1✔
128
    output_path.parent.mkdir(parents=True, exist_ok=True)
1✔
129
    output_path.write_text(rendered, encoding="ascii")
1✔
130

131

132
def generate_python_wrappers(
1✔
133
    specs: Iterable[tuple[F2pyNamelistSpec, str]],
134
    output: str | Path,
135
) -> None:
136
    """Generate Python wrapper classes for f2py namelist *specs* at *output*."""
137
    output_path = Path(output)
1✔
138
    spec_entries = list(specs)
1✔
139
    extension_modules: set[str] = set()
1✔
140
    for _, extension_module in spec_entries:
1✔
141
        _validate_python_module_name(extension_module)
1✔
142
        extension_modules.add(extension_module)
1✔
143
    classes: list[PythonWrapperSpec] = []
1✔
144
    for spec, extension_module in spec_entries:
1✔
145
        classes.append(
1✔
146
            PythonWrapperSpec(
147
                class_name=_class_name(spec.namelist_name),
148
                namelist_name=spec.namelist_name,
149
                f2py_module_name=spec.f2py_module_name,
150
                extension_module=extension_module,
151
                required_args=spec.required_args,
152
                optional_args=spec.optional_args,
153
                all_args=spec.all_args,
154
            )
155
        )
156
    rendered = _TEMPLATE_ENV.get_template("python_wrappers.py.j2").render(
1✔
157
        {"imports": sorted(extension_modules), "classes": classes}
158
    )
159
    output_path.parent.mkdir(parents=True, exist_ok=True)
1✔
160
    output_path.write_text(rendered, encoding="ascii")
1✔
161

162

163
def generate_f2cmap(
1✔
164
    output: str | Path,
165
    usage: F2pyKindUsage,
166
    c_types: F2pyCTypeMap,
167
) -> None:
168
    """Generate a .f2py_f2cmap file for the explicitly mapped *usage*."""
169
    missing_real = sorted(usage.real - set(c_types.real))
1✔
170
    missing_integer = sorted(usage.integer - set(c_types.integer))
1✔
171
    if missing_real:
1✔
172
        raise ValueError("missing f2py real C type mappings: " + ", ".join(missing_real))
1✔
173
    if missing_integer:
1✔
NEW
174
        raise ValueError(
×
175
            "missing f2py integer C type mappings: " + ", ".join(missing_integer)
176
        )
177

178
    integer_map = dict(c_types.integer)
1✔
179
    integer_map.setdefault("c_intptr_t", "long_long")
1✔
180
    real_items = ", ".join(
1✔
181
        f"{name}={c_types.real[name]!r}" for name in sorted(usage.real)
182
    )
183
    integer_items = ", ".join(
1✔
184
        f"{name}={integer_map[name]!r}" for name in sorted(usage.integer | {"c_intptr_t"})
185
    )
186
    rendered = f"dict(real=dict({real_items}), integer=dict({integer_items}))\n"
1✔
187
    output_path = Path(output)
1✔
188
    output_path.parent.mkdir(parents=True, exist_ok=True)
1✔
189
    output_path.write_text(rendered, encoding="ascii")
1✔
190

191

192
def collect_f2py_kind_usage(
1✔
193
    schemas: Iterable[dict[str, Any]],
194
    *,
195
    constants: dict[str, int | float] | None = None,
196
) -> F2pyKindUsage:
197
    """Collect schema kind aliases used in f2py wrapper arguments."""
198
    usage = F2pyKindUsage(real=set(), integer=set())
1✔
199
    for schema in schemas:
1✔
200
        for _, type_info in _iter_field_type_infos(schema, constants):
1✔
201
            category = (
1✔
202
                type_info.element_category
203
                if type_info.category == "array"
204
                else type_info.category
205
            )
206
            if type_info.kind is None:
1✔
207
                continue
1✔
208
            if category == "real":
1✔
209
                usage.real.add(type_info.kind)
1✔
210
            elif category == "integer":
1✔
211
                usage.integer.add(type_info.kind)
1✔
212
    return usage
1✔
213

214

215
def merge_f2py_kind_usage(usages: Iterable[F2pyKindUsage]) -> F2pyKindUsage:
1✔
216
    """Merge multiple f2py kind usage objects."""
NEW
217
    merged = F2pyKindUsage(real=set(), integer=set())
×
NEW
218
    for usage in usages:
×
NEW
219
        merged.real.update(usage.real)
×
NEW
220
        merged.integer.update(usage.integer)
×
NEW
221
    return merged
×
222

223

224
def build_f2py_namelist_spec(
1✔
225
    schema: dict[str, Any],
226
    *,
227
    helper_module: str = "nml_helper",
228
    kind_module: str | None = None,
229
    kind_map: dict[str, str] | None = None,
230
    kind_allowlist: Iterable[str] | None = None,
231
    constants: dict[str, int | float] | None = None,
232
    errmsg_len: int = 1024,
233
) -> F2pyNamelistSpec:
234
    """Build f2py wrapper metadata for one namelist schema."""
235
    context = _build_context(
1✔
236
        schema,
237
        helper_module=helper_module,
238
        kind_module=kind_module,
239
        kind_map=kind_map,
240
        kind_allowlist=kind_allowlist,
241
        constants=constants,
242
        module_doc=None,
243
    )
244
    fields = cast("list[FieldSpec]", context["fields"])
1✔
245
    type_infos = {
1✔
246
        name: type_info for name, type_info in _iter_field_type_infos(schema, constants)
247
    }
248
    required_args: list[F2pyArgumentSpec] = []
1✔
249
    optional_args: list[F2pyArgumentSpec] = []
1✔
250
    argument_list: list[str] = []
1✔
251
    argument_declarations: list[str] = []
1✔
252
    bridge_declarations: list[str] = []
1✔
253
    bridge_assignments: list[str] = []
1✔
254
    set_call_arguments: list[str] = []
1✔
255
    array_dimensions: list[F2pyArrayDimensionSpec] = []
1✔
256
    for field in fields:
1✔
257
        type_info = type_infos[field.name]
1✔
258
        rank = len(type_info.dimensions) if type_info.category == "array" else 0
1✔
259
        has_flag = None if field.required else f"has_{field.name}"
1✔
260
        spec = F2pyArgumentSpec(
1✔
261
            name=field.name,
262
            required=field.required,
263
            rank=rank,
264
            numpy_dtype=_numpy_dtype(type_info),
265
            dummy_value=_python_dummy_value(type_info),
266
            has_flag=has_flag,
267
        )
268
        if field.required:
1✔
269
            required_args.append(spec)
1✔
270
        else:
271
            optional_args.append(spec)
1✔
272
        field_arguments, field_declarations = _f2py_field_arguments(field, type_info)
1✔
273
        argument_list.extend(field_arguments)
1✔
274
        argument_declarations.extend(field_declarations)
1✔
275
        if rank > 0:
1✔
276
            dim_names = _array_dimension_argument_names(field.name, rank)
1✔
277
            array_dimensions.append(
1✔
278
                F2pyArrayDimensionSpec(field_name=field.name, names=dim_names)
279
            )
280
        if has_flag is not None:
1✔
281
            argument_list.append(has_flag)
1✔
282
            argument_declarations.append(f"logical, intent(in) :: {has_flag}")
1✔
283
            bridge_declarations.append(_optional_bridge_declaration(field.name, type_info))
1✔
284
            bridge_assignments.append(_optional_bridge_assignment(field.name, type_info))
1✔
285
            set_call_arguments.append(f"{field.name}=maybe_{field.name}")
1✔
286
        else:
287
            set_call_arguments.append(f"{field.name}={field.name}")
1✔
288

289
    namelist_name = cast("str", context["namelist_name"])
1✔
290
    return F2pyNamelistSpec(
1✔
291
        namelist_name=namelist_name,
292
        module_name=cast("str", context["module_name"]),
293
        type_name=cast("str", context["type_name"]),
294
        helper_module=helper_module,
295
        kind_module=cast("str", context["kind_module"]),
296
        kind_imports=cast("list[str]", context["kind_imports"]),
297
        f2py_module_name=f"f2py_{namelist_name}",
298
        resolve_handle_name=f"{context['module_name']}_resolve_handle",
299
        handle_ctype="c_intptr_t",
300
        errmsg_len=errmsg_len,
301
        argument_list=argument_list,
302
        argument_declarations=argument_declarations,
303
        bridge_declarations=bridge_declarations,
304
        bridge_assignments=bridge_assignments,
305
        set_call_arguments=set_call_arguments,
306
        array_dimensions=array_dimensions,
307
        required_args=required_args,
308
        optional_args=optional_args,
309
        all_args=required_args + optional_args,
310
    )
311

312

313
def _iter_field_type_infos(
1✔
314
    schema: dict[str, Any],
315
    constants: dict[str, int | float] | None,
316
) -> list[tuple[str, FieldTypeInfo]]:
317
    properties = schema.get("properties")
1✔
318
    if not isinstance(properties, dict):
1✔
NEW
319
        raise ValueError("schema must define object 'properties'")
×
320
    entries: list[tuple[str, FieldTypeInfo]] = []
1✔
321
    seen: set[str] = set()
1✔
322
    for raw_name, prop in properties.items():
1✔
323
        if not isinstance(raw_name, str):
1✔
NEW
324
            raise ValueError("property names must be strings")
×
325
        if not isinstance(prop, dict):
1✔
NEW
326
            raise ValueError(f"property '{raw_name}' must be an object")
×
327
        name = raw_name.lower()
1✔
328
        if name in seen:
1✔
NEW
329
            raise ValueError(f"duplicate property '{raw_name}'")
×
330
        seen.add(name)
1✔
331
        entries.append((name, _field_type_info(prop, constants)))
1✔
332
    return entries
1✔
333

334

335
def _class_name(namelist_name: str) -> str:
1✔
336
    parts = [part for part in re.split(r"[^0-9A-Za-z]+", namelist_name) if part]
1✔
337
    if not parts:
1✔
NEW
338
        return "Namelist"
×
339
    name = "".join(part[:1].upper() + part[1:] for part in parts)
1✔
340
    if name[0].isdigit():
1✔
NEW
341
        name = f"Namelist{name}"
×
342
    if keyword.iskeyword(name):
1✔
NEW
343
        name = f"{name}Namelist"
×
344
    return name
1✔
345

346

347
def _numpy_dtype(type_info: FieldTypeInfo) -> str | None:
1✔
348
    category = (
1✔
349
        type_info.element_category
350
        if type_info.category == "array"
351
        else type_info.category
352
    )
353
    if category == "real":
1✔
354
        return "float"
1✔
355
    if category == "integer":
1✔
356
        return "int"
1✔
357
    if category == "boolean":
1✔
NEW
358
        return "bool"
×
359
    if category == "string":
1✔
360
        return "str"
1✔
NEW
361
    return None
×
362

363

364
def _python_dummy_value(type_info: FieldTypeInfo) -> str:
1✔
365
    category = (
1✔
366
        type_info.element_category
367
        if type_info.category == "array"
368
        else type_info.category
369
    )
370
    if category == "real":
1✔
371
        return "0.0"
1✔
372
    if category == "integer":
1✔
373
        return "0"
1✔
374
    if category == "boolean":
1✔
NEW
375
        return "False"
×
376
    if category == "string":
1✔
377
        return '""'
1✔
NEW
378
    return "None"
×
379

380

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

384

385
def _f2py_field_arguments(
1✔
386
    field: FieldSpec,
387
    type_info: FieldTypeInfo,
388
) -> tuple[list[str], list[str]]:
389
    if type_info.category != "array":
1✔
390
        return [field.name], [f"{type_info.arg_type_spec}, intent(in) :: {field.name}"]
1✔
391

392
    dim_names = _array_dimension_argument_names(field.name, len(type_info.dimensions))
1✔
393
    dims = ", ".join(dim_names)
1✔
394
    declarations = [f"integer, intent(in) :: {dim_name}" for dim_name in dim_names]
1✔
395
    declarations.append(
1✔
396
        f"{type_info.arg_type_spec}, dimension({dims}), intent(in) :: {field.name}"
397
    )
398
    return [*dim_names, field.name], declarations
1✔
399

400

401
def _optional_bridge_declaration(name: str, type_info: FieldTypeInfo) -> str:
1✔
402
    if type_info.category == "array":
1✔
403
        dims = ", ".join(":" for _ in type_info.dimensions)
1✔
404
        return f"{type_info.arg_type_spec}, dimension({dims}), allocatable :: maybe_{name}"
1✔
405
    if type_info.category == "string":
1✔
NEW
406
        return f"character(len=:), allocatable :: maybe_{name}"
×
407
    return f"{type_info.arg_type_spec}, allocatable :: maybe_{name}"
1✔
408

409

410
def _optional_bridge_assignment(name: str, type_info: FieldTypeInfo) -> str:
1✔
411
    has_flag = f"has_{name}"
1✔
412
    maybe_name = f"maybe_{name}"
1✔
413
    if type_info.category == "array":
1✔
414
        dims = ", ".join(_array_dimension_argument_names(name, len(type_info.dimensions)))
1✔
415
        return (
1✔
416
            f"if ({has_flag}) then\n"
417
            f"  allocate({maybe_name}({dims}))\n"
418
            f"  {maybe_name} = {name}\n"
419
            "end if"
420
        )
421
    if type_info.category == "string":
1✔
NEW
422
        return (
×
423
            f"if ({has_flag}) then\n"
424
            f"  {maybe_name} = {name}\n"
425
            "end if"
426
        )
427
    return (
1✔
428
        f"if ({has_flag}) then\n"
429
        f"  allocate({maybe_name})\n"
430
        f"  {maybe_name} = {name}\n"
431
        "end if"
432
    )
433

434

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