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

MuellerSeb / nml-tools / 26084506336

19 May 2026 08:04AM UTC coverage: 57.68% (-0.9%) from 58.57%
26084506336

Pull #22

github

web-flow
Merge 586a0475c into 24fbc7064
Pull Request #22: Support pyproject.toml Configuration

0 of 59 new or added lines in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

1671 of 2897 relevant lines covered (57.68%)

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
×
13
import f90nml  # type: ignore
×
14
from click.exceptions import Exit
×
NEW
15
from packaging.version import InvalidVersion, Version
×
16

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

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

38
logger = logging.getLogger(__name__)
×
39
_FORTRAN_IDENTIFIER = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
×
40
_CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
×
NEW
41
_DEFAULT_CONFIG = Path("nml-config.toml")
×
NEW
42
_PYPROJECT_CONFIG = Path("pyproject.toml")
×
43

44

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

51

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

59

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

68

NEW
69
def _load_config_checked(path: Path | None) -> tuple[dict[str, Any], Path]:
×
NEW
70
    config_path = _resolve_config_path(path)
×
NEW
71
    raw_config = _load_toml_checked(config_path)
×
NEW
72
    if config_path.name == _PYPROJECT_CONFIG.name:
×
NEW
73
        config = _extract_pyproject_config(raw_config)
×
74
    else:
NEW
75
        config = raw_config
×
NEW
76
    _check_minimum_version(config)
×
NEW
77
    return config, config_path
×
78

79

NEW
80
def _resolve_config_path(path: Path | None) -> Path:
×
NEW
81
    if path is not None:
×
NEW
82
        return path
×
NEW
83
    if _DEFAULT_CONFIG.is_file():
×
NEW
84
        return _DEFAULT_CONFIG
×
NEW
85
    if _PYPROJECT_CONFIG.is_file():
×
NEW
86
        raw_config = _load_toml_checked(_PYPROJECT_CONFIG)
×
NEW
87
        if _has_pyproject_config(raw_config):
×
NEW
88
            return _PYPROJECT_CONFIG
×
NEW
89
    raise click.ClickException(
×
90
        "no config found; create nml-config.toml or add [tool.nml-tools] to pyproject.toml"
91
    )
92

93

NEW
94
def _has_pyproject_config(config: dict[str, Any]) -> bool:
×
NEW
95
    tool_raw = config.get("tool")
×
NEW
96
    return isinstance(tool_raw, dict) and isinstance(tool_raw.get("nml-tools"), dict)
×
97

98

NEW
99
def _extract_pyproject_config(config: dict[str, Any]) -> dict[str, Any]:
×
NEW
100
    tool_raw = config.get("tool")
×
NEW
101
    if not isinstance(tool_raw, dict):
×
NEW
102
        raise click.ClickException("pyproject.toml must define [tool.nml-tools]")
×
NEW
103
    nml_tools_raw = tool_raw.get("nml-tools")
×
NEW
104
    if not isinstance(nml_tools_raw, dict):
×
NEW
105
        raise click.ClickException("pyproject.toml must define [tool.nml-tools]")
×
NEW
106
    return nml_tools_raw
×
107

108

NEW
109
def _check_minimum_version(config: dict[str, Any]) -> None:
×
NEW
110
    minimum_raw = config.get("minimum-version")
×
NEW
111
    if minimum_raw is None:
×
NEW
112
        return
×
NEW
113
    if not isinstance(minimum_raw, str) or not minimum_raw.strip():
×
NEW
114
        raise click.ClickException("config 'minimum-version' must be a non-empty string")
×
NEW
115
    try:
×
NEW
116
        minimum = Version(minimum_raw.strip())
×
NEW
117
    except InvalidVersion as exc:
×
NEW
118
        raise click.ClickException(
×
119
            f"config 'minimum-version' is not a valid version: {minimum_raw}"
120
        ) from exc
NEW
121
    try:
×
NEW
122
        current = Version(__version__)
×
123
    except InvalidVersion as exc:  # pragma: no cover - package version should be valid
124
        raise click.ClickException(f"nml-tools version is not valid: {__version__}") from exc
NEW
125
    if current < minimum:
×
NEW
126
        raise click.ClickException(
×
127
            f"config requires nml-tools >= {minimum}, current version is {current}"
128
        )
129

130

UNCOV
131
def _resolve_optional_path(
×
132
    value: Any,
133
    *,
134
    base_dir: Path,
135
    key: str,
136
) -> Path | None:
137
    if value is None:
×
138
        return None
×
139
    if not isinstance(value, str):
×
140
        raise click.ClickException(f"config '{key}' must be a string")
×
141
    return base_dir / value
×
142

143

144
def _load_helper_settings(
×
145
    config: dict[str, Any],
146
    base_dir: Path,
147
) -> tuple[Path | None, str, int, str | None]:
148
    default_buffer = 1024
×
149
    helper_raw = config.get("helper")
×
150
    if helper_raw is None:
×
151
        helper_path = _resolve_optional_path(
×
152
            config.get("helper_path"),
153
            base_dir=base_dir,
154
            key="helper_path",
155
        )
156
        helper_module_raw = config.get("helper_module", "nml_helper")
×
157
        if not isinstance(helper_module_raw, str):
×
158
            raise click.ClickException("config 'helper_module' must be a string")
×
159
        helper_module = helper_module_raw.strip()
×
160
        if not helper_module:
×
161
            raise click.ClickException("config 'helper_module' must be a non-empty string")
×
162
        return helper_path, helper_module, default_buffer, None
×
163

164
    if not isinstance(helper_raw, dict):
×
165
        raise click.ClickException("config 'helper' must be a table")
×
166
    if "helper_path" in config or "helper_module" in config:
×
167
        logger.warning("config uses [helper]; ignoring legacy helper_path/helper_module")
×
168
    helper_path = _resolve_optional_path(
×
169
        helper_raw.get("path"),
170
        base_dir=base_dir,
171
        key="helper.path",
172
    )
173
    helper_module_raw = helper_raw.get("module", "nml_helper")
×
174
    if not isinstance(helper_module_raw, str):
×
175
        raise click.ClickException("config 'helper.module' must be a string")
×
176
    helper_module = helper_module_raw.strip()
×
177
    if not helper_module:
×
178
        raise click.ClickException("config 'helper.module' must be a non-empty string")
×
179
    header_raw = helper_raw.get("header")
×
180
    if header_raw is None:
×
181
        header = None
×
182
    else:
183
        if not isinstance(header_raw, str):
×
184
            raise click.ClickException("config 'helper.header' must be a string")
×
185
        header = header_raw.rstrip()
×
186
        if not header:
×
187
            header = None
×
188
    buffer_raw = helper_raw.get("buffer", default_buffer)
×
189
    if isinstance(buffer_raw, bool) or not isinstance(buffer_raw, int):
×
190
        raise click.ClickException("config 'helper.buffer' must be an integer")
×
191
    if buffer_raw <= 0:
×
192
        raise click.ClickException("config 'helper.buffer' must be positive")
×
193
    return helper_path, helper_module, buffer_raw, header
×
194

195

196
def _load_kind_settings(config: dict[str, Any]) -> tuple[str, dict[str, str], set[str]]:
×
197
    kinds_raw = config.get("kinds")
×
198
    if not isinstance(kinds_raw, dict):
×
199
        raise click.ClickException("config must define a [kinds] table")
×
200
    module_raw = kinds_raw.get("module")
×
201
    if not isinstance(module_raw, str) or not module_raw.strip():
×
202
        raise click.ClickException("config 'kinds.module' must be a non-empty string")
×
203
    module = module_raw.strip()
×
204

205
    map_raw = kinds_raw.get("map", {})
×
206
    if map_raw is None:
×
207
        map_raw = {}
×
208
    if not isinstance(map_raw, dict):
×
209
        raise click.ClickException("config 'kinds.map' must be a table")
×
210
    kind_map: dict[str, str] = {}
×
211
    for alias, target in map_raw.items():
×
212
        if not isinstance(alias, str) or not isinstance(target, str):
×
213
            raise click.ClickException("config 'kinds.map' keys and values must be strings")
×
214
        kind_map[alias] = target
×
215

216
    real_raw = kinds_raw.get("real", [])
×
217
    integer_raw = kinds_raw.get("integer", [])
×
218
    if not isinstance(real_raw, list) or not all(isinstance(item, str) for item in real_raw):
×
219
        raise click.ClickException("config 'kinds.real' must be a list of strings")
×
220
    if not isinstance(integer_raw, list) or not all(isinstance(item, str) for item in integer_raw):
×
221
        raise click.ClickException("config 'kinds.integer' must be a list of strings")
×
222
    allowlist = set(real_raw) | set(integer_raw)
×
223

224
    return module, kind_map, allowlist
×
225

226

227
def _load_f2py_settings(
×
228
    config: dict[str, Any],
229
    base_dir: Path,
230
) -> tuple[Path | None, F2pyCTypeMap]:
231
    f2py_raw = config.get("f2py")
×
232
    if f2py_raw is None:
×
233
        return None, F2pyCTypeMap(real={}, integer={})
×
234
    if not isinstance(f2py_raw, dict):
×
235
        raise click.ClickException("config 'f2py' must be a table")
×
236
    if "python_package" in f2py_raw:
×
237
        raise click.ClickException(
×
238
            "config 'f2py.python_package' is no longer supported; "
239
            "place the f2py extension next to the generated Python wrapper"
240
        )
241

242
    f2cmap_path = _resolve_optional_path(
×
243
        f2py_raw.get("f2cmap_path"),
244
        base_dir=base_dir,
245
        key="f2py.f2cmap_path",
246
    )
247

248
    c_types_raw = f2py_raw.get("c_types", {})
×
249
    if c_types_raw is None:
×
250
        c_types_raw = {}
×
251
    if not isinstance(c_types_raw, dict):
×
252
        raise click.ClickException("config 'f2py.c_types' must be a table")
×
253

254
    real = _load_f2py_ctype_table(c_types_raw, "real")
×
255
    integer = _load_f2py_ctype_table(c_types_raw, "integer")
×
256
    return f2cmap_path, F2pyCTypeMap(real=real, integer=integer)
×
257

258

259
def _load_f2py_ctype_table(c_types: dict[str, Any], key: str) -> dict[str, str]:
×
260
    raw = c_types.get(key, {})
×
261
    if raw is None:
×
262
        raw = {}
×
263
    if not isinstance(raw, dict):
×
264
        raise click.ClickException(f"config 'f2py.c_types.{key}' must be a table")
×
265
    values: dict[str, str] = {}
×
266
    for kind, c_type in raw.items():
×
267
        if not isinstance(kind, str) or not kind.strip():
×
268
            raise click.ClickException(
×
269
                f"config 'f2py.c_types.{key}' keys must be non-empty strings"
270
            )
271
        if not isinstance(c_type, str) or not c_type.strip():
×
272
            raise click.ClickException(
×
273
                f"config 'f2py.c_types.{key}.{kind}' must be a non-empty string"
274
            )
275
        values[kind.strip()] = c_type.strip()
×
276
    return values
×
277

278

279
def _format_constant_literal(value: int | float) -> tuple[str, str]:
×
280
    if isinstance(value, bool):
×
281
        raise click.ClickException("config constants must not be boolean")
×
282
    if isinstance(value, int):
×
283
        return "integer", str(value)
×
284
    if isinstance(value, float):
×
285
        literal = repr(float(value))
×
286
        if literal.lower() == "nan":
×
287
            raise click.ClickException("config constants must not be NaN")
×
288
        if "." not in literal and "e" not in literal and "E" not in literal:
×
289
            literal = f"{literal}.0"
×
290
        return "real", literal
×
291
    raise click.ClickException("config constants must be integers or reals")
×
292

293

294
def _load_constants(config: dict[str, Any]) -> tuple[dict[str, int | float], list[ConstantSpec]]:
×
295
    constants_raw = config.get("constants", {})
×
296
    if constants_raw is None:
×
297
        constants_raw = {}
×
298
    if not isinstance(constants_raw, dict):
×
299
        raise click.ClickException("config 'constants' must be a table")
×
300

301
    constants: dict[str, int | float] = {}
×
302
    specs: list[ConstantSpec] = []
×
303
    for name_raw, entry in constants_raw.items():
×
304
        if not isinstance(name_raw, str):
×
305
            raise click.ClickException("config constants must use string keys")
×
306
        name = name_raw.strip()
×
307
        if not name:
×
308
            raise click.ClickException("config constants must have non-empty names")
×
309
        if not _FORTRAN_IDENTIFIER.match(name):
×
310
            raise click.ClickException(
×
311
                f"config constant '{name}' must be a valid Fortran identifier"
312
            )
313
        if not isinstance(entry, dict):
×
314
            raise click.ClickException(f"config constant '{name}' must be a table with 'value'")
×
315
        if "value" not in entry:
×
316
            raise click.ClickException(f"config constant '{name}' must define 'value'")
×
317
        value = entry.get("value")
×
318
        if not isinstance(value, (int, float)):
×
319
            raise click.ClickException("config constants must be integers or reals")
×
320
        type_spec, literal = _format_constant_literal(value)
×
321
        doc = entry.get("doc")
×
322
        if doc is not None:
×
323
            if not isinstance(doc, str):
×
324
                raise click.ClickException(f"config constant '{name}' doc must be a string")
×
325
            doc = " ".join(doc.splitlines()).strip() or None
×
326
        specs.append(
×
327
            ConstantSpec(
328
                name=name,
329
                type_spec=type_spec,
330
                value=literal,
331
                doc=doc,
332
            )
333
        )
334
        constants[name] = value
×
335
    return constants, specs
×
336

337

338
def _load_bool_field(section: dict[str, Any], key: str, *, label: str) -> bool:
×
339
    value = section.get(key, False)
×
340
    if isinstance(value, bool):
×
341
        return value
×
342
    raise click.ClickException(f"config '{label}' must be a boolean")
×
343

344

345
def _load_documentation_settings(
×
346
    config: dict[str, Any],
347
) -> tuple[str | None, bool, bool, str]:
348
    doc_raw = config.get("documentation")
×
349
    if doc_raw is None:
×
350
        return None, False, False, "numpy"
×
351
    if not isinstance(doc_raw, dict):
×
352
        raise click.ClickException("config 'documentation' must be a table")
×
353
    module_doc = None
×
354
    if "module" in doc_raw:
×
355
        module_raw = doc_raw.get("module")
×
356
        if not isinstance(module_raw, str):
×
357
            raise click.ClickException("config 'documentation.module' must be a string")
×
358
        module_doc = module_raw.strip() or None
×
359
    md_doxygen_id_from_name = _load_bool_field(
×
360
        doc_raw,
361
        "md_doxygen_id_from_name",
362
        label="documentation.md_doxygen_id_from_name",
363
    )
364
    md_add_toc_statement = _load_bool_field(
×
365
        doc_raw,
366
        "md_add_toc_statement",
367
        label="documentation.md_add_toc_statement",
368
    )
369
    py_style_raw = doc_raw.get("py-style", "numpy")
×
370
    if not isinstance(py_style_raw, str):
×
371
        raise click.ClickException("config 'documentation.py-style' must be a string")
×
372
    py_style = py_style_raw.strip().lower()
×
373
    if py_style not in {"numpy", "doxygen"}:
×
374
        raise click.ClickException(
×
375
            "config 'documentation.py-style' must be 'numpy' or 'doxygen'"
376
        )
377
    return module_doc, md_doxygen_id_from_name, md_add_toc_statement, py_style
×
378

379

380
def _parse_cli_constants(values: tuple[str, ...]) -> dict[str, int | float]:
×
381
    constants: dict[str, int | float] = {}
×
382
    for entry in values:
×
383
        if "=" not in entry:
×
384
            raise click.ClickException("constants must be provided as NAME=VALUE")
×
385
        raw_name, raw_value = entry.split("=", 1)
×
386
        name = raw_name.strip()
×
387
        if not name:
×
388
            raise click.ClickException("constants must use non-empty names")
×
389
        if not _FORTRAN_IDENTIFIER.match(name):
×
390
            raise click.ClickException(f"constant '{name}' must be a valid identifier")
×
391
        value_text = raw_value.strip()
×
392
        if not value_text:
×
393
            raise click.ClickException(f"constant '{name}' must define a value")
×
394
        if value_text.lower() in {"nan", "+nan", "-nan", "inf", "+inf", "-inf"}:
×
395
            raise click.ClickException(f"constant '{name}' must be finite")
×
396
        if re.fullmatch(r"[+-]?\d+", value_text):
×
397
            value: int | float = int(value_text)
×
398
        else:
399
            try:
×
400
                value = float(value_text)
×
401
            except ValueError as exc:
×
402
                raise click.ClickException(
×
403
                    f"constant '{name}' value '{value_text}' must be numeric"
404
                ) from exc
405
            if math.isinf(value) or math.isnan(value):
×
406
                raise click.ClickException(f"constant '{name}' must be finite")
×
407
        constants[name] = value
×
408
    return constants
×
409

410

411
def _iter_namelists(config: dict[str, Any], base_dir: Path) -> list[dict[str, Any]]:
×
412
    raw_entries = config.get("namelists")
×
413
    if raw_entries is None and "nml-files" in config:
×
414
        raise click.ClickException("config uses deprecated 'nml-files'; rename to 'namelists'")
×
415
    if not isinstance(raw_entries, list) or not raw_entries:
×
416
        raise click.ClickException("config must define non-empty 'namelists'")
×
417

418
    entries: list[dict[str, Path | None]] = []
×
419
    for entry in raw_entries:
×
420
        if not isinstance(entry, dict):
×
421
            raise click.ClickException("each namelists entry must be a table")
×
422
        schema_raw = entry.get("schema")
×
423
        if not isinstance(schema_raw, str):
×
424
            raise click.ClickException("namelists entry must define string 'schema'")
×
425
        schema_path = base_dir / schema_raw
×
426
        f2py_path = _resolve_optional_path(
×
427
            entry.get("f2py_path"),
428
            base_dir=base_dir,
429
            key="f2py_path",
430
        )
431
        py_path = _resolve_optional_path(
×
432
            entry.get("py_path"),
433
            base_dir=base_dir,
434
            key="py_path",
435
        )
436
        mod_path = _resolve_optional_path(
×
437
            entry.get("mod_path"),
438
            base_dir=base_dir,
439
            key="mod_path",
440
        )
441
        if f2py_path is not None and mod_path is None:
×
442
            raise click.ClickException(
×
443
                "namelists entry with 'f2py_path' must define 'mod_path'"
444
            )
445
        if py_path is not None and f2py_path is None:
×
446
            raise click.ClickException("namelists entry with 'py_path' must define 'f2py_path'")
×
447
        entries.append(
×
448
            {
449
                "schema": schema_path,
450
                "mod_path": mod_path,
451
                "doc_path": _resolve_optional_path(
452
                    entry.get("doc_path"),
453
                    base_dir=base_dir,
454
                    key="doc_path",
455
                ),
456
                "temp_path": _resolve_optional_path(
457
                    entry.get("temp_path"),
458
                    base_dir=base_dir,
459
                    key="temp_path",
460
                ),
461
                "f2py_path": f2py_path,
462
                "py_path": py_path,
463
            }
464
        )
465
    return entries
×
466

467

468
def _iter_templates(config: dict[str, Any], base_dir: Path) -> list[dict[str, Any]]:
×
469
    raw_entries = config.get("templates")
×
470
    if raw_entries is None:
×
471
        return []
×
472
    if not isinstance(raw_entries, list) or not raw_entries:
×
473
        raise click.ClickException("config 'templates' must be a non-empty list")
×
474

475
    entries: list[dict[str, Any]] = []
×
476
    for entry in raw_entries:
×
477
        if not isinstance(entry, dict):
×
478
            raise click.ClickException("each templates entry must be a table")
×
479
        output_raw = entry.get("output")
×
480
        if not isinstance(output_raw, str):
×
481
            raise click.ClickException("templates entry must define string 'output'")
×
482
        output_path = base_dir / output_raw
×
483

484
        schemas_raw = entry.get("schemas")
×
485
        if not isinstance(schemas_raw, list) or not schemas_raw:
×
486
            raise click.ClickException("templates entry must define non-empty 'schemas'")
×
487
        if not all(isinstance(item, str) for item in schemas_raw):
×
488
            raise click.ClickException("templates 'schemas' entries must be strings")
×
489
        schema_paths = [base_dir / item for item in schemas_raw]
×
490

491
        doc_mode = entry.get("doc_mode", "plain")
×
492
        if not isinstance(doc_mode, str):
×
493
            raise click.ClickException("templates 'doc_mode' must be a string")
×
494
        value_mode = entry.get("value_mode", "empty")
×
495
        if not isinstance(value_mode, str):
×
496
            raise click.ClickException("templates 'value_mode' must be a string")
×
497
        values_raw = entry.get("values", {})
×
498
        if values_raw is None:
×
499
            values_raw = {}
×
500
        if not isinstance(values_raw, dict):
×
501
            raise click.ClickException("templates 'values' must be a table")
×
502

503
        entries.append(
×
504
            {
505
                "output": output_path,
506
                "schemas": schema_paths,
507
                "doc_mode": doc_mode,
508
                "value_mode": value_mode,
509
                "values": values_raw,
510
            }
511
        )
512
    return entries
×
513

514

515
def _generate_f2py_outputs(
×
516
    loaded_entries: list[dict[str, Any]],
517
    *,
518
    helper_module: str,
519
    helper_buffer: int,
520
    kind_module: str,
521
    kind_map: dict[str, str],
522
    kind_allowlist: set[str],
523
    constants: dict[str, int | float],
524
    f2cmap_path: Path | None,
525
    f2py_c_types: F2pyCTypeMap,
526
    py_style: str,
527
    include_python: bool,
528
) -> None:
529
    f2py_groups: dict[Path, list[dict[str, Any]]] = {}
×
530
    py_groups: dict[Path, list[tuple[dict[str, Any], Path]]] = {}
×
531
    for loaded in loaded_entries:
×
532
        entry = loaded["entry"]
×
533
        schema = loaded["schema"]
×
534
        f2py_path = entry["f2py_path"]
×
535
        if f2py_path is None:
×
536
            continue
×
537
        f2py_groups.setdefault(f2py_path, []).append(schema)
×
538
        py_path = entry["py_path"]
×
539
        if include_python and py_path is not None:
×
540
            py_groups.setdefault(py_path, []).append((schema, f2py_path))
×
541

542
    for f2py_path, schemas in f2py_groups.items():
×
543
        try:
×
544
            logger.info("Generating f2py wrappers at %s", f2py_path)
×
545
            generate_f2py_wrappers(
×
546
                schemas,
547
                f2py_path,
548
                helper_module=helper_module,
549
                kind_module=kind_module,
550
                kind_map=kind_map,
551
                kind_allowlist=kind_allowlist,
552
                constants=constants,
553
                errmsg_len=helper_buffer,
554
            )
555
        except ValueError as exc:
×
556
            raise click.ClickException(str(exc)) from exc
×
557

558
    if f2cmap_path is not None:
×
559
        try:
×
560
            usage = merge_f2py_kind_usage(
×
561
                collect_f2py_kind_usage(schemas, constants=constants)
562
                for schemas in f2py_groups.values()
563
            )
564
            logger.info("Generating f2py kind map at %s", f2cmap_path)
×
565
            generate_f2cmap(f2cmap_path, usage, f2py_c_types)
×
566
        except ValueError as exc:
×
567
            raise click.ClickException(str(exc)) from exc
×
568

569
    if not include_python:
×
570
        return
×
571
    for py_path, entries in py_groups.items():
×
572
        try:
×
573
            logger.info("Generating Python f2py wrappers at %s", py_path)
×
574
            specs = [
×
575
                (
576
                    build_f2py_namelist_spec(
577
                        schema,
578
                        helper_module=helper_module,
579
                        kind_module=kind_module,
580
                        kind_map=kind_map,
581
                        kind_allowlist=kind_allowlist,
582
                        constants=constants,
583
                        errmsg_len=helper_buffer,
584
                    ),
585
                    f2py_path.stem,
586
                )
587
                for schema, f2py_path in entries
588
            ]
589
            generate_python_wrappers(
×
590
                specs,
591
                py_path,
592
                py_style=py_style,
593
            )
594
        except ValueError as exc:
×
595
            raise click.ClickException(str(exc)) from exc
×
596

597

598
@click.group(context_settings=_CONTEXT_SETTINGS)
×
599
@click.version_option(__version__, "-V", "--version", prog_name="nml-tools")
×
600
@click.option("--verbose", "-v", count=True, help="Increase verbosity (repeatable).")
×
601
@click.option("--quiet", "-q", count=True, help="Decrease verbosity (repeatable).")
×
602
def cli(verbose: int, quiet: int) -> None:
×
603
    """nml-tools command line interface."""
604
    _configure_logging(verbose, quiet)
×
605

606

607
@cli.command("generate", context_settings=_CONTEXT_SETTINGS)
×
608
@click.option(
×
609
    "--config",
610
    "config_path",
611
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
612
    default=None,
613
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
614
)
NEW
615
def generate(config_path: Path | None) -> None:
×
616
    """Generate outputs from a configuration file."""
NEW
617
    config, config_path = _load_config_checked(config_path)
×
618
    logger.info("Loading config from %s", config_path)
×
619
    base_dir = config_path.parent
×
620
    logger.debug("Base directory: %s", base_dir)
×
621
    helper_path, helper_module, helper_buffer, helper_header = _load_helper_settings(
×
622
        config,
623
        base_dir,
624
    )
625
    (
×
626
        module_doc,
627
        md_doxygen_id_from_name,
628
        md_add_toc_statement,
629
        py_style,
630
    ) = _load_documentation_settings(config)
631
    constants, constant_specs = _load_constants(config)
×
632
    kind_module, kind_map, kind_allowlist = _load_kind_settings(config)
×
633
    f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir)
×
634
    if helper_path is not None:
×
635
        try:
×
636
            logger.info("Generating helper module at %s", helper_path)
×
637
            generate_helper(
×
638
                helper_path,
639
                module_name=helper_module,
640
                len_buf=helper_buffer,
641
                constants=constant_specs,
642
                module_doc=module_doc,
643
                helper_header=helper_header,
644
            )
645
        except ValueError as exc:
×
646
            raise click.ClickException(str(exc)) from exc
×
647
    entries = _iter_namelists(config, base_dir)
×
648
    logger.info("Found %d schema entries", len(entries))
×
649
    loaded_entries: list[dict[str, Any]] = []
×
650
    for namelist_entry in entries:
×
651
        schema_path = namelist_entry["schema"]
×
652
        if schema_path is None:
×
653
            raise click.ClickException("namelists entry missing schema path")
×
654
        try:
×
655
            logger.info("Loading schema %s", schema_path)
×
656
            schema = load_schema(schema_path)
×
657
        except (FileNotFoundError, ValueError) as exc:
×
658
            raise click.ClickException(str(exc)) from exc
×
659
        loaded_entries.append({"entry": namelist_entry, "schema": schema})
×
660
        mod_path = namelist_entry["mod_path"]
×
661
        if mod_path is not None:
×
662
            try:
×
663
                logger.info("Generating Fortran module at %s", mod_path)
×
664
                generate_fortran(
×
665
                    schema,
666
                    mod_path,
667
                    helper_module=helper_module,
668
                    kind_module=kind_module,
669
                    kind_map=kind_map,
670
                    kind_allowlist=kind_allowlist,
671
                    constants=constants,
672
                    module_doc=module_doc,
673
                    f2py_handle_helpers=namelist_entry["f2py_path"] is not None,
674
                )
675
            except ValueError as exc:
×
676
                raise click.ClickException(str(exc)) from exc
×
677
        doc_path = namelist_entry["doc_path"]
×
678
        if doc_path is not None:
×
679
            try:
×
680
                logger.info("Generating Markdown docs at %s", doc_path)
×
681
                generate_docs(
×
682
                    schema,
683
                    doc_path,
684
                    constants=constants,
685
                    md_doxygen_id_from_name=md_doxygen_id_from_name,
686
                    md_add_toc_statement=md_add_toc_statement,
687
                )
688
            except ValueError as exc:
×
689
                raise click.ClickException(str(exc)) from exc
×
690

691
    _generate_f2py_outputs(
×
692
        loaded_entries,
693
        helper_module=helper_module,
694
        helper_buffer=helper_buffer,
695
        kind_module=kind_module,
696
        kind_map=kind_map,
697
        kind_allowlist=kind_allowlist,
698
        constants=constants,
699
        f2cmap_path=f2cmap_path,
700
        f2py_c_types=f2py_c_types,
701
        py_style=py_style,
702
        include_python=True,
703
    )
704

705
    template_entries = _iter_templates(config, base_dir)
×
706
    if template_entries:
×
707
        logger.info("Found %d template entries", len(template_entries))
×
708
    for template_entry in template_entries:
×
709
        try:
×
710
            schemas = []
×
711
            for schema_path in template_entry["schemas"]:
×
712
                logger.info("Loading schema %s", schema_path)
×
713
                schemas.append(load_schema(schema_path))
×
714
            logger.info("Generating template at %s", template_entry["output"])
×
715
            generate_template(
×
716
                schemas,
717
                template_entry["output"],
718
                doc_mode=template_entry["doc_mode"],
719
                value_mode=template_entry["value_mode"],
720
                constants=constants,
721
                kind_map=kind_map,
722
                kind_allowlist=kind_allowlist,
723
                values=template_entry["values"],
724
            )
725
        except (FileNotFoundError, ValueError) as exc:
×
726
            raise click.ClickException(str(exc)) from exc
×
727

728

729
@cli.command("gen-fortran", context_settings=_CONTEXT_SETTINGS)
×
730
@click.option(
×
731
    "--config",
732
    "config_path",
733
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
734
    default=None,
735
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
736
)
NEW
737
def gen_fortran(config_path: Path | None) -> None:
×
738
    """Generate Fortran module(s)."""
NEW
739
    config, config_path = _load_config_checked(config_path)
×
740
    logger.info("Loading config from %s", config_path)
×
741
    base_dir = config_path.parent
×
742
    logger.debug("Base directory: %s", base_dir)
×
743
    helper_path, helper_module, helper_buffer, helper_header = _load_helper_settings(
×
744
        config,
745
        base_dir,
746
    )
747
    module_doc, _, _, py_style = _load_documentation_settings(config)
×
748
    constants, constant_specs = _load_constants(config)
×
749
    kind_module, kind_map, kind_allowlist = _load_kind_settings(config)
×
750
    f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir)
×
751
    if helper_path is not None:
×
752
        try:
×
753
            logger.info("Generating helper module at %s", helper_path)
×
754
            generate_helper(
×
755
                helper_path,
756
                module_name=helper_module,
757
                len_buf=helper_buffer,
758
                constants=constant_specs,
759
                module_doc=module_doc,
760
                helper_header=helper_header,
761
            )
762
        except ValueError as exc:
×
763
            raise click.ClickException(str(exc)) from exc
×
764

765
    entries = _iter_namelists(config, base_dir)
×
766
    logger.info("Found %d schema entries", len(entries))
×
767
    loaded_entries: list[dict[str, Any]] = []
×
768
    for entry in entries:
×
769
        schema_path = entry["schema"]
×
770
        if schema_path is None:
×
771
            raise click.ClickException("namelists entry missing schema path")
×
772
        try:
×
773
            logger.info("Loading schema %s", schema_path)
×
774
            schema = load_schema(schema_path)
×
775
        except (FileNotFoundError, ValueError) as exc:
×
776
            raise click.ClickException(str(exc)) from exc
×
777
        loaded_entries.append({"entry": entry, "schema": schema})
×
778
        mod_path = entry["mod_path"]
×
779
        if mod_path is None:
×
780
            continue
×
781
        try:
×
782
            logger.info("Generating Fortran module at %s", mod_path)
×
783
            generate_fortran(
×
784
                schema,
785
                mod_path,
786
                helper_module=helper_module,
787
                kind_module=kind_module,
788
                kind_map=kind_map,
789
                kind_allowlist=kind_allowlist,
790
                constants=constants,
791
                module_doc=module_doc,
792
                f2py_handle_helpers=entry["f2py_path"] is not None,
793
            )
794
        except ValueError as exc:
×
795
            raise click.ClickException(str(exc)) from exc
×
796

797
    _generate_f2py_outputs(
×
798
        loaded_entries,
799
        helper_module=helper_module,
800
        helper_buffer=helper_buffer,
801
        kind_module=kind_module,
802
        kind_map=kind_map,
803
        kind_allowlist=kind_allowlist,
804
        constants=constants,
805
        f2cmap_path=f2cmap_path,
806
        f2py_c_types=f2py_c_types,
807
        py_style=py_style,
808
        include_python=False,
809
    )
810

811

812
@cli.command("validate", context_settings=_CONTEXT_SETTINGS)
×
813
@click.option(
×
814
    "--config",
815
    "config_path",
816
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
817
    default=None,
818
)
819
@click.option(
×
820
    "--schema",
821
    "schema_paths",
822
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
823
    multiple=True,
824
)
825
@click.option(
×
826
    "--input",
827
    "input_option",
828
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
829
)
830
@click.option(
×
831
    "--constants",
832
    "constant_args",
833
    multiple=True,
834
    help="Additional constants as NAME=VALUE (repeatable).",
835
)
836
@click.argument(
×
837
    "input_path",
838
    required=False,
839
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
840
)
841
def validate(
×
842
    config_path: Path | None,
843
    schema_paths: tuple[Path, ...],
844
    input_option: Path | None,
845
    constant_args: tuple[str, ...],
846
    input_path: Path | None,
847
) -> None:
848
    """Validate a namelist file against schema definitions."""
849
    if input_option is not None and input_path is not None:
×
850
        raise click.ClickException("input path provided twice")
×
851
    input_path = input_option or input_path
×
852
    if input_path is None:
×
853
        raise click.ClickException("input path is required")
×
854
    constants = _parse_cli_constants(constant_args)
×
855
    schemas: list[dict[str, Any]] = []
×
856

857
    if schema_paths:
×
858
        if config_path is not None:
×
NEW
859
            config, config_path = _load_config_checked(config_path)
×
860
            logger.info("Loading config from %s", config_path)
×
861
            cfg_constants, _ = _load_constants(config)
×
862
            constants = {**cfg_constants, **constants}
×
863
        for schema_file in schema_paths:
×
864
            try:
×
865
                logger.info("Loading schema %s", schema_file)
×
866
                schemas.append(load_schema(schema_file))
×
867
            except (FileNotFoundError, ValueError) as exc:
×
868
                raise click.ClickException(str(exc)) from exc
×
869
        require_all = True
×
870
    else:
NEW
871
        config, config_path = _load_config_checked(config_path)
×
872
        logger.info("Loading config from %s", config_path)
×
873
        base_dir = config_path.parent
×
874
        cfg_constants, _ = _load_constants(config)
×
875
        constants = {**cfg_constants, **constants}
×
876
        entries = _iter_namelists(config, base_dir)
×
877
        logger.info("Found %d schema entries", len(entries))
×
878
        for entry in entries:
×
879
            schema_path = entry["schema"]
×
880
            if schema_path is None:
×
881
                raise click.ClickException("namelists entry missing schema path")
×
882
            try:
×
883
                logger.info("Loading schema %s", schema_path)
×
884
                schemas.append(load_schema(schema_path))
×
885
            except (FileNotFoundError, ValueError) as exc:
×
886
                raise click.ClickException(str(exc)) from exc
×
887
        require_all = False
×
888

889
    if not schemas:
×
890
        raise click.ClickException("no schemas provided for validation")
×
891

892
    try:
×
893
        logger.info("Reading namelist %s", input_path)
×
894
        namelist_file = f90nml.read(input_path)
×
895
    except Exception as exc:  # pragma: no cover - f90nml raises custom errors
896
        raise click.ClickException(f"failed to read namelist: {exc}") from exc
897

898
    file_entries: dict[str, tuple[str, Any]] = {}
×
899
    for name, values in namelist_file.items():
×
900
        if not isinstance(name, str):
×
901
            raise click.ClickException("namelist names must be strings")
×
902
        key = name.lower()
×
903
        if key in file_entries:
×
904
            raise click.ClickException(f"namelist '{name}' appears multiple times")
×
905
        file_entries[key] = (name, values)
×
906

907
    schema_entries: dict[str, dict[str, Any]] = {}
×
908
    for schema in schemas:
×
909
        namelist_name = schema.get("x-fortran-namelist")
×
910
        if not isinstance(namelist_name, str) or not namelist_name.strip():
×
911
            raise click.ClickException("schema must define non-empty 'x-fortran-namelist'")
×
912
        key = namelist_name.lower()
×
913
        if key in schema_entries:
×
914
            raise click.ClickException(f"duplicate schema for namelist '{namelist_name}'")
×
915
        schema_entries[key] = schema
×
916

917
    for key, (name, _) in file_entries.items():
×
918
        if key not in schema_entries:
×
919
            raise click.ClickException(f"input contains unknown namelist '{name}'")
×
920

921
    validated = 0
×
922
    for key, schema in schema_entries.items():
×
923
        if key not in file_entries:
×
924
            if require_all:
×
925
                raise click.ClickException(
×
926
                    f"input is missing namelist '{schema.get('x-fortran-namelist')}'"
927
                )
928
            continue
×
929
        try:
×
930
            validate_namelist(
×
931
                schema,
932
                file_entries[key][1],
933
                constants=constants,
934
            )
935
        except ValueError as exc:
×
936
            raise click.ClickException(str(exc)) from exc
×
937
        validated += 1
×
938
    logger.info("Validation completed (%d namelist%s).", validated, "" if validated == 1 else "s")
×
939

940

941
@cli.command("gen-markdown", context_settings=_CONTEXT_SETTINGS)
×
942
@click.option(
×
943
    "--config",
944
    "config_path",
945
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
946
    default=None,
947
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
948
)
NEW
949
def gen_markdown(config_path: Path | None) -> None:
×
950
    """Generate Markdown docs."""
NEW
951
    config, config_path = _load_config_checked(config_path)
×
952
    logger.info("Loading config from %s", config_path)
×
953
    base_dir = config_path.parent
×
954
    constants, _ = _load_constants(config)
×
955
    _, md_doxygen_id_from_name, md_add_toc_statement, _ = _load_documentation_settings(
×
956
        config
957
    )
958
    entries = _iter_namelists(config, base_dir)
×
959
    logger.info("Found %d schema entries", len(entries))
×
960
    for entry in entries:
×
961
        schema_path = entry["schema"]
×
962
        if schema_path is None:
×
963
            raise click.ClickException("namelists entry missing schema path")
×
964
        try:
×
965
            logger.info("Loading schema %s", schema_path)
×
966
            schema = load_schema(schema_path)
×
967
        except (FileNotFoundError, ValueError) as exc:
×
968
            raise click.ClickException(str(exc)) from exc
×
969
        doc_path = entry["doc_path"]
×
970
        if doc_path is None:
×
971
            continue
×
972
        try:
×
973
            logger.info("Generating Markdown docs at %s", doc_path)
×
974
            generate_docs(
×
975
                schema,
976
                doc_path,
977
                constants=constants,
978
                md_doxygen_id_from_name=md_doxygen_id_from_name,
979
                md_add_toc_statement=md_add_toc_statement,
980
            )
981
        except ValueError as exc:
×
982
            raise click.ClickException(str(exc)) from exc
×
983

984

985
@cli.command("gen-template", context_settings=_CONTEXT_SETTINGS)
×
986
@click.option(
×
987
    "--config",
988
    "config_path",
989
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
990
    default=None,
991
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
992
)
NEW
993
def gen_template(config_path: Path | None) -> None:
×
994
    """Generate template namelist(s)."""
NEW
995
    config, config_path = _load_config_checked(config_path)
×
996
    logger.info("Loading config from %s", config_path)
×
997
    base_dir = config_path.parent
×
998
    constants, _ = _load_constants(config)
×
999
    _, kind_map, kind_allowlist = _load_kind_settings(config)
×
1000
    templates = _iter_templates(config, base_dir)
×
1001
    if not templates:
×
1002
        raise click.ClickException("config must define non-empty 'templates'")
×
1003
    logger.info("Found %d template entries", len(templates))
×
1004
    for entry in templates:
×
1005
        try:
×
1006
            schemas = []
×
1007
            for schema_path in entry["schemas"]:
×
1008
                logger.info("Loading schema %s", schema_path)
×
1009
                schemas.append(load_schema(schema_path))
×
1010
            logger.info("Generating template at %s", entry["output"])
×
1011
            generate_template(
×
1012
                schemas,
1013
                entry["output"],
1014
                doc_mode=entry["doc_mode"],
1015
                value_mode=entry["value_mode"],
1016
                constants=constants,
1017
                kind_map=kind_map,
1018
                kind_allowlist=kind_allowlist,
1019
                values=entry["values"],
1020
            )
1021
        except (FileNotFoundError, ValueError) as exc:
×
1022
            raise click.ClickException(str(exc)) from exc
×
1023

1024

1025
def main(argv: list[str] | None = None) -> int:
×
1026
    """Entry point for the CLI."""
1027
    try:
×
1028
        cli.main(args=argv, prog_name="nml-tools", standalone_mode=False)
×
1029
    except Exit as exc:
×
1030
        return exc.exit_code
×
1031
    except click.ClickException as exc:
×
1032
        exc.show()
×
1033
        return 1
×
1034
    return 0
×
1035

1036

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