• 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

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
        if py_path is not None and f2py_path is None:
×
NEW
372
            raise click.ClickException("namelists entry with 'py_path' must define 'f2py_path'")
×
UNCOV
373
        entries.append(
×
374
            {
375
                "schema": schema_path,
376
                "mod_path": _resolve_optional_path(
377
                    entry.get("mod_path"),
378
                    base_dir=base_dir,
379
                    key="mod_path",
380
                ),
381
                "doc_path": _resolve_optional_path(
382
                    entry.get("doc_path"),
383
                    base_dir=base_dir,
384
                    key="doc_path",
385
                ),
386
                "temp_path": _resolve_optional_path(
387
                    entry.get("temp_path"),
388
                    base_dir=base_dir,
389
                    key="temp_path",
390
                ),
391
                "f2py_path": f2py_path,
392
                "py_path": py_path,
393
            }
394
        )
395
    return entries
×
396

397

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

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

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

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

433
        entries.append(
×
434
            {
435
                "output": output_path,
436
                "schemas": schema_paths,
437
                "doc_mode": doc_mode,
438
                "value_mode": value_mode,
439
                "values": values_raw,
440
            }
441
        )
442
    return entries
×
443

444

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

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

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

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

527

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

536

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

NEW
621
    _generate_f2py_outputs(
×
622
        loaded_entries,
623
        helper_module=helper_module,
624
        helper_buffer=helper_buffer,
625
        kind_module=kind_module,
626
        kind_map=kind_map,
627
        kind_allowlist=kind_allowlist,
628
        constants=constants,
629
        f2cmap_path=f2cmap_path,
630
        f2py_c_types=f2py_c_types,
631
        py_style=py_style,
632
        include_python=True,
633
    )
634

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

658

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

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

NEW
727
    _generate_f2py_outputs(
×
728
        loaded_entries,
729
        helper_module=helper_module,
730
        helper_buffer=helper_buffer,
731
        kind_module=kind_module,
732
        kind_map=kind_map,
733
        kind_allowlist=kind_allowlist,
734
        constants=constants,
735
        f2cmap_path=f2cmap_path,
736
        f2py_c_types=f2py_c_types,
737
        py_style=py_style,
738
        include_python=False,
739
    )
740

741

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

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

821
    if not schemas:
×
822
        raise click.ClickException("no schemas provided for validation")
×
823

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

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

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

849
    for key, (name, _) in file_entries.items():
×
850
        if key not in schema_entries:
×
851
            raise click.ClickException(f"input contains unknown namelist '{name}'")
×
852

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

872

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

916

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

956

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

968

969
if __name__ == "__main__":  # pragma: no cover - CLI entry point
970
    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