• 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

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]:
283
    doc_raw = config.get("documentation")
×
284
    if doc_raw is None:
×
285
        return None, False, False
×
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
    )
304
    return module_doc, md_doxygen_id_from_name, md_add_toc_statement
×
305

306

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

337

NEW
338
def _iter_namelists(config: dict[str, Any], base_dir: Path) -> list[dict[str, Any]]:
×
339
    raw_entries = config.get("namelists")
×
340
    if raw_entries is None and "nml-files" in config:
×
341
        raise click.ClickException("config uses deprecated 'nml-files'; rename to 'namelists'")
×
342
    if not isinstance(raw_entries, list) or not raw_entries:
×
343
        raise click.ClickException("config must define non-empty 'namelists'")
×
344

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

389

390
def _iter_templates(config: dict[str, Any], base_dir: Path) -> list[dict[str, Any]]:
×
391
    raw_entries = config.get("templates")
×
392
    if raw_entries is None:
×
393
        return []
×
394
    if not isinstance(raw_entries, list) or not raw_entries:
×
395
        raise click.ClickException("config 'templates' must be a non-empty list")
×
396

397
    entries: list[dict[str, Any]] = []
×
398
    for entry in raw_entries:
×
399
        if not isinstance(entry, dict):
×
400
            raise click.ClickException("each templates entry must be a table")
×
401
        output_raw = entry.get("output")
×
402
        if not isinstance(output_raw, str):
×
403
            raise click.ClickException("templates entry must define string 'output'")
×
404
        output_path = base_dir / output_raw
×
405

406
        schemas_raw = entry.get("schemas")
×
407
        if not isinstance(schemas_raw, list) or not schemas_raw:
×
408
            raise click.ClickException("templates entry must define non-empty 'schemas'")
×
409
        if not all(isinstance(item, str) for item in schemas_raw):
×
410
            raise click.ClickException("templates 'schemas' entries must be strings")
×
411
        schema_paths = [base_dir / item for item in schemas_raw]
×
412

413
        doc_mode = entry.get("doc_mode", "plain")
×
414
        if not isinstance(doc_mode, str):
×
415
            raise click.ClickException("templates 'doc_mode' must be a string")
×
416
        value_mode = entry.get("value_mode", "empty")
×
417
        if not isinstance(value_mode, str):
×
418
            raise click.ClickException("templates 'value_mode' must be a string")
×
419
        values_raw = entry.get("values", {})
×
420
        if values_raw is None:
×
421
            values_raw = {}
×
422
        if not isinstance(values_raw, dict):
×
423
            raise click.ClickException("templates 'values' must be a table")
×
424

425
        entries.append(
×
426
            {
427
                "output": output_path,
428
                "schemas": schema_paths,
429
                "doc_mode": doc_mode,
430
                "value_mode": value_mode,
431
                "values": values_raw,
432
            }
433
        )
434
    return entries
×
435

436

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

NEW
463
    for f2py_path, schemas in f2py_groups.items():
×
NEW
464
        try:
×
NEW
465
            logger.info("Generating f2py wrappers at %s", f2py_path)
×
NEW
466
            generate_f2py_wrappers(
×
467
                schemas,
468
                f2py_path,
469
                helper_module=helper_module,
470
                kind_module=kind_module,
471
                kind_map=kind_map,
472
                kind_allowlist=kind_allowlist,
473
                constants=constants,
474
                errmsg_len=helper_buffer,
475
            )
NEW
476
        except ValueError as exc:
×
NEW
477
            raise click.ClickException(str(exc)) from exc
×
478

NEW
479
    if f2cmap_path is not None:
×
NEW
480
        try:
×
NEW
481
            usage = merge_f2py_kind_usage(
×
482
                collect_f2py_kind_usage(schemas, constants=constants)
483
                for schemas in f2py_groups.values()
484
            )
NEW
485
            logger.info("Generating f2py kind map at %s", f2cmap_path)
×
NEW
486
            generate_f2cmap(f2cmap_path, usage, f2py_c_types)
×
NEW
487
        except ValueError as exc:
×
NEW
488
            raise click.ClickException(str(exc)) from exc
×
489

NEW
490
    if not include_python:
×
NEW
491
        return
×
NEW
492
    for py_path, entries in py_groups.items():
×
NEW
493
        try:
×
NEW
494
            logger.info("Generating Python f2py wrappers at %s", py_path)
×
NEW
495
            specs = [
×
496
                (
497
                    build_f2py_namelist_spec(
498
                        schema,
499
                        helper_module=helper_module,
500
                        kind_module=kind_module,
501
                        kind_map=kind_map,
502
                        kind_allowlist=kind_allowlist,
503
                        constants=constants,
504
                        errmsg_len=helper_buffer,
505
                    ),
506
                    f2py_path.stem,
507
                )
508
                for schema, f2py_path in entries
509
            ]
NEW
510
            generate_python_wrappers(
×
511
                specs,
512
                py_path,
513
            )
NEW
514
        except ValueError as exc:
×
NEW
515
            raise click.ClickException(str(exc)) from exc
×
516

517

518
@click.group(context_settings=_CONTEXT_SETTINGS)
×
519
@click.version_option(__version__, "-V", "--version", prog_name="nml-tools")
×
520
@click.option("--verbose", "-v", count=True, help="Increase verbosity (repeatable).")
×
521
@click.option("--quiet", "-q", count=True, help="Decrease verbosity (repeatable).")
×
522
def cli(verbose: int, quiet: int) -> None:
×
523
    """nml-tools command line interface."""
524
    _configure_logging(verbose, quiet)
×
525

526

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

NEW
608
    _generate_f2py_outputs(
×
609
        loaded_entries,
610
        helper_module=helper_module,
611
        helper_buffer=helper_buffer,
612
        kind_module=kind_module,
613
        kind_map=kind_map,
614
        kind_allowlist=kind_allowlist,
615
        constants=constants,
616
        f2cmap_path=f2cmap_path,
617
        f2py_c_types=f2py_c_types,
618
        include_python=True,
619
    )
620

621
    template_entries = _iter_templates(config, base_dir)
×
622
    if template_entries:
×
623
        logger.info("Found %d template entries", len(template_entries))
×
624
    for template_entry in template_entries:
×
625
        try:
×
626
            schemas = []
×
627
            for schema_path in template_entry["schemas"]:
×
628
                logger.info("Loading schema %s", schema_path)
×
629
                schemas.append(load_schema(schema_path))
×
630
            logger.info("Generating template at %s", template_entry["output"])
×
631
            generate_template(
×
632
                schemas,
633
                template_entry["output"],
634
                doc_mode=template_entry["doc_mode"],
635
                value_mode=template_entry["value_mode"],
636
                constants=constants,
637
                kind_map=kind_map,
638
                kind_allowlist=kind_allowlist,
639
                values=template_entry["values"],
640
            )
641
        except (FileNotFoundError, ValueError) as exc:
×
642
            raise click.ClickException(str(exc)) from exc
×
643

644

645
@cli.command("gen-fortran", context_settings=_CONTEXT_SETTINGS)
×
646
@click.option(
×
647
    "--config",
648
    "config_path",
649
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
650
    default="nml-config.toml",
651
    show_default=True,
652
)
653
def gen_fortran(config_path: Path) -> None:
×
654
    """Generate Fortran module(s)."""
655
    logger.info("Loading config from %s", config_path)
×
656
    config = _load_config_checked(config_path)
×
657
    base_dir = config_path.parent
×
658
    logger.debug("Base directory: %s", base_dir)
×
659
    helper_path, helper_module, helper_buffer, helper_header = _load_helper_settings(
×
660
        config,
661
        base_dir,
662
    )
663
    module_doc, _, _ = _load_documentation_settings(config)
×
664
    constants, constant_specs = _load_constants(config)
×
665
    kind_module, kind_map, kind_allowlist = _load_kind_settings(config)
×
NEW
666
    f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir)
×
667
    if helper_path is not None:
×
668
        try:
×
669
            logger.info("Generating helper module at %s", helper_path)
×
670
            generate_helper(
×
671
                helper_path,
672
                module_name=helper_module,
673
                len_buf=helper_buffer,
674
                constants=constant_specs,
675
                module_doc=module_doc,
676
                helper_header=helper_header,
677
            )
678
        except ValueError as exc:
×
679
            raise click.ClickException(str(exc)) from exc
×
680

681
    entries = _iter_namelists(config, base_dir)
×
682
    logger.info("Found %d schema entries", len(entries))
×
NEW
683
    loaded_entries: list[dict[str, Any]] = []
×
684
    for entry in entries:
×
685
        schema_path = entry["schema"]
×
686
        if schema_path is None:
×
687
            raise click.ClickException("namelists entry missing schema path")
×
688
        try:
×
689
            logger.info("Loading schema %s", schema_path)
×
690
            schema = load_schema(schema_path)
×
691
        except (FileNotFoundError, ValueError) as exc:
×
692
            raise click.ClickException(str(exc)) from exc
×
NEW
693
        loaded_entries.append({"entry": entry, "schema": schema})
×
694
        mod_path = entry["mod_path"]
×
695
        if mod_path is None:
×
696
            continue
×
697
        try:
×
698
            logger.info("Generating Fortran module at %s", mod_path)
×
699
            generate_fortran(
×
700
                schema,
701
                mod_path,
702
                helper_module=helper_module,
703
                kind_module=kind_module,
704
                kind_map=kind_map,
705
                kind_allowlist=kind_allowlist,
706
                constants=constants,
707
                module_doc=module_doc,
708
                f2py_handle_helpers=entry["f2py_path"] is not None,
709
            )
710
        except ValueError as exc:
×
711
            raise click.ClickException(str(exc)) from exc
×
712

NEW
713
    _generate_f2py_outputs(
×
714
        loaded_entries,
715
        helper_module=helper_module,
716
        helper_buffer=helper_buffer,
717
        kind_module=kind_module,
718
        kind_map=kind_map,
719
        kind_allowlist=kind_allowlist,
720
        constants=constants,
721
        f2cmap_path=f2cmap_path,
722
        f2py_c_types=f2py_c_types,
723
        include_python=False,
724
    )
725

726

727
@cli.command("validate", context_settings=_CONTEXT_SETTINGS)
×
728
@click.option(
×
729
    "--config",
730
    "config_path",
731
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
732
    default=None,
733
)
734
@click.option(
×
735
    "--schema",
736
    "schema_paths",
737
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
738
    multiple=True,
739
)
740
@click.option(
×
741
    "--input",
742
    "input_option",
743
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
744
)
745
@click.option(
×
746
    "--constants",
747
    "constant_args",
748
    multiple=True,
749
    help="Additional constants as NAME=VALUE (repeatable).",
750
)
751
@click.argument(
×
752
    "input_path",
753
    required=False,
754
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
755
)
756
def validate(
×
757
    config_path: Path | None,
758
    schema_paths: tuple[Path, ...],
759
    input_option: Path | None,
760
    constant_args: tuple[str, ...],
761
    input_path: Path | None,
762
) -> None:
763
    """Validate a namelist file against schema definitions."""
764
    if input_option is not None and input_path is not None:
×
765
        raise click.ClickException("input path provided twice")
×
766
    input_path = input_option or input_path
×
767
    if input_path is None:
×
768
        raise click.ClickException("input path is required")
×
769
    constants = _parse_cli_constants(constant_args)
×
770
    schemas: list[dict[str, Any]] = []
×
771

772
    if schema_paths:
×
773
        if config_path is not None:
×
774
            logger.info("Loading config from %s", config_path)
×
775
            config = _load_config_checked(config_path)
×
776
            cfg_constants, _ = _load_constants(config)
×
777
            constants = {**cfg_constants, **constants}
×
778
        for schema_file in schema_paths:
×
779
            try:
×
780
                logger.info("Loading schema %s", schema_file)
×
781
                schemas.append(load_schema(schema_file))
×
782
            except (FileNotFoundError, ValueError) as exc:
×
783
                raise click.ClickException(str(exc)) from exc
×
784
        require_all = True
×
785
    else:
786
        if config_path is None:
×
787
            config_path = Path("nml-config.toml")
×
788
        logger.info("Loading config from %s", config_path)
×
789
        config = _load_config_checked(config_path)
×
790
        base_dir = config_path.parent
×
791
        cfg_constants, _ = _load_constants(config)
×
792
        constants = {**cfg_constants, **constants}
×
793
        entries = _iter_namelists(config, base_dir)
×
794
        logger.info("Found %d schema entries", len(entries))
×
795
        for entry in entries:
×
796
            schema_path = entry["schema"]
×
797
            if schema_path is None:
×
798
                raise click.ClickException("namelists entry missing schema path")
×
799
            try:
×
800
                logger.info("Loading schema %s", schema_path)
×
801
                schemas.append(load_schema(schema_path))
×
802
            except (FileNotFoundError, ValueError) as exc:
×
803
                raise click.ClickException(str(exc)) from exc
×
804
        require_all = False
×
805

806
    if not schemas:
×
807
        raise click.ClickException("no schemas provided for validation")
×
808

809
    try:
×
810
        logger.info("Reading namelist %s", input_path)
×
811
        namelist_file = f90nml.read(input_path)
×
812
    except Exception as exc:  # pragma: no cover - f90nml raises custom errors
813
        raise click.ClickException(f"failed to read namelist: {exc}") from exc
814

815
    file_entries: dict[str, tuple[str, Any]] = {}
×
816
    for name, values in namelist_file.items():
×
817
        if not isinstance(name, str):
×
818
            raise click.ClickException("namelist names must be strings")
×
819
        key = name.lower()
×
820
        if key in file_entries:
×
821
            raise click.ClickException(f"namelist '{name}' appears multiple times")
×
822
        file_entries[key] = (name, values)
×
823

824
    schema_entries: dict[str, dict[str, Any]] = {}
×
825
    for schema in schemas:
×
826
        namelist_name = schema.get("x-fortran-namelist")
×
827
        if not isinstance(namelist_name, str) or not namelist_name.strip():
×
828
            raise click.ClickException("schema must define non-empty 'x-fortran-namelist'")
×
829
        key = namelist_name.lower()
×
830
        if key in schema_entries:
×
831
            raise click.ClickException(f"duplicate schema for namelist '{namelist_name}'")
×
832
        schema_entries[key] = schema
×
833

834
    for key, (name, _) in file_entries.items():
×
835
        if key not in schema_entries:
×
836
            raise click.ClickException(f"input contains unknown namelist '{name}'")
×
837

838
    validated = 0
×
839
    for key, schema in schema_entries.items():
×
840
        if key not in file_entries:
×
841
            if require_all:
×
842
                raise click.ClickException(
×
843
                    f"input is missing namelist '{schema.get('x-fortran-namelist')}'"
844
                )
845
            continue
×
846
        try:
×
847
            validate_namelist(
×
848
                schema,
849
                file_entries[key][1],
850
                constants=constants,
851
            )
852
        except ValueError as exc:
×
853
            raise click.ClickException(str(exc)) from exc
×
854
        validated += 1
×
855
    logger.info("Validation completed (%d namelist%s).", validated, "" if validated == 1 else "s")
×
856

857

858
@cli.command("gen-markdown", context_settings=_CONTEXT_SETTINGS)
×
859
@click.option(
×
860
    "--config",
861
    "config_path",
862
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
863
    default="nml-config.toml",
864
    show_default=True,
865
)
866
def gen_markdown(config_path: Path) -> None:
×
867
    """Generate Markdown docs."""
868
    logger.info("Loading config from %s", config_path)
×
869
    config = _load_config_checked(config_path)
×
870
    base_dir = config_path.parent
×
871
    constants, _ = _load_constants(config)
×
872
    _, md_doxygen_id_from_name, md_add_toc_statement = _load_documentation_settings(
×
873
        config
874
    )
875
    entries = _iter_namelists(config, base_dir)
×
876
    logger.info("Found %d schema entries", len(entries))
×
877
    for entry in entries:
×
878
        schema_path = entry["schema"]
×
879
        if schema_path is None:
×
880
            raise click.ClickException("namelists entry missing schema path")
×
881
        try:
×
882
            logger.info("Loading schema %s", schema_path)
×
883
            schema = load_schema(schema_path)
×
884
        except (FileNotFoundError, ValueError) as exc:
×
885
            raise click.ClickException(str(exc)) from exc
×
886
        doc_path = entry["doc_path"]
×
887
        if doc_path is None:
×
888
            continue
×
889
        try:
×
890
            logger.info("Generating Markdown docs at %s", doc_path)
×
891
            generate_docs(
×
892
                schema,
893
                doc_path,
894
                constants=constants,
895
                md_doxygen_id_from_name=md_doxygen_id_from_name,
896
                md_add_toc_statement=md_add_toc_statement,
897
            )
898
        except ValueError as exc:
×
899
            raise click.ClickException(str(exc)) from exc
×
900

901

902
@cli.command("gen-template", context_settings=_CONTEXT_SETTINGS)
×
903
@click.option(
×
904
    "--config",
905
    "config_path",
906
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
907
    default="nml-config.toml",
908
    show_default=True,
909
)
910
def gen_template(config_path: Path) -> None:
×
911
    """Generate template namelist(s)."""
912
    logger.info("Loading config from %s", config_path)
×
913
    config = _load_config_checked(config_path)
×
914
    base_dir = config_path.parent
×
915
    constants, _ = _load_constants(config)
×
916
    _, kind_map, kind_allowlist = _load_kind_settings(config)
×
917
    templates = _iter_templates(config, base_dir)
×
918
    if not templates:
×
919
        raise click.ClickException("config must define non-empty 'templates'")
×
920
    logger.info("Found %d template entries", len(templates))
×
921
    for entry in templates:
×
922
        try:
×
923
            schemas = []
×
924
            for schema_path in entry["schemas"]:
×
925
                logger.info("Loading schema %s", schema_path)
×
926
                schemas.append(load_schema(schema_path))
×
927
            logger.info("Generating template at %s", entry["output"])
×
928
            generate_template(
×
929
                schemas,
930
                entry["output"],
931
                doc_mode=entry["doc_mode"],
932
                value_mode=entry["value_mode"],
933
                constants=constants,
934
                kind_map=kind_map,
935
                kind_allowlist=kind_allowlist,
936
                values=entry["values"],
937
            )
938
        except (FileNotFoundError, ValueError) as exc:
×
939
            raise click.ClickException(str(exc)) from exc
×
940

941

942
def main(argv: list[str] | None = None) -> int:
×
943
    """Entry point for the CLI."""
944
    try:
×
945
        cli.main(args=argv, prog_name="nml-tools", standalone_mode=False)
×
946
    except Exit as exc:
×
947
        return exc.exit_code
×
948
    except click.ClickException as exc:
×
949
        exc.show()
×
950
        return 1
×
951
    return 0
×
952

953

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