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

MuellerSeb / nml-tools / 26064423491

18 May 2026 10:34PM UTC coverage: 58.57% (+2.1%) from 56.443%
26064423491

Pull #19

github

MuellerSeb
Pin Fortran CI action version
Pull Request #19: F2py wrapper support

253 of 372 new or added lines in 4 files covered. (68.01%)

1 existing line in 1 file now uncovered.

1671 of 2853 relevant lines covered (58.57%)

0.59 hits per line

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

0.0
/src/nml_tools/cli.py
1
"""Command line interface for nml-tools."""
2

3
from __future__ import annotations
×
4

5
import logging
×
6
import math
×
7
import re
×
8
import sys
×
9
from pathlib import Path
×
10
from typing import Any
×
11

12
import click
×
NEW
13
import f90nml  # type: ignore
×
14
from click.exceptions import Exit
×
15

16
from ._version import __version__
×
NEW
17
from .codegen_f2py import (
×
18
    F2pyCTypeMap,
19
    build_f2py_namelist_spec,
20
    collect_f2py_kind_usage,
21
    generate_f2cmap,
22
    generate_f2py_wrappers,
23
    generate_python_wrappers,
24
    merge_f2py_kind_usage,
25
)
26
from .codegen_fortran import ConstantSpec, generate_fortran, generate_helper
×
27
from .codegen_markdown import generate_docs
×
28
from .codegen_template import generate_template
×
29
from .schema import load_schema
×
30
from .validate import validate_namelist
×
31

32
if sys.version_info >= (3, 11):
×
33
    import tomllib
×
34
else:  # pragma: no cover - python<3.11
35
    import tomli as tomllib
36

37
logger = logging.getLogger(__name__)
×
38
_FORTRAN_IDENTIFIER = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
×
39
_CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
×
40

41

42
def _configure_logging(verbose: int, quiet: int) -> None:
×
43
    base_level = logging.INFO
×
44
    level = base_level - (10 * verbose) + (10 * quiet)
×
45
    level = max(logging.DEBUG, min(logging.CRITICAL, level))
×
46
    logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
×
47

48

49
def _load_config(path: Path) -> dict[str, Any]:
×
50
    with path.open("rb") as handle:
×
51
        data = tomllib.load(handle)
×
52
    if not isinstance(data, dict):
×
53
        raise click.ClickException("config must be a table")
×
54
    return data
×
55

56

57
def _load_config_checked(path: Path) -> dict[str, Any]:
×
58
    try:
×
59
        return _load_config(path)
×
60
    except FileNotFoundError as exc:
×
61
        raise click.ClickException(str(exc)) from exc
×
62
    except Exception as exc:  # pragma: no cover - tomllib may raise ValueError
63
        raise click.ClickException(f"failed to read config: {exc}") from exc
64

65

66
def _resolve_optional_path(
×
67
    value: Any,
68
    *,
69
    base_dir: Path,
70
    key: str,
71
) -> Path | None:
72
    if value is None:
×
73
        return None
×
74
    if not isinstance(value, str):
×
75
        raise click.ClickException(f"config '{key}' must be a string")
×
76
    return base_dir / value
×
77

78

79
def _load_helper_settings(
×
80
    config: dict[str, Any],
81
    base_dir: Path,
82
) -> tuple[Path | None, str, int, str | None]:
83
    default_buffer = 1024
×
84
    helper_raw = config.get("helper")
×
85
    if helper_raw is None:
×
86
        helper_path = _resolve_optional_path(
×
87
            config.get("helper_path"),
88
            base_dir=base_dir,
89
            key="helper_path",
90
        )
91
        helper_module_raw = config.get("helper_module", "nml_helper")
×
92
        if not isinstance(helper_module_raw, str):
×
93
            raise click.ClickException("config 'helper_module' must be a string")
×
94
        helper_module = helper_module_raw.strip()
×
95
        if not helper_module:
×
96
            raise click.ClickException("config 'helper_module' must be a non-empty string")
×
97
        return helper_path, helper_module, default_buffer, None
×
98

99
    if not isinstance(helper_raw, dict):
×
100
        raise click.ClickException("config 'helper' must be a table")
×
101
    if "helper_path" in config or "helper_module" in config:
×
102
        logger.warning("config uses [helper]; ignoring legacy helper_path/helper_module")
×
103
    helper_path = _resolve_optional_path(
×
104
        helper_raw.get("path"),
105
        base_dir=base_dir,
106
        key="helper.path",
107
    )
108
    helper_module_raw = helper_raw.get("module", "nml_helper")
×
109
    if not isinstance(helper_module_raw, str):
×
110
        raise click.ClickException("config 'helper.module' must be a string")
×
111
    helper_module = helper_module_raw.strip()
×
112
    if not helper_module:
×
113
        raise click.ClickException("config 'helper.module' must be a non-empty string")
×
114
    header_raw = helper_raw.get("header")
×
115
    if header_raw is None:
×
116
        header = None
×
117
    else:
118
        if not isinstance(header_raw, str):
×
119
            raise click.ClickException("config 'helper.header' must be a string")
×
120
        header = header_raw.rstrip()
×
121
        if not header:
×
122
            header = None
×
123
    buffer_raw = helper_raw.get("buffer", default_buffer)
×
124
    if isinstance(buffer_raw, bool) or not isinstance(buffer_raw, int):
×
125
        raise click.ClickException("config 'helper.buffer' must be an integer")
×
126
    if buffer_raw <= 0:
×
127
        raise click.ClickException("config 'helper.buffer' must be positive")
×
128
    return helper_path, helper_module, buffer_raw, header
×
129

130

131
def _load_kind_settings(config: dict[str, Any]) -> tuple[str, dict[str, str], set[str]]:
×
132
    kinds_raw = config.get("kinds")
×
133
    if not isinstance(kinds_raw, dict):
×
134
        raise click.ClickException("config must define a [kinds] table")
×
135
    module_raw = kinds_raw.get("module")
×
136
    if not isinstance(module_raw, str) or not module_raw.strip():
×
137
        raise click.ClickException("config 'kinds.module' must be a non-empty string")
×
138
    module = module_raw.strip()
×
139

140
    map_raw = kinds_raw.get("map", {})
×
141
    if map_raw is None:
×
142
        map_raw = {}
×
143
    if not isinstance(map_raw, dict):
×
144
        raise click.ClickException("config 'kinds.map' must be a table")
×
145
    kind_map: dict[str, str] = {}
×
146
    for alias, target in map_raw.items():
×
147
        if not isinstance(alias, str) or not isinstance(target, str):
×
148
            raise click.ClickException("config 'kinds.map' keys and values must be strings")
×
149
        kind_map[alias] = target
×
150

151
    real_raw = kinds_raw.get("real", [])
×
152
    integer_raw = kinds_raw.get("integer", [])
×
153
    if not isinstance(real_raw, list) or not all(isinstance(item, str) for item in real_raw):
×
154
        raise click.ClickException("config 'kinds.real' must be a list of strings")
×
155
    if not isinstance(integer_raw, list) or not all(isinstance(item, str) for item in integer_raw):
×
156
        raise click.ClickException("config 'kinds.integer' must be a list of strings")
×
157
    allowlist = set(real_raw) | set(integer_raw)
×
158

159
    return module, kind_map, allowlist
×
160

161

NEW
162
def _load_f2py_settings(
×
163
    config: dict[str, Any],
164
    base_dir: Path,
165
) -> tuple[Path | None, F2pyCTypeMap]:
NEW
166
    f2py_raw = config.get("f2py")
×
NEW
167
    if f2py_raw is None:
×
NEW
168
        return None, F2pyCTypeMap(real={}, integer={})
×
NEW
169
    if not isinstance(f2py_raw, dict):
×
NEW
170
        raise click.ClickException("config 'f2py' must be a table")
×
NEW
171
    if "python_package" in f2py_raw:
×
NEW
172
        raise click.ClickException(
×
173
            "config 'f2py.python_package' is no longer supported; "
174
            "place the f2py extension next to the generated Python wrapper"
175
        )
176

NEW
177
    f2cmap_path = _resolve_optional_path(
×
178
        f2py_raw.get("f2cmap_path"),
179
        base_dir=base_dir,
180
        key="f2py.f2cmap_path",
181
    )
182

NEW
183
    c_types_raw = f2py_raw.get("c_types", {})
×
NEW
184
    if c_types_raw is None:
×
NEW
185
        c_types_raw = {}
×
NEW
186
    if not isinstance(c_types_raw, dict):
×
NEW
187
        raise click.ClickException("config 'f2py.c_types' must be a table")
×
188

NEW
189
    real = _load_f2py_ctype_table(c_types_raw, "real")
×
NEW
190
    integer = _load_f2py_ctype_table(c_types_raw, "integer")
×
NEW
191
    return f2cmap_path, F2pyCTypeMap(real=real, integer=integer)
×
192

193

NEW
194
def _load_f2py_ctype_table(c_types: dict[str, Any], key: str) -> dict[str, str]:
×
NEW
195
    raw = c_types.get(key, {})
×
NEW
196
    if raw is None:
×
NEW
197
        raw = {}
×
NEW
198
    if not isinstance(raw, dict):
×
NEW
199
        raise click.ClickException(f"config 'f2py.c_types.{key}' must be a table")
×
NEW
200
    values: dict[str, str] = {}
×
NEW
201
    for kind, c_type in raw.items():
×
NEW
202
        if not isinstance(kind, str) or not kind.strip():
×
NEW
203
            raise click.ClickException(
×
204
                f"config 'f2py.c_types.{key}' keys must be non-empty strings"
205
            )
NEW
206
        if not isinstance(c_type, str) or not c_type.strip():
×
NEW
207
            raise click.ClickException(
×
208
                f"config 'f2py.c_types.{key}.{kind}' must be a non-empty string"
209
            )
NEW
210
        values[kind.strip()] = c_type.strip()
×
NEW
211
    return values
×
212

213

214
def _format_constant_literal(value: int | float) -> tuple[str, str]:
×
215
    if isinstance(value, bool):
×
216
        raise click.ClickException("config constants must not be boolean")
×
217
    if isinstance(value, int):
×
218
        return "integer", str(value)
×
219
    if isinstance(value, float):
×
220
        literal = repr(float(value))
×
221
        if literal.lower() == "nan":
×
222
            raise click.ClickException("config constants must not be NaN")
×
223
        if "." not in literal and "e" not in literal and "E" not in literal:
×
224
            literal = f"{literal}.0"
×
225
        return "real", literal
×
226
    raise click.ClickException("config constants must be integers or reals")
×
227

228

229
def _load_constants(config: dict[str, Any]) -> tuple[dict[str, int | float], list[ConstantSpec]]:
×
230
    constants_raw = config.get("constants", {})
×
231
    if constants_raw is None:
×
232
        constants_raw = {}
×
233
    if not isinstance(constants_raw, dict):
×
234
        raise click.ClickException("config 'constants' must be a table")
×
235

236
    constants: dict[str, int | float] = {}
×
237
    specs: list[ConstantSpec] = []
×
238
    for name_raw, entry in constants_raw.items():
×
239
        if not isinstance(name_raw, str):
×
240
            raise click.ClickException("config constants must use string keys")
×
241
        name = name_raw.strip()
×
242
        if not name:
×
243
            raise click.ClickException("config constants must have non-empty names")
×
244
        if not _FORTRAN_IDENTIFIER.match(name):
×
245
            raise click.ClickException(
×
246
                f"config constant '{name}' must be a valid Fortran identifier"
247
            )
248
        if not isinstance(entry, dict):
×
249
            raise click.ClickException(f"config constant '{name}' must be a table with 'value'")
×
250
        if "value" not in entry:
×
251
            raise click.ClickException(f"config constant '{name}' must define 'value'")
×
252
        value = entry.get("value")
×
253
        if not isinstance(value, (int, float)):
×
254
            raise click.ClickException("config constants must be integers or reals")
×
255
        type_spec, literal = _format_constant_literal(value)
×
256
        doc = entry.get("doc")
×
257
        if doc is not None:
×
258
            if not isinstance(doc, str):
×
259
                raise click.ClickException(f"config constant '{name}' doc must be a string")
×
260
            doc = " ".join(doc.splitlines()).strip() or None
×
261
        specs.append(
×
262
            ConstantSpec(
263
                name=name,
264
                type_spec=type_spec,
265
                value=literal,
266
                doc=doc,
267
            )
268
        )
269
        constants[name] = value
×
270
    return constants, specs
×
271

272

273
def _load_bool_field(section: dict[str, Any], key: str, *, label: str) -> bool:
×
274
    value = section.get(key, False)
×
275
    if isinstance(value, bool):
×
276
        return value
×
277
    raise click.ClickException(f"config '{label}' must be a boolean")
×
278

279

280
def _load_documentation_settings(
×
281
    config: dict[str, Any],
282
) -> tuple[str | None, bool, bool, str]:
283
    doc_raw = config.get("documentation")
×
284
    if doc_raw is None:
×
NEW
285
        return None, False, False, "numpy"
×
286
    if not isinstance(doc_raw, dict):
×
287
        raise click.ClickException("config 'documentation' must be a table")
×
288
    module_doc = None
×
289
    if "module" in doc_raw:
×
290
        module_raw = doc_raw.get("module")
×
291
        if not isinstance(module_raw, str):
×
292
            raise click.ClickException("config 'documentation.module' must be a string")
×
293
        module_doc = module_raw.strip() or None
×
294
    md_doxygen_id_from_name = _load_bool_field(
×
295
        doc_raw,
296
        "md_doxygen_id_from_name",
297
        label="documentation.md_doxygen_id_from_name",
298
    )
299
    md_add_toc_statement = _load_bool_field(
×
300
        doc_raw,
301
        "md_add_toc_statement",
302
        label="documentation.md_add_toc_statement",
303
    )
NEW
304
    py_style_raw = doc_raw.get("py-style", "numpy")
×
NEW
305
    if not isinstance(py_style_raw, str):
×
NEW
306
        raise click.ClickException("config 'documentation.py-style' must be a string")
×
NEW
307
    py_style = py_style_raw.strip().lower()
×
NEW
308
    if py_style not in {"numpy", "doxygen"}:
×
NEW
309
        raise click.ClickException(
×
310
            "config 'documentation.py-style' must be 'numpy' or 'doxygen'"
311
        )
NEW
312
    return module_doc, md_doxygen_id_from_name, md_add_toc_statement, py_style
×
313

314

315
def _parse_cli_constants(values: tuple[str, ...]) -> dict[str, int | float]:
×
316
    constants: dict[str, int | float] = {}
×
317
    for entry in values:
×
318
        if "=" not in entry:
×
319
            raise click.ClickException("constants must be provided as NAME=VALUE")
×
320
        raw_name, raw_value = entry.split("=", 1)
×
321
        name = raw_name.strip()
×
322
        if not name:
×
323
            raise click.ClickException("constants must use non-empty names")
×
324
        if not _FORTRAN_IDENTIFIER.match(name):
×
325
            raise click.ClickException(f"constant '{name}' must be a valid identifier")
×
326
        value_text = raw_value.strip()
×
327
        if not value_text:
×
328
            raise click.ClickException(f"constant '{name}' must define a value")
×
329
        if value_text.lower() in {"nan", "+nan", "-nan", "inf", "+inf", "-inf"}:
×
330
            raise click.ClickException(f"constant '{name}' must be finite")
×
331
        if re.fullmatch(r"[+-]?\d+", value_text):
×
332
            value: int | float = int(value_text)
×
333
        else:
334
            try:
×
335
                value = float(value_text)
×
336
            except ValueError as exc:
×
337
                raise click.ClickException(
×
338
                    f"constant '{name}' value '{value_text}' must be numeric"
339
                ) from exc
340
            if math.isinf(value) or math.isnan(value):
×
341
                raise click.ClickException(f"constant '{name}' must be finite")
×
342
        constants[name] = value
×
343
    return constants
×
344

345

NEW
346
def _iter_namelists(config: dict[str, Any], base_dir: Path) -> list[dict[str, Any]]:
×
347
    raw_entries = config.get("namelists")
×
348
    if raw_entries is None and "nml-files" in config:
×
349
        raise click.ClickException("config uses deprecated 'nml-files'; rename to 'namelists'")
×
350
    if not isinstance(raw_entries, list) or not raw_entries:
×
351
        raise click.ClickException("config must define non-empty 'namelists'")
×
352

353
    entries: list[dict[str, Path | None]] = []
×
354
    for entry in raw_entries:
×
355
        if not isinstance(entry, dict):
×
356
            raise click.ClickException("each namelists entry must be a table")
×
357
        schema_raw = entry.get("schema")
×
358
        if not isinstance(schema_raw, str):
×
359
            raise click.ClickException("namelists entry must define string 'schema'")
×
360
        schema_path = base_dir / schema_raw
×
NEW
361
        f2py_path = _resolve_optional_path(
×
362
            entry.get("f2py_path"),
363
            base_dir=base_dir,
364
            key="f2py_path",
365
        )
NEW
366
        py_path = _resolve_optional_path(
×
367
            entry.get("py_path"),
368
            base_dir=base_dir,
369
            key="py_path",
370
        )
NEW
371
        mod_path = _resolve_optional_path(
×
372
            entry.get("mod_path"),
373
            base_dir=base_dir,
374
            key="mod_path",
375
        )
NEW
376
        if f2py_path is not None and mod_path is None:
×
NEW
377
            raise click.ClickException(
×
378
                "namelists entry with 'f2py_path' must define 'mod_path'"
379
            )
NEW
380
        if py_path is not None and f2py_path is None:
×
NEW
381
            raise click.ClickException("namelists entry with 'py_path' must define 'f2py_path'")
×
UNCOV
382
        entries.append(
×
383
            {
384
                "schema": schema_path,
385
                "mod_path": mod_path,
386
                "doc_path": _resolve_optional_path(
387
                    entry.get("doc_path"),
388
                    base_dir=base_dir,
389
                    key="doc_path",
390
                ),
391
                "temp_path": _resolve_optional_path(
392
                    entry.get("temp_path"),
393
                    base_dir=base_dir,
394
                    key="temp_path",
395
                ),
396
                "f2py_path": f2py_path,
397
                "py_path": py_path,
398
            }
399
        )
400
    return entries
×
401

402

403
def _iter_templates(config: dict[str, Any], base_dir: Path) -> list[dict[str, Any]]:
×
404
    raw_entries = config.get("templates")
×
405
    if raw_entries is None:
×
406
        return []
×
407
    if not isinstance(raw_entries, list) or not raw_entries:
×
408
        raise click.ClickException("config 'templates' must be a non-empty list")
×
409

410
    entries: list[dict[str, Any]] = []
×
411
    for entry in raw_entries:
×
412
        if not isinstance(entry, dict):
×
413
            raise click.ClickException("each templates entry must be a table")
×
414
        output_raw = entry.get("output")
×
415
        if not isinstance(output_raw, str):
×
416
            raise click.ClickException("templates entry must define string 'output'")
×
417
        output_path = base_dir / output_raw
×
418

419
        schemas_raw = entry.get("schemas")
×
420
        if not isinstance(schemas_raw, list) or not schemas_raw:
×
421
            raise click.ClickException("templates entry must define non-empty 'schemas'")
×
422
        if not all(isinstance(item, str) for item in schemas_raw):
×
423
            raise click.ClickException("templates 'schemas' entries must be strings")
×
424
        schema_paths = [base_dir / item for item in schemas_raw]
×
425

426
        doc_mode = entry.get("doc_mode", "plain")
×
427
        if not isinstance(doc_mode, str):
×
428
            raise click.ClickException("templates 'doc_mode' must be a string")
×
429
        value_mode = entry.get("value_mode", "empty")
×
430
        if not isinstance(value_mode, str):
×
431
            raise click.ClickException("templates 'value_mode' must be a string")
×
432
        values_raw = entry.get("values", {})
×
433
        if values_raw is None:
×
434
            values_raw = {}
×
435
        if not isinstance(values_raw, dict):
×
436
            raise click.ClickException("templates 'values' must be a table")
×
437

438
        entries.append(
×
439
            {
440
                "output": output_path,
441
                "schemas": schema_paths,
442
                "doc_mode": doc_mode,
443
                "value_mode": value_mode,
444
                "values": values_raw,
445
            }
446
        )
447
    return entries
×
448

449

NEW
450
def _generate_f2py_outputs(
×
451
    loaded_entries: list[dict[str, Any]],
452
    *,
453
    helper_module: str,
454
    helper_buffer: int,
455
    kind_module: str,
456
    kind_map: dict[str, str],
457
    kind_allowlist: set[str],
458
    constants: dict[str, int | float],
459
    f2cmap_path: Path | None,
460
    f2py_c_types: F2pyCTypeMap,
461
    py_style: str,
462
    include_python: bool,
463
) -> None:
NEW
464
    f2py_groups: dict[Path, list[dict[str, Any]]] = {}
×
NEW
465
    py_groups: dict[Path, list[tuple[dict[str, Any], Path]]] = {}
×
NEW
466
    for loaded in loaded_entries:
×
NEW
467
        entry = loaded["entry"]
×
NEW
468
        schema = loaded["schema"]
×
NEW
469
        f2py_path = entry["f2py_path"]
×
NEW
470
        if f2py_path is None:
×
NEW
471
            continue
×
NEW
472
        f2py_groups.setdefault(f2py_path, []).append(schema)
×
NEW
473
        py_path = entry["py_path"]
×
NEW
474
        if include_python and py_path is not None:
×
NEW
475
            py_groups.setdefault(py_path, []).append((schema, f2py_path))
×
476

NEW
477
    for f2py_path, schemas in f2py_groups.items():
×
NEW
478
        try:
×
NEW
479
            logger.info("Generating f2py wrappers at %s", f2py_path)
×
NEW
480
            generate_f2py_wrappers(
×
481
                schemas,
482
                f2py_path,
483
                helper_module=helper_module,
484
                kind_module=kind_module,
485
                kind_map=kind_map,
486
                kind_allowlist=kind_allowlist,
487
                constants=constants,
488
                errmsg_len=helper_buffer,
489
            )
NEW
490
        except ValueError as exc:
×
NEW
491
            raise click.ClickException(str(exc)) from exc
×
492

NEW
493
    if f2cmap_path is not None:
×
NEW
494
        try:
×
NEW
495
            usage = merge_f2py_kind_usage(
×
496
                collect_f2py_kind_usage(schemas, constants=constants)
497
                for schemas in f2py_groups.values()
498
            )
NEW
499
            logger.info("Generating f2py kind map at %s", f2cmap_path)
×
NEW
500
            generate_f2cmap(f2cmap_path, usage, f2py_c_types)
×
NEW
501
        except ValueError as exc:
×
NEW
502
            raise click.ClickException(str(exc)) from exc
×
503

NEW
504
    if not include_python:
×
NEW
505
        return
×
NEW
506
    for py_path, entries in py_groups.items():
×
NEW
507
        try:
×
NEW
508
            logger.info("Generating Python f2py wrappers at %s", py_path)
×
NEW
509
            specs = [
×
510
                (
511
                    build_f2py_namelist_spec(
512
                        schema,
513
                        helper_module=helper_module,
514
                        kind_module=kind_module,
515
                        kind_map=kind_map,
516
                        kind_allowlist=kind_allowlist,
517
                        constants=constants,
518
                        errmsg_len=helper_buffer,
519
                    ),
520
                    f2py_path.stem,
521
                )
522
                for schema, f2py_path in entries
523
            ]
NEW
524
            generate_python_wrappers(
×
525
                specs,
526
                py_path,
527
                py_style=py_style,
528
            )
NEW
529
        except ValueError as exc:
×
NEW
530
            raise click.ClickException(str(exc)) from exc
×
531

532

533
@click.group(context_settings=_CONTEXT_SETTINGS)
×
534
@click.version_option(__version__, "-V", "--version", prog_name="nml-tools")
×
535
@click.option("--verbose", "-v", count=True, help="Increase verbosity (repeatable).")
×
536
@click.option("--quiet", "-q", count=True, help="Decrease verbosity (repeatable).")
×
537
def cli(verbose: int, quiet: int) -> None:
×
538
    """nml-tools command line interface."""
539
    _configure_logging(verbose, quiet)
×
540

541

542
@cli.command("generate", context_settings=_CONTEXT_SETTINGS)
×
543
@click.option(
×
544
    "--config",
545
    "config_path",
546
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
547
    default="nml-config.toml",
548
    show_default=True,
549
)
550
def generate(config_path: Path) -> None:
×
551
    """Generate outputs from a configuration file."""
552
    logger.info("Loading config from %s", config_path)
×
553
    config = _load_config_checked(config_path)
×
554
    base_dir = config_path.parent
×
555
    logger.debug("Base directory: %s", base_dir)
×
556
    helper_path, helper_module, helper_buffer, helper_header = _load_helper_settings(
×
557
        config,
558
        base_dir,
559
    )
NEW
560
    (
×
561
        module_doc,
562
        md_doxygen_id_from_name,
563
        md_add_toc_statement,
564
        py_style,
565
    ) = _load_documentation_settings(config)
566
    constants, constant_specs = _load_constants(config)
×
567
    kind_module, kind_map, kind_allowlist = _load_kind_settings(config)
×
NEW
568
    f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir)
×
569
    if helper_path is not None:
×
570
        try:
×
571
            logger.info("Generating helper module at %s", helper_path)
×
572
            generate_helper(
×
573
                helper_path,
574
                module_name=helper_module,
575
                len_buf=helper_buffer,
576
                constants=constant_specs,
577
                module_doc=module_doc,
578
                helper_header=helper_header,
579
            )
580
        except ValueError as exc:
×
581
            raise click.ClickException(str(exc)) from exc
×
582
    entries = _iter_namelists(config, base_dir)
×
583
    logger.info("Found %d schema entries", len(entries))
×
NEW
584
    loaded_entries: list[dict[str, Any]] = []
×
585
    for namelist_entry in entries:
×
586
        schema_path = namelist_entry["schema"]
×
587
        if schema_path is None:
×
588
            raise click.ClickException("namelists entry missing schema path")
×
589
        try:
×
590
            logger.info("Loading schema %s", schema_path)
×
591
            schema = load_schema(schema_path)
×
592
        except (FileNotFoundError, ValueError) as exc:
×
593
            raise click.ClickException(str(exc)) from exc
×
NEW
594
        loaded_entries.append({"entry": namelist_entry, "schema": schema})
×
595
        mod_path = namelist_entry["mod_path"]
×
596
        if mod_path is not None:
×
597
            try:
×
598
                logger.info("Generating Fortran module at %s", mod_path)
×
599
                generate_fortran(
×
600
                    schema,
601
                    mod_path,
602
                    helper_module=helper_module,
603
                    kind_module=kind_module,
604
                    kind_map=kind_map,
605
                    kind_allowlist=kind_allowlist,
606
                    constants=constants,
607
                    module_doc=module_doc,
608
                    f2py_handle_helpers=namelist_entry["f2py_path"] is not None,
609
                )
610
            except ValueError as exc:
×
611
                raise click.ClickException(str(exc)) from exc
×
612
        doc_path = namelist_entry["doc_path"]
×
613
        if doc_path is not None:
×
614
            try:
×
615
                logger.info("Generating Markdown docs at %s", doc_path)
×
616
                generate_docs(
×
617
                    schema,
618
                    doc_path,
619
                    constants=constants,
620
                    md_doxygen_id_from_name=md_doxygen_id_from_name,
621
                    md_add_toc_statement=md_add_toc_statement,
622
                )
623
            except ValueError as exc:
×
624
                raise click.ClickException(str(exc)) from exc
×
625

NEW
626
    _generate_f2py_outputs(
×
627
        loaded_entries,
628
        helper_module=helper_module,
629
        helper_buffer=helper_buffer,
630
        kind_module=kind_module,
631
        kind_map=kind_map,
632
        kind_allowlist=kind_allowlist,
633
        constants=constants,
634
        f2cmap_path=f2cmap_path,
635
        f2py_c_types=f2py_c_types,
636
        py_style=py_style,
637
        include_python=True,
638
    )
639

640
    template_entries = _iter_templates(config, base_dir)
×
641
    if template_entries:
×
642
        logger.info("Found %d template entries", len(template_entries))
×
643
    for template_entry in template_entries:
×
644
        try:
×
645
            schemas = []
×
646
            for schema_path in template_entry["schemas"]:
×
647
                logger.info("Loading schema %s", schema_path)
×
648
                schemas.append(load_schema(schema_path))
×
649
            logger.info("Generating template at %s", template_entry["output"])
×
650
            generate_template(
×
651
                schemas,
652
                template_entry["output"],
653
                doc_mode=template_entry["doc_mode"],
654
                value_mode=template_entry["value_mode"],
655
                constants=constants,
656
                kind_map=kind_map,
657
                kind_allowlist=kind_allowlist,
658
                values=template_entry["values"],
659
            )
660
        except (FileNotFoundError, ValueError) as exc:
×
661
            raise click.ClickException(str(exc)) from exc
×
662

663

664
@cli.command("gen-fortran", context_settings=_CONTEXT_SETTINGS)
×
665
@click.option(
×
666
    "--config",
667
    "config_path",
668
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
669
    default="nml-config.toml",
670
    show_default=True,
671
)
672
def gen_fortran(config_path: Path) -> None:
×
673
    """Generate Fortran module(s)."""
674
    logger.info("Loading config from %s", config_path)
×
675
    config = _load_config_checked(config_path)
×
676
    base_dir = config_path.parent
×
677
    logger.debug("Base directory: %s", base_dir)
×
678
    helper_path, helper_module, helper_buffer, helper_header = _load_helper_settings(
×
679
        config,
680
        base_dir,
681
    )
NEW
682
    module_doc, _, _, py_style = _load_documentation_settings(config)
×
683
    constants, constant_specs = _load_constants(config)
×
684
    kind_module, kind_map, kind_allowlist = _load_kind_settings(config)
×
NEW
685
    f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir)
×
686
    if helper_path is not None:
×
687
        try:
×
688
            logger.info("Generating helper module at %s", helper_path)
×
689
            generate_helper(
×
690
                helper_path,
691
                module_name=helper_module,
692
                len_buf=helper_buffer,
693
                constants=constant_specs,
694
                module_doc=module_doc,
695
                helper_header=helper_header,
696
            )
697
        except ValueError as exc:
×
698
            raise click.ClickException(str(exc)) from exc
×
699

700
    entries = _iter_namelists(config, base_dir)
×
701
    logger.info("Found %d schema entries", len(entries))
×
NEW
702
    loaded_entries: list[dict[str, Any]] = []
×
703
    for entry in entries:
×
704
        schema_path = entry["schema"]
×
705
        if schema_path is None:
×
706
            raise click.ClickException("namelists entry missing schema path")
×
707
        try:
×
708
            logger.info("Loading schema %s", schema_path)
×
709
            schema = load_schema(schema_path)
×
710
        except (FileNotFoundError, ValueError) as exc:
×
711
            raise click.ClickException(str(exc)) from exc
×
NEW
712
        loaded_entries.append({"entry": entry, "schema": schema})
×
713
        mod_path = entry["mod_path"]
×
714
        if mod_path is None:
×
715
            continue
×
716
        try:
×
717
            logger.info("Generating Fortran module at %s", mod_path)
×
718
            generate_fortran(
×
719
                schema,
720
                mod_path,
721
                helper_module=helper_module,
722
                kind_module=kind_module,
723
                kind_map=kind_map,
724
                kind_allowlist=kind_allowlist,
725
                constants=constants,
726
                module_doc=module_doc,
727
                f2py_handle_helpers=entry["f2py_path"] is not None,
728
            )
729
        except ValueError as exc:
×
730
            raise click.ClickException(str(exc)) from exc
×
731

NEW
732
    _generate_f2py_outputs(
×
733
        loaded_entries,
734
        helper_module=helper_module,
735
        helper_buffer=helper_buffer,
736
        kind_module=kind_module,
737
        kind_map=kind_map,
738
        kind_allowlist=kind_allowlist,
739
        constants=constants,
740
        f2cmap_path=f2cmap_path,
741
        f2py_c_types=f2py_c_types,
742
        py_style=py_style,
743
        include_python=False,
744
    )
745

746

747
@cli.command("validate", context_settings=_CONTEXT_SETTINGS)
×
748
@click.option(
×
749
    "--config",
750
    "config_path",
751
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
752
    default=None,
753
)
754
@click.option(
×
755
    "--schema",
756
    "schema_paths",
757
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
758
    multiple=True,
759
)
760
@click.option(
×
761
    "--input",
762
    "input_option",
763
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
764
)
765
@click.option(
×
766
    "--constants",
767
    "constant_args",
768
    multiple=True,
769
    help="Additional constants as NAME=VALUE (repeatable).",
770
)
771
@click.argument(
×
772
    "input_path",
773
    required=False,
774
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
775
)
776
def validate(
×
777
    config_path: Path | None,
778
    schema_paths: tuple[Path, ...],
779
    input_option: Path | None,
780
    constant_args: tuple[str, ...],
781
    input_path: Path | None,
782
) -> None:
783
    """Validate a namelist file against schema definitions."""
784
    if input_option is not None and input_path is not None:
×
785
        raise click.ClickException("input path provided twice")
×
786
    input_path = input_option or input_path
×
787
    if input_path is None:
×
788
        raise click.ClickException("input path is required")
×
789
    constants = _parse_cli_constants(constant_args)
×
790
    schemas: list[dict[str, Any]] = []
×
791

792
    if schema_paths:
×
793
        if config_path is not None:
×
794
            logger.info("Loading config from %s", config_path)
×
795
            config = _load_config_checked(config_path)
×
796
            cfg_constants, _ = _load_constants(config)
×
797
            constants = {**cfg_constants, **constants}
×
798
        for schema_file in schema_paths:
×
799
            try:
×
800
                logger.info("Loading schema %s", schema_file)
×
801
                schemas.append(load_schema(schema_file))
×
802
            except (FileNotFoundError, ValueError) as exc:
×
803
                raise click.ClickException(str(exc)) from exc
×
804
        require_all = True
×
805
    else:
806
        if config_path is None:
×
807
            config_path = Path("nml-config.toml")
×
808
        logger.info("Loading config from %s", config_path)
×
809
        config = _load_config_checked(config_path)
×
810
        base_dir = config_path.parent
×
811
        cfg_constants, _ = _load_constants(config)
×
812
        constants = {**cfg_constants, **constants}
×
813
        entries = _iter_namelists(config, base_dir)
×
814
        logger.info("Found %d schema entries", len(entries))
×
815
        for entry in entries:
×
816
            schema_path = entry["schema"]
×
817
            if schema_path is None:
×
818
                raise click.ClickException("namelists entry missing schema path")
×
819
            try:
×
820
                logger.info("Loading schema %s", schema_path)
×
821
                schemas.append(load_schema(schema_path))
×
822
            except (FileNotFoundError, ValueError) as exc:
×
823
                raise click.ClickException(str(exc)) from exc
×
824
        require_all = False
×
825

826
    if not schemas:
×
827
        raise click.ClickException("no schemas provided for validation")
×
828

829
    try:
×
830
        logger.info("Reading namelist %s", input_path)
×
831
        namelist_file = f90nml.read(input_path)
×
832
    except Exception as exc:  # pragma: no cover - f90nml raises custom errors
833
        raise click.ClickException(f"failed to read namelist: {exc}") from exc
834

835
    file_entries: dict[str, tuple[str, Any]] = {}
×
836
    for name, values in namelist_file.items():
×
837
        if not isinstance(name, str):
×
838
            raise click.ClickException("namelist names must be strings")
×
839
        key = name.lower()
×
840
        if key in file_entries:
×
841
            raise click.ClickException(f"namelist '{name}' appears multiple times")
×
842
        file_entries[key] = (name, values)
×
843

844
    schema_entries: dict[str, dict[str, Any]] = {}
×
845
    for schema in schemas:
×
846
        namelist_name = schema.get("x-fortran-namelist")
×
847
        if not isinstance(namelist_name, str) or not namelist_name.strip():
×
848
            raise click.ClickException("schema must define non-empty 'x-fortran-namelist'")
×
849
        key = namelist_name.lower()
×
850
        if key in schema_entries:
×
851
            raise click.ClickException(f"duplicate schema for namelist '{namelist_name}'")
×
852
        schema_entries[key] = schema
×
853

854
    for key, (name, _) in file_entries.items():
×
855
        if key not in schema_entries:
×
856
            raise click.ClickException(f"input contains unknown namelist '{name}'")
×
857

858
    validated = 0
×
859
    for key, schema in schema_entries.items():
×
860
        if key not in file_entries:
×
861
            if require_all:
×
862
                raise click.ClickException(
×
863
                    f"input is missing namelist '{schema.get('x-fortran-namelist')}'"
864
                )
865
            continue
×
866
        try:
×
867
            validate_namelist(
×
868
                schema,
869
                file_entries[key][1],
870
                constants=constants,
871
            )
872
        except ValueError as exc:
×
873
            raise click.ClickException(str(exc)) from exc
×
874
        validated += 1
×
875
    logger.info("Validation completed (%d namelist%s).", validated, "" if validated == 1 else "s")
×
876

877

878
@cli.command("gen-markdown", context_settings=_CONTEXT_SETTINGS)
×
879
@click.option(
×
880
    "--config",
881
    "config_path",
882
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
883
    default="nml-config.toml",
884
    show_default=True,
885
)
886
def gen_markdown(config_path: Path) -> None:
×
887
    """Generate Markdown docs."""
888
    logger.info("Loading config from %s", config_path)
×
889
    config = _load_config_checked(config_path)
×
890
    base_dir = config_path.parent
×
891
    constants, _ = _load_constants(config)
×
NEW
892
    _, md_doxygen_id_from_name, md_add_toc_statement, _ = _load_documentation_settings(
×
893
        config
894
    )
895
    entries = _iter_namelists(config, base_dir)
×
896
    logger.info("Found %d schema entries", len(entries))
×
897
    for entry in entries:
×
898
        schema_path = entry["schema"]
×
899
        if schema_path is None:
×
900
            raise click.ClickException("namelists entry missing schema path")
×
901
        try:
×
902
            logger.info("Loading schema %s", schema_path)
×
903
            schema = load_schema(schema_path)
×
904
        except (FileNotFoundError, ValueError) as exc:
×
905
            raise click.ClickException(str(exc)) from exc
×
906
        doc_path = entry["doc_path"]
×
907
        if doc_path is None:
×
908
            continue
×
909
        try:
×
910
            logger.info("Generating Markdown docs at %s", doc_path)
×
911
            generate_docs(
×
912
                schema,
913
                doc_path,
914
                constants=constants,
915
                md_doxygen_id_from_name=md_doxygen_id_from_name,
916
                md_add_toc_statement=md_add_toc_statement,
917
            )
918
        except ValueError as exc:
×
919
            raise click.ClickException(str(exc)) from exc
×
920

921

922
@cli.command("gen-template", context_settings=_CONTEXT_SETTINGS)
×
923
@click.option(
×
924
    "--config",
925
    "config_path",
926
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
927
    default="nml-config.toml",
928
    show_default=True,
929
)
930
def gen_template(config_path: Path) -> None:
×
931
    """Generate template namelist(s)."""
932
    logger.info("Loading config from %s", config_path)
×
933
    config = _load_config_checked(config_path)
×
934
    base_dir = config_path.parent
×
935
    constants, _ = _load_constants(config)
×
936
    _, kind_map, kind_allowlist = _load_kind_settings(config)
×
937
    templates = _iter_templates(config, base_dir)
×
938
    if not templates:
×
939
        raise click.ClickException("config must define non-empty 'templates'")
×
940
    logger.info("Found %d template entries", len(templates))
×
941
    for entry in templates:
×
942
        try:
×
943
            schemas = []
×
944
            for schema_path in entry["schemas"]:
×
945
                logger.info("Loading schema %s", schema_path)
×
946
                schemas.append(load_schema(schema_path))
×
947
            logger.info("Generating template at %s", entry["output"])
×
948
            generate_template(
×
949
                schemas,
950
                entry["output"],
951
                doc_mode=entry["doc_mode"],
952
                value_mode=entry["value_mode"],
953
                constants=constants,
954
                kind_map=kind_map,
955
                kind_allowlist=kind_allowlist,
956
                values=entry["values"],
957
            )
958
        except (FileNotFoundError, ValueError) as exc:
×
959
            raise click.ClickException(str(exc)) from exc
×
960

961

962
def main(argv: list[str] | None = None) -> int:
×
963
    """Entry point for the CLI."""
964
    try:
×
965
        cli.main(args=argv, prog_name="nml-tools", standalone_mode=False)
×
966
    except Exit as exc:
×
967
        return exc.exit_code
×
968
    except click.ClickException as exc:
×
969
        exc.show()
×
970
        return 1
×
971
    return 0
×
972

973

974
if __name__ == "__main__":  # pragma: no cover - CLI entry point
975
    raise SystemExit(main())
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