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

MuellerSeb / nml-tools / 26193108022

20 May 2026 10:13PM UTC coverage: 79.154% (+4.8%) from 74.315%
26193108022

Pull #27

github

MuellerSeb
Use trailing underscores for generated bridge internals
Pull Request #27: Add Runtime Array Dimensions

443 of 489 new or added lines in 7 files covered. (90.59%)

3 existing lines in 2 files now uncovered.

2582 of 3262 relevant lines covered (79.15%)

0.79 hits per line

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

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

3
from __future__ import annotations
1✔
4

5
import logging
1✔
6
import sys
1✔
7
from dataclasses import dataclass
1✔
8
from difflib import unified_diff
1✔
9
from pathlib import Path
1✔
10
from typing import Any, cast
1✔
11

12
import click
1✔
13
import f90nml  # type: ignore
1✔
14
from click.exceptions import Exit
1✔
15
from packaging.version import InvalidVersion, Version
1✔
16

17
from ._utils import constant_dimension_overlap, is_fortran_identifier
1✔
18
from ._version import __version__
1✔
19
from .codegen_f2py import (
1✔
20
    F2pyCTypeMap,
21
    build_f2py_namelist_spec,
22
    collect_f2py_kind_usage,
23
    merge_f2py_kind_usage,
24
    render_f2cmap,
25
    render_f2py_wrappers,
26
    render_python_wrappers,
27
)
28
from .codegen_fortran import (
1✔
29
    ConstantSpec,
30
    generate_fortran,
31
    generate_helper,
32
    render_fortran,
33
    render_helper,
34
)
35
from .codegen_markdown import generate_docs, render_docs
1✔
36
from .codegen_template import generate_template, render_template
1✔
37
from .schema import load_schema
1✔
38
from .validate import validate_namelist
1✔
39

40
if sys.version_info >= (3, 11):
1✔
41
    import tomllib
1✔
42
else:  # pragma: no cover - python<3.11
43
    import tomli as tomllib
44

45
logger = logging.getLogger(__name__)
1✔
46
_CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
1✔
47
_DEFAULT_CONFIG = Path("nml-config.toml")
1✔
48
_PYPROJECT_CONFIG = Path("pyproject.toml")
1✔
49

50
_NamedIntegerTypeBase = cast(Any, click.ParamType)
1✔
51

52

53
class NamedIntegerType(_NamedIntegerTypeBase):  # type: ignore[valid-type, misc]
1✔
54
    """Parse NAME=INT values for CLI options."""
55

56
    name = "NAME=INT"
1✔
57

58
    def __init__(self, *, label: str, positive: bool = False) -> None:
1✔
59
        self._label = label
1✔
60
        self._positive = positive
1✔
61

62
    def convert(
1✔
63
        self,
64
        value: Any,
65
        param: click.Parameter | None,
66
        ctx: click.Context | None,
67
    ) -> tuple[str, int]:
68
        if not isinstance(value, str) or "=" not in value:
1✔
69
            self.fail("must be NAME=INT", param, ctx)
1✔
70

71
        raw_name, raw_value = value.split("=", 1)
1✔
72
        name = raw_name.strip()
1✔
73
        if not name:
1✔
NEW
74
            self.fail("must use non-empty names", param, ctx)
×
75
        if not is_fortran_identifier(name):
1✔
76
            self.fail(f"{self._label} '{name}' must be a valid identifier", param, ctx)
1✔
77

78
        value_text = raw_value.strip()
1✔
79
        if not value_text:
1✔
NEW
80
            self.fail(f"{self._label} '{name}' must define a value", param, ctx)
×
81
        value_digits = value_text[1:] if value_text[:1] in {"+", "-"} else value_text
1✔
82
        if not value_digits.isdigit():
1✔
83
            self.fail(f"{self._label} '{name}' value must be an integer", param, ctx)
1✔
84

85
        parsed_value = int(value_text)
1✔
86
        if self._positive and parsed_value <= 0:
1✔
87
            self.fail(f"{self._label} '{name}' value must be positive", param, ctx)
1✔
88
        return name.lower(), parsed_value
1✔
89

90

91
_CONSTANT_TYPE = NamedIntegerType(label="constant")
1✔
92
_DIMENSION_TYPE = NamedIntegerType(label="dimension", positive=True)
1✔
93

94

95
@dataclass(frozen=True)
1✔
96
class GeneratedOutput:
1✔
97
    """Generated file content for write/check commands."""
98

99
    path: Path
1✔
100
    content: str
1✔
101

102

103
def _configure_logging(verbose: int, quiet: int) -> None:
1✔
104
    base_level = logging.INFO
1✔
105
    level = base_level - (10 * verbose) + (10 * quiet)
1✔
106
    level = max(logging.DEBUG, min(logging.CRITICAL, level))
1✔
107
    logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
1✔
108

109

110
def _load_toml(path: Path) -> dict[str, Any]:
1✔
111
    with path.open("rb") as handle:
1✔
112
        data = tomllib.load(handle)
1✔
113
    if not isinstance(data, dict):
1✔
114
        raise click.ClickException("config must be a table")
×
115
    return data
1✔
116

117

118
def _load_toml_checked(path: Path) -> dict[str, Any]:
1✔
119
    try:
1✔
120
        return _load_toml(path)
1✔
121
    except FileNotFoundError as exc:
1✔
122
        raise click.ClickException(str(exc)) from exc
1✔
123
    except Exception as exc:  # pragma: no cover - tomllib may raise ValueError
124
        raise click.ClickException(f"failed to read config: {exc}") from exc
125

126

127
def _load_config_checked(path: Path | None) -> tuple[dict[str, Any], Path]:
1✔
128
    config_path = _resolve_config_path(path)
1✔
129
    raw_config = _load_toml_checked(config_path)
1✔
130
    if config_path.name == _PYPROJECT_CONFIG.name:
1✔
131
        config = _extract_pyproject_config(raw_config)
1✔
132
    else:
133
        config = raw_config
1✔
134
    _check_minimum_version(config)
1✔
135
    return config, config_path
1✔
136

137

138
def _resolve_config_path(path: Path | None) -> Path:
1✔
139
    if path is not None:
1✔
140
        return path
1✔
141
    if _DEFAULT_CONFIG.is_file():
1✔
142
        return _DEFAULT_CONFIG
1✔
143
    if _PYPROJECT_CONFIG.is_file():
1✔
144
        raw_config = _load_toml_checked(_PYPROJECT_CONFIG)
1✔
145
        if _has_pyproject_config(raw_config):
1✔
146
            return _PYPROJECT_CONFIG
1✔
147
    raise click.ClickException(
1✔
148
        "no config found; create nml-config.toml or add [tool.nml-tools] to pyproject.toml"
149
    )
150

151

152
def _has_pyproject_config(config: dict[str, Any]) -> bool:
1✔
153
    tool_raw = config.get("tool")
1✔
154
    return isinstance(tool_raw, dict) and isinstance(tool_raw.get("nml-tools"), dict)
1✔
155

156

157
def _extract_pyproject_config(config: dict[str, Any]) -> dict[str, Any]:
1✔
158
    tool_raw = config.get("tool")
1✔
159
    if not isinstance(tool_raw, dict):
1✔
160
        raise click.ClickException("pyproject.toml must define [tool.nml-tools]")
1✔
161
    nml_tools_raw = tool_raw.get("nml-tools")
1✔
162
    if not isinstance(nml_tools_raw, dict):
1✔
163
        raise click.ClickException("pyproject.toml must define [tool.nml-tools]")
1✔
164
    return nml_tools_raw
1✔
165

166

167
def _check_minimum_version(config: dict[str, Any]) -> None:
1✔
168
    minimum_raw = config.get("minimum-version")
1✔
169
    if minimum_raw is None:
1✔
170
        return
1✔
171
    if not isinstance(minimum_raw, str) or not minimum_raw.strip():
1✔
172
        raise click.ClickException("config 'minimum-version' must be a non-empty string")
1✔
173
    try:
1✔
174
        minimum = Version(minimum_raw.strip())
1✔
175
    except InvalidVersion as exc:
1✔
176
        raise click.ClickException(
1✔
177
            f"config 'minimum-version' is not a valid version: {minimum_raw}"
178
        ) from exc
179
    try:
1✔
180
        current = Version(__version__)
1✔
181
    except InvalidVersion as exc:  # pragma: no cover - package version should be valid
182
        raise click.ClickException(f"nml-tools version is not valid: {__version__}") from exc
183
    if current < minimum:
1✔
184
        raise click.ClickException(
1✔
185
            f"config requires nml-tools >= {minimum}, current version is {current}"
186
        )
187

188

189
def _resolve_optional_path(
1✔
190
    value: Any,
191
    *,
192
    base_dir: Path,
193
    key: str,
194
) -> Path | None:
195
    if value is None:
1✔
196
        return None
1✔
197
    if not isinstance(value, str):
1✔
198
        raise click.ClickException(f"config '{key}' must be a string")
×
199
    return base_dir / value
1✔
200

201

202
def _load_helper_settings(
1✔
203
    config: dict[str, Any],
204
    base_dir: Path,
205
) -> tuple[Path | None, str, int, str | None]:
206
    default_buffer = 1024
1✔
207
    helper_raw = config.get("helper")
1✔
208
    if helper_raw is None:
1✔
209
        helper_path = _resolve_optional_path(
1✔
210
            config.get("helper_path"),
211
            base_dir=base_dir,
212
            key="helper_path",
213
        )
214
        helper_module_raw = config.get("helper_module", "nml_helper")
1✔
215
        if not isinstance(helper_module_raw, str):
1✔
216
            raise click.ClickException("config 'helper_module' must be a string")
×
217
        helper_module = helper_module_raw.strip()
1✔
218
        if not helper_module:
1✔
219
            raise click.ClickException("config 'helper_module' must be a non-empty string")
×
220
        return helper_path, helper_module, default_buffer, None
1✔
221

222
    if not isinstance(helper_raw, dict):
1✔
223
        raise click.ClickException("config 'helper' must be a table")
×
224
    if "helper_path" in config or "helper_module" in config:
1✔
225
        logger.warning("config uses [helper]; ignoring legacy helper_path/helper_module")
×
226
    helper_path = _resolve_optional_path(
1✔
227
        helper_raw.get("path"),
228
        base_dir=base_dir,
229
        key="helper.path",
230
    )
231
    helper_module_raw = helper_raw.get("module", "nml_helper")
1✔
232
    if not isinstance(helper_module_raw, str):
1✔
233
        raise click.ClickException("config 'helper.module' must be a string")
×
234
    helper_module = helper_module_raw.strip()
1✔
235
    if not helper_module:
1✔
236
        raise click.ClickException("config 'helper.module' must be a non-empty string")
×
237
    header_raw = helper_raw.get("header")
1✔
238
    if header_raw is None:
1✔
239
        header = None
1✔
240
    else:
241
        if not isinstance(header_raw, str):
×
242
            raise click.ClickException("config 'helper.header' must be a string")
×
243
        header = header_raw.rstrip()
×
244
        if not header:
×
245
            header = None
×
246
    buffer_raw = helper_raw.get("buffer", default_buffer)
1✔
247
    if isinstance(buffer_raw, bool) or not isinstance(buffer_raw, int):
1✔
248
        raise click.ClickException("config 'helper.buffer' must be an integer")
×
249
    if buffer_raw <= 0:
1✔
250
        raise click.ClickException("config 'helper.buffer' must be positive")
×
251
    return helper_path, helper_module, buffer_raw, header
1✔
252

253

254
def _load_kind_settings(config: dict[str, Any]) -> tuple[str, dict[str, str], set[str]]:
1✔
255
    kinds_raw = config.get("kinds")
1✔
256
    if not isinstance(kinds_raw, dict):
1✔
257
        raise click.ClickException("config must define a [kinds] table")
×
258
    module_raw = kinds_raw.get("module")
1✔
259
    if not isinstance(module_raw, str) or not module_raw.strip():
1✔
260
        raise click.ClickException("config 'kinds.module' must be a non-empty string")
×
261
    module = module_raw.strip()
1✔
262

263
    map_raw = kinds_raw.get("map", {})
1✔
264
    if map_raw is None:
1✔
265
        map_raw = {}
×
266
    if not isinstance(map_raw, dict):
1✔
267
        raise click.ClickException("config 'kinds.map' must be a table")
×
268
    kind_map: dict[str, str] = {}
1✔
269
    for alias, target in map_raw.items():
1✔
270
        if not isinstance(alias, str) or not isinstance(target, str):
1✔
271
            raise click.ClickException("config 'kinds.map' keys and values must be strings")
×
272
        kind_map[alias] = target
1✔
273

274
    real_raw = kinds_raw.get("real", [])
1✔
275
    integer_raw = kinds_raw.get("integer", [])
1✔
276
    if not isinstance(real_raw, list) or not all(isinstance(item, str) for item in real_raw):
1✔
277
        raise click.ClickException("config 'kinds.real' must be a list of strings")
×
278
    if not isinstance(integer_raw, list) or not all(isinstance(item, str) for item in integer_raw):
1✔
279
        raise click.ClickException("config 'kinds.integer' must be a list of strings")
×
280
    allowlist = set(real_raw) | set(integer_raw)
1✔
281

282
    return module, kind_map, allowlist
1✔
283

284

285
def _load_f2py_settings(
1✔
286
    config: dict[str, Any],
287
    base_dir: Path,
288
) -> tuple[Path | None, F2pyCTypeMap]:
289
    f2py_raw = config.get("f2py")
1✔
290
    if f2py_raw is None:
1✔
291
        return None, F2pyCTypeMap(real={}, integer={})
1✔
292
    if not isinstance(f2py_raw, dict):
1✔
293
        raise click.ClickException("config 'f2py' must be a table")
×
294
    if "python_package" in f2py_raw:
1✔
295
        raise click.ClickException(
×
296
            "config 'f2py.python_package' is no longer supported; "
297
            "place the f2py extension next to the generated Python wrapper"
298
        )
299

300
    f2cmap_path = _resolve_optional_path(
1✔
301
        f2py_raw.get("f2cmap_path"),
302
        base_dir=base_dir,
303
        key="f2py.f2cmap_path",
304
    )
305

306
    c_types_raw = f2py_raw.get("c_types", {})
1✔
307
    if c_types_raw is None:
1✔
308
        c_types_raw = {}
×
309
    if not isinstance(c_types_raw, dict):
1✔
310
        raise click.ClickException("config 'f2py.c_types' must be a table")
×
311

312
    real = _load_f2py_ctype_table(c_types_raw, "real")
1✔
313
    integer = _load_f2py_ctype_table(c_types_raw, "integer")
1✔
314
    return f2cmap_path, F2pyCTypeMap(real=real, integer=integer)
1✔
315

316

317
def _load_f2py_ctype_table(c_types: dict[str, Any], key: str) -> dict[str, str]:
1✔
318
    raw = c_types.get(key, {})
1✔
319
    if raw is None:
1✔
320
        raw = {}
×
321
    if not isinstance(raw, dict):
1✔
322
        raise click.ClickException(f"config 'f2py.c_types.{key}' must be a table")
×
323
    values: dict[str, str] = {}
1✔
324
    for kind, c_type in raw.items():
1✔
325
        if not isinstance(kind, str) or not kind.strip():
1✔
326
            raise click.ClickException(
×
327
                f"config 'f2py.c_types.{key}' keys must be non-empty strings"
328
            )
329
        if not isinstance(c_type, str) or not c_type.strip():
1✔
330
            raise click.ClickException(
×
331
                f"config 'f2py.c_types.{key}.{kind}' must be a non-empty string"
332
            )
333
        values[kind.strip()] = c_type.strip()
1✔
334
    return values
1✔
335

336

337
def _format_constant_literal(value: int) -> tuple[str, str]:
1✔
338
    if isinstance(value, bool):
1✔
339
        raise click.ClickException("config constants must not be boolean")
×
340
    if isinstance(value, int):
1✔
341
        return "integer", str(value)
1✔
NEW
342
    raise click.ClickException("config constants must be integers")
×
343

344

345
def _load_constants(config: dict[str, Any]) -> tuple[dict[str, int], list[ConstantSpec]]:
1✔
346
    constants_raw = config.get("constants", {})
1✔
347
    if constants_raw is None:
1✔
348
        constants_raw = {}
×
349
    if not isinstance(constants_raw, dict):
1✔
350
        raise click.ClickException("config 'constants' must be a table")
×
351

352
    constants: dict[str, int] = {}
1✔
353
    specs: list[ConstantSpec] = []
1✔
354
    for name_raw, entry in constants_raw.items():
1✔
355
        if not isinstance(name_raw, str):
1✔
356
            raise click.ClickException("config constants must use string keys")
×
357
        name = name_raw.strip()
1✔
358
        if not name:
1✔
359
            raise click.ClickException("config constants must have non-empty names")
×
360
        if not is_fortran_identifier(name):
1✔
361
            raise click.ClickException(
×
362
                f"config constant '{name}' must be a valid Fortran identifier"
363
            )
364
        canonical_name = name.lower()
1✔
365
        if canonical_name in constants:
1✔
366
            raise click.ClickException(
1✔
367
                f"config constant '{name}' duplicates another constant name"
368
            )
369
        if not isinstance(entry, dict):
1✔
370
            raise click.ClickException(f"config constant '{name}' must be a table with 'value'")
×
371
        if "value" not in entry:
1✔
372
            raise click.ClickException(f"config constant '{name}' must define 'value'")
×
373
        value = entry.get("value")
1✔
374
        if isinstance(value, bool) or not isinstance(value, int):
1✔
375
            raise click.ClickException("config constants must be integers")
1✔
376
        type_spec, literal = _format_constant_literal(value)
1✔
377
        doc = entry.get("doc")
1✔
378
        if doc is not None:
1✔
379
            if not isinstance(doc, str):
1✔
380
                raise click.ClickException(f"config constant '{name}' doc must be a string")
×
381
            doc = " ".join(doc.splitlines()).strip() or None
1✔
382
        specs.append(
1✔
383
            ConstantSpec(
384
                name=canonical_name,
385
                type_spec=type_spec,
386
                value=literal,
387
                doc=doc,
388
            )
389
        )
390
        constants[canonical_name] = value
1✔
391
    return constants, specs
1✔
392

393

394
def _load_dimensions(
1✔
395
    config: dict[str, Any],
396
    constants: dict[str, int],
397
) -> tuple[dict[str, int], list[ConstantSpec]]:
398
    dimensions_raw = config.get("dimensions", {})
1✔
399
    if dimensions_raw is None:
1✔
NEW
400
        dimensions_raw = {}
×
401
    if not isinstance(dimensions_raw, dict):
1✔
NEW
402
        raise click.ClickException("config 'dimensions' must be a table")
×
403

404
    dimensions: dict[str, int] = {}
1✔
405
    specs: list[ConstantSpec] = []
1✔
406
    constant_names = {name.lower() for name in constants}
1✔
407
    for name_raw, entry in dimensions_raw.items():
1✔
408
        if not isinstance(name_raw, str):
1✔
NEW
409
            raise click.ClickException("config dimensions must use string keys")
×
410
        name = name_raw.strip()
1✔
411
        if not name:
1✔
NEW
412
            raise click.ClickException("config dimensions must have non-empty names")
×
413
        if not is_fortran_identifier(name):
1✔
NEW
414
            raise click.ClickException(
×
415
                f"config dimension '{name}' must be a valid Fortran identifier"
416
            )
417
        canonical_name = name.lower()
1✔
418
        if canonical_name in constant_names:
1✔
419
            raise click.ClickException(
1✔
420
                f"config dimension '{name}' duplicates a constant name"
421
            )
422
        if canonical_name in dimensions:
1✔
423
            raise click.ClickException(
1✔
424
                f"config dimension '{name}' duplicates another dimension name"
425
            )
426
        if not isinstance(entry, dict):
1✔
NEW
427
            raise click.ClickException(f"config dimension '{name}' must be a table with 'value'")
×
428
        if "value" not in entry:
1✔
NEW
429
            raise click.ClickException(f"config dimension '{name}' must define 'value'")
×
430
        value = entry.get("value")
1✔
431
        if isinstance(value, bool) or not isinstance(value, int):
1✔
NEW
432
            raise click.ClickException(f"config dimension '{name}' value must be an integer")
×
433
        if value <= 0:
1✔
434
            raise click.ClickException(f"config dimension '{name}' value must be positive")
1✔
435
        doc = entry.get("doc")
1✔
436
        if doc is not None:
1✔
437
            if not isinstance(doc, str):
1✔
NEW
438
                raise click.ClickException(f"config dimension '{name}' doc must be a string")
×
439
            doc = " ".join(doc.splitlines()).strip() or None
1✔
440
        specs.append(
1✔
441
            ConstantSpec(
442
                name=canonical_name,
443
                type_spec="integer",
444
                value=str(value),
445
                doc=doc,
446
            )
447
        )
448
        dimensions[canonical_name] = value
1✔
449
    return dimensions, specs
1✔
450

451

452
def _load_bool_field(section: dict[str, Any], key: str, *, label: str) -> bool:
1✔
453
    value = section.get(key, False)
×
454
    if isinstance(value, bool):
×
455
        return value
×
456
    raise click.ClickException(f"config '{label}' must be a boolean")
×
457

458

459
def _load_documentation_settings(
1✔
460
    config: dict[str, Any],
461
) -> tuple[str | None, bool, bool, str]:
462
    doc_raw = config.get("documentation")
1✔
463
    if doc_raw is None:
1✔
464
        return None, False, False, "numpy"
1✔
465
    if not isinstance(doc_raw, dict):
×
466
        raise click.ClickException("config 'documentation' must be a table")
×
467
    module_doc = None
×
468
    if "module" in doc_raw:
×
469
        module_raw = doc_raw.get("module")
×
470
        if not isinstance(module_raw, str):
×
471
            raise click.ClickException("config 'documentation.module' must be a string")
×
472
        module_doc = module_raw.strip() or None
×
473
    md_doxygen_id_from_name = _load_bool_field(
×
474
        doc_raw,
475
        "md_doxygen_id_from_name",
476
        label="documentation.md_doxygen_id_from_name",
477
    )
478
    md_add_toc_statement = _load_bool_field(
×
479
        doc_raw,
480
        "md_add_toc_statement",
481
        label="documentation.md_add_toc_statement",
482
    )
483
    py_style_raw = doc_raw.get("py-style", "numpy")
×
484
    if not isinstance(py_style_raw, str):
×
485
        raise click.ClickException("config 'documentation.py-style' must be a string")
×
486
    py_style = py_style_raw.strip().lower()
×
487
    if py_style not in {"numpy", "doxygen"}:
×
488
        raise click.ClickException(
×
489
            "config 'documentation.py-style' must be 'numpy' or 'doxygen'"
490
        )
491
    return module_doc, md_doxygen_id_from_name, md_add_toc_statement, py_style
×
492

493

494
def _parse_cli_constants(values: tuple[tuple[str, int], ...]) -> dict[str, int]:
1✔
495
    constants: dict[str, int] = {}
1✔
496
    for name, value in values:
1✔
497
        if name in constants:
1✔
498
            raise click.ClickException(f"constant '{name}' duplicates another constant name")
1✔
499
        constants[name] = value
1✔
500
    return constants
1✔
501

502

503
def _parse_cli_dimensions(values: tuple[tuple[str, int], ...]) -> dict[str, int]:
1✔
504
    dimensions: dict[str, int] = {}
1✔
505
    for name, value in values:
1✔
506
        if name in dimensions:
1✔
507
            raise click.ClickException(f"dimension '{name}' duplicates another dimension name")
1✔
508
        if value <= 0:
1✔
NEW
509
            raise click.ClickException(f"dimension '{name}' value must be positive")
×
510
        dimensions[name] = value
1✔
511
    return dimensions
1✔
512

513

514
def _reject_constant_dimension_overlap(
1✔
515
    constants: dict[str, int],
516
    dimensions: dict[str, int],
517
) -> None:
518
    duplicate_names = constant_dimension_overlap(constants, dimensions)
1✔
519
    if duplicate_names:
1✔
NEW
520
        raise click.ClickException(
×
521
            "constants and dimensions must not share names: " + ", ".join(duplicate_names)
522
        )
523

524

525
def _iter_namelists(config: dict[str, Any], base_dir: Path) -> list[dict[str, Any]]:
1✔
526
    raw_entries = config.get("namelists")
1✔
527
    if raw_entries is None and "nml-files" in config:
1✔
528
        raise click.ClickException("config uses deprecated 'nml-files'; rename to 'namelists'")
×
529
    if not isinstance(raw_entries, list) or not raw_entries:
1✔
530
        raise click.ClickException("config must define non-empty 'namelists'")
×
531

532
    entries: list[dict[str, Path | None]] = []
1✔
533
    for entry in raw_entries:
1✔
534
        if not isinstance(entry, dict):
1✔
535
            raise click.ClickException("each namelists entry must be a table")
×
536
        schema_raw = entry.get("schema")
1✔
537
        if not isinstance(schema_raw, str):
1✔
538
            raise click.ClickException("namelists entry must define string 'schema'")
×
539
        schema_path = base_dir / schema_raw
1✔
540
        f2py_path = _resolve_optional_path(
1✔
541
            entry.get("f2py_path"),
542
            base_dir=base_dir,
543
            key="f2py_path",
544
        )
545
        py_path = _resolve_optional_path(
1✔
546
            entry.get("py_path"),
547
            base_dir=base_dir,
548
            key="py_path",
549
        )
550
        mod_path = _resolve_optional_path(
1✔
551
            entry.get("mod_path"),
552
            base_dir=base_dir,
553
            key="mod_path",
554
        )
555
        if f2py_path is not None and mod_path is None:
1✔
556
            raise click.ClickException(
×
557
                "namelists entry with 'f2py_path' must define 'mod_path'"
558
            )
559
        if py_path is not None and f2py_path is None:
1✔
560
            raise click.ClickException("namelists entry with 'py_path' must define 'f2py_path'")
×
561
        entries.append(
1✔
562
            {
563
                "schema": schema_path,
564
                "mod_path": mod_path,
565
                "doc_path": _resolve_optional_path(
566
                    entry.get("doc_path"),
567
                    base_dir=base_dir,
568
                    key="doc_path",
569
                ),
570
                "temp_path": _resolve_optional_path(
571
                    entry.get("temp_path"),
572
                    base_dir=base_dir,
573
                    key="temp_path",
574
                ),
575
                "f2py_path": f2py_path,
576
                "py_path": py_path,
577
            }
578
        )
579
    return entries
1✔
580

581

582
def _iter_templates(config: dict[str, Any], base_dir: Path) -> list[dict[str, Any]]:
1✔
583
    raw_entries = config.get("templates")
1✔
584
    if raw_entries is None:
1✔
585
        return []
1✔
586
    if not isinstance(raw_entries, list) or not raw_entries:
1✔
587
        raise click.ClickException("config 'templates' must be a non-empty list")
×
588

589
    entries: list[dict[str, Any]] = []
1✔
590
    for entry in raw_entries:
1✔
591
        if not isinstance(entry, dict):
1✔
592
            raise click.ClickException("each templates entry must be a table")
×
593
        output_raw = entry.get("output")
1✔
594
        if not isinstance(output_raw, str):
1✔
595
            raise click.ClickException("templates entry must define string 'output'")
×
596
        output_path = base_dir / output_raw
1✔
597

598
        schemas_raw = entry.get("schemas")
1✔
599
        if not isinstance(schemas_raw, list) or not schemas_raw:
1✔
600
            raise click.ClickException("templates entry must define non-empty 'schemas'")
×
601
        if not all(isinstance(item, str) for item in schemas_raw):
1✔
602
            raise click.ClickException("templates 'schemas' entries must be strings")
×
603
        schema_paths = [base_dir / item for item in schemas_raw]
1✔
604

605
        doc_mode = entry.get("doc_mode", "plain")
1✔
606
        if not isinstance(doc_mode, str):
1✔
607
            raise click.ClickException("templates 'doc_mode' must be a string")
×
608
        value_mode = entry.get("value_mode", "empty")
1✔
609
        if not isinstance(value_mode, str):
1✔
610
            raise click.ClickException("templates 'value_mode' must be a string")
×
611
        values_raw = entry.get("values", {})
1✔
612
        if values_raw is None:
1✔
613
            values_raw = {}
×
614
        if not isinstance(values_raw, dict):
1✔
615
            raise click.ClickException("templates 'values' must be a table")
×
616

617
        entries.append(
1✔
618
            {
619
                "output": output_path,
620
                "schemas": schema_paths,
621
                "doc_mode": doc_mode,
622
                "value_mode": value_mode,
623
                "values": values_raw,
624
            }
625
        )
626
    return entries
1✔
627

628

629
def _collect_generated_outputs(
1✔
630
    config: dict[str, Any],
631
    config_path: Path,
632
) -> list[GeneratedOutput]:
633
    base_dir = config_path.parent
1✔
634
    logger.debug("Base directory: %s", base_dir)
1✔
635
    helper_path, helper_module, helper_buffer, helper_header = _load_helper_settings(
1✔
636
        config,
637
        base_dir,
638
    )
639
    (
1✔
640
        module_doc,
641
        md_doxygen_id_from_name,
642
        md_add_toc_statement,
643
        py_style,
644
    ) = _load_documentation_settings(config)
645
    constants, constant_specs = _load_constants(config)
1✔
646
    dimensions, dimension_specs = _load_dimensions(config, constants)
1✔
647
    kind_module, kind_map, kind_allowlist = _load_kind_settings(config)
1✔
648
    f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir)
1✔
649
    outputs: list[GeneratedOutput] = []
1✔
650

651
    if helper_path is not None:
1✔
652
        try:
1✔
653
            logger.debug("Rendering helper module at %s", helper_path)
1✔
654
            outputs.append(
1✔
655
                GeneratedOutput(
656
                    helper_path,
657
                    render_helper(
658
                        file_name=helper_path.name,
659
                        module_name=helper_module,
660
                        len_buf=helper_buffer,
661
                        constants=constant_specs + dimension_specs,
662
                        module_doc=module_doc,
663
                        helper_header=helper_header,
664
                    ),
665
                )
666
            )
667
        except ValueError as exc:
×
668
            raise click.ClickException(str(exc)) from exc
×
669

670
    entries = _iter_namelists(config, base_dir)
1✔
671
    logger.debug("Found %d schema entries", len(entries))
1✔
672
    loaded_entries: list[dict[str, Any]] = []
1✔
673
    for namelist_entry in entries:
1✔
674
        schema_path = namelist_entry["schema"]
1✔
675
        if schema_path is None:
1✔
676
            raise click.ClickException("namelists entry missing schema path")
×
677
        try:
1✔
678
            logger.debug("Loading schema %s", schema_path)
1✔
679
            schema = load_schema(schema_path)
1✔
680
        except (FileNotFoundError, ValueError) as exc:
×
681
            raise click.ClickException(str(exc)) from exc
×
682
        loaded_entries.append({"entry": namelist_entry, "schema": schema})
1✔
683

684
        mod_path = namelist_entry["mod_path"]
1✔
685
        if mod_path is not None:
1✔
686
            try:
1✔
687
                logger.debug("Rendering Fortran module at %s", mod_path)
1✔
688
                outputs.append(
1✔
689
                    GeneratedOutput(
690
                        mod_path,
691
                        render_fortran(
692
                            schema,
693
                            file_name=mod_path.name,
694
                            helper_module=helper_module,
695
                            kind_module=kind_module,
696
                            kind_map=kind_map,
697
                            kind_allowlist=kind_allowlist,
698
                            constants=constants,
699
                            dimensions=dimensions,
700
                            module_doc=module_doc,
701
                            f2py_handle_helpers=namelist_entry["f2py_path"] is not None,
702
                        ),
703
                    )
704
                )
705
            except ValueError as exc:
×
706
                raise click.ClickException(str(exc)) from exc
×
707

708
        doc_path = namelist_entry["doc_path"]
1✔
709
        if doc_path is not None:
1✔
710
            try:
1✔
711
                logger.debug("Rendering Markdown docs at %s", doc_path)
1✔
712
                outputs.append(
1✔
713
                    GeneratedOutput(
714
                        doc_path,
715
                        render_docs(
716
                            schema,
717
                            constants=constants,
718
                            dimensions=dimensions,
719
                            md_doxygen_id_from_name=md_doxygen_id_from_name,
720
                            md_add_toc_statement=md_add_toc_statement,
721
                        ),
722
                    )
723
                )
724
            except ValueError as exc:
×
725
                raise click.ClickException(str(exc)) from exc
×
726

727
    outputs.extend(
1✔
728
        _collect_f2py_outputs(
729
            loaded_entries,
730
            helper_module=helper_module,
731
            helper_buffer=helper_buffer,
732
            kind_module=kind_module,
733
            kind_map=kind_map,
734
            kind_allowlist=kind_allowlist,
735
            constants=constants,
736
            dimensions=dimensions,
737
            f2cmap_path=f2cmap_path,
738
            f2py_c_types=f2py_c_types,
739
            py_style=py_style,
740
            include_python=True,
741
        )
742
    )
743

744
    template_entries = _iter_templates(config, base_dir)
1✔
745
    if template_entries:
1✔
746
        logger.debug("Found %d template entries", len(template_entries))
1✔
747
    for template_entry in template_entries:
1✔
748
        try:
1✔
749
            schemas = []
1✔
750
            for schema_path in template_entry["schemas"]:
1✔
751
                logger.debug("Loading schema %s", schema_path)
1✔
752
                schemas.append(load_schema(schema_path))
1✔
753
            logger.debug("Rendering template at %s", template_entry["output"])
1✔
754
            outputs.append(
1✔
755
                GeneratedOutput(
756
                    template_entry["output"],
757
                    render_template(
758
                        schemas,
759
                        doc_mode=template_entry["doc_mode"],
760
                        value_mode=template_entry["value_mode"],
761
                        constants=constants,
762
                        dimensions=dimensions,
763
                        kind_map=kind_map,
764
                        kind_allowlist=kind_allowlist,
765
                        values=template_entry["values"],
766
                    ),
767
                )
768
            )
769
        except (FileNotFoundError, ValueError) as exc:
×
770
            raise click.ClickException(str(exc)) from exc
×
771

772
    return outputs
1✔
773

774

775
def _collect_f2py_outputs(
1✔
776
    loaded_entries: list[dict[str, Any]],
777
    *,
778
    helper_module: str,
779
    helper_buffer: int,
780
    kind_module: str,
781
    kind_map: dict[str, str],
782
    kind_allowlist: set[str],
783
    constants: dict[str, int],
784
    dimensions: dict[str, int],
785
    f2cmap_path: Path | None,
786
    f2py_c_types: F2pyCTypeMap,
787
    py_style: str,
788
    include_python: bool,
789
) -> list[GeneratedOutput]:
790
    outputs: list[GeneratedOutput] = []
1✔
791
    f2py_groups: dict[Path, list[dict[str, Any]]] = {}
1✔
792
    py_groups: dict[Path, list[tuple[dict[str, Any], Path]]] = {}
1✔
793
    for loaded in loaded_entries:
1✔
794
        entry = loaded["entry"]
1✔
795
        schema = loaded["schema"]
1✔
796
        f2py_path = entry["f2py_path"]
1✔
797
        if f2py_path is None:
1✔
798
            continue
1✔
799
        f2py_groups.setdefault(f2py_path, []).append(schema)
1✔
800
        py_path = entry["py_path"]
1✔
801
        if include_python and py_path is not None:
1✔
802
            py_groups.setdefault(py_path, []).append((schema, f2py_path))
1✔
803

804
    for f2py_path, schemas in f2py_groups.items():
1✔
805
        try:
1✔
806
            logger.debug("Rendering f2py wrappers at %s", f2py_path)
1✔
807
            outputs.append(
1✔
808
                GeneratedOutput(
809
                    f2py_path,
810
                    render_f2py_wrappers(
811
                        schemas,
812
                        file_name=f2py_path.name,
813
                        helper_module=helper_module,
814
                        kind_module=kind_module,
815
                        kind_map=kind_map,
816
                        kind_allowlist=kind_allowlist,
817
                        constants=constants,
818
                        dimensions=dimensions,
819
                        errmsg_len=helper_buffer,
820
                    ),
821
                )
822
            )
823
        except ValueError as exc:
×
824
            raise click.ClickException(str(exc)) from exc
×
825

826
    if f2cmap_path is not None:
1✔
827
        try:
1✔
828
            usage = merge_f2py_kind_usage(
1✔
829
                collect_f2py_kind_usage(schemas, constants=constants, dimensions=dimensions)
830
                for schemas in f2py_groups.values()
831
            )
832
            logger.debug("Rendering f2py kind map at %s", f2cmap_path)
1✔
833
            outputs.append(
1✔
834
                GeneratedOutput(
835
                    f2cmap_path,
836
                    render_f2cmap(usage, f2py_c_types),
837
                )
838
            )
839
        except ValueError as exc:
×
840
            raise click.ClickException(str(exc)) from exc
×
841

842
    if not include_python:
1✔
843
        return outputs
1✔
844
    for py_path, entries in py_groups.items():
1✔
845
        try:
1✔
846
            logger.debug("Rendering Python f2py wrappers at %s", py_path)
1✔
847
            specs = [
1✔
848
                (
849
                    build_f2py_namelist_spec(
850
                        schema,
851
                        helper_module=helper_module,
852
                        kind_module=kind_module,
853
                        kind_map=kind_map,
854
                        kind_allowlist=kind_allowlist,
855
                        constants=constants,
856
                        dimensions=dimensions,
857
                        errmsg_len=helper_buffer,
858
                    ),
859
                    f2py_path.stem,
860
                )
861
                for schema, f2py_path in entries
862
            ]
863
            outputs.append(
1✔
864
                GeneratedOutput(
865
                    py_path,
866
                    render_python_wrappers(
867
                        specs,
868
                        py_style=py_style,
869
                    ),
870
                )
871
            )
872
        except ValueError as exc:
×
873
            raise click.ClickException(str(exc)) from exc
×
874
    return outputs
1✔
875

876

877
def _generate_f2py_outputs(
1✔
878
    loaded_entries: list[dict[str, Any]],
879
    *,
880
    helper_module: str,
881
    helper_buffer: int,
882
    kind_module: str,
883
    kind_map: dict[str, str],
884
    kind_allowlist: set[str],
885
    constants: dict[str, int],
886
    dimensions: dict[str, int],
887
    f2cmap_path: Path | None,
888
    f2py_c_types: F2pyCTypeMap,
889
    py_style: str,
890
    include_python: bool,
891
) -> None:
892
    _write_generated_outputs(
1✔
893
        _collect_f2py_outputs(
894
            loaded_entries,
895
            helper_module=helper_module,
896
            helper_buffer=helper_buffer,
897
            kind_module=kind_module,
898
            kind_map=kind_map,
899
            kind_allowlist=kind_allowlist,
900
            constants=constants,
901
            dimensions=dimensions,
902
            f2cmap_path=f2cmap_path,
903
            f2py_c_types=f2py_c_types,
904
            py_style=py_style,
905
            include_python=include_python,
906
        )
907
    )
908

909

910
def _write_generated_outputs(outputs: list[GeneratedOutput]) -> None:
1✔
911
    for output in outputs:
1✔
912
        logger.info("Writing generated file %s", output.path)
1✔
913
        output.path.parent.mkdir(parents=True, exist_ok=True)
1✔
914
        output.path.write_text(output.content, encoding="ascii")
1✔
915

916

917
def _check_generated_outputs(outputs: list[GeneratedOutput], *, show_diff: bool) -> int:
1✔
918
    failed = 0
1✔
919
    for output in outputs:
1✔
920
        if not output.path.exists():
1✔
921
            failed += 1
1✔
922
            click.echo(f"MISSING: {output.path}", err=True)
1✔
923
            continue
1✔
924
        current = output.path.read_text(encoding="ascii")
1✔
925
        if current != output.content:
1✔
926
            failed += 1
1✔
927
            click.echo(f"DIFF: {output.path}", err=True)
1✔
928
            if show_diff:
1✔
929
                diff = unified_diff(
1✔
930
                    current.splitlines(keepends=True),
931
                    output.content.splitlines(keepends=True),
932
                    fromfile=f"current {output.path}",
933
                    tofile=f"generated {output.path}",
934
                )
935
                for line in diff:
1✔
936
                    click.echo(line, nl=False)
1✔
937
            continue
1✔
938
        logger.debug("OK: %s", output.path)
1✔
939
    return failed
1✔
940

941

942
@click.group(context_settings=_CONTEXT_SETTINGS)
1✔
943
@click.version_option(__version__, "-V", "--version", prog_name="nml-tools")
1✔
944
@click.option("--verbose", "-v", count=True, help="Increase verbosity (repeatable).")
1✔
945
@click.option("--quiet", "-q", count=True, help="Decrease verbosity (repeatable).")
1✔
946
def cli(verbose: int, quiet: int) -> None:
1✔
947
    """nml-tools command line interface."""
948
    _configure_logging(verbose, quiet)
1✔
949

950

951
@cli.command("generate", context_settings=_CONTEXT_SETTINGS)
1✔
952
@click.option(
1✔
953
    "--config",
954
    "config_path",
955
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
956
    default=None,
957
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
958
)
959
def generate(config_path: Path | None) -> None:
1✔
960
    """Generate outputs from a configuration file."""
961
    config, config_path = _load_config_checked(config_path)
1✔
962
    logger.info("Loading config from %s", config_path)
1✔
963
    _write_generated_outputs(_collect_generated_outputs(config, config_path))
1✔
964

965

966
@cli.command("check", context_settings=_CONTEXT_SETTINGS)
1✔
967
@click.option(
1✔
968
    "--config",
969
    "config_path",
970
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
971
    default=None,
972
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
973
)
974
@click.option(
1✔
975
    "--diff",
976
    "show_diff",
977
    is_flag=True,
978
    help="Show unified diffs for generated files that differ.",
979
)
980
def check(config_path: Path | None, show_diff: bool) -> None:
1✔
981
    """Check that configured generated files are up to date."""
982
    config, config_path = _load_config_checked(config_path)
1✔
983
    logger.debug("Loading config from %s", config_path)
1✔
984
    failures = _check_generated_outputs(
1✔
985
        _collect_generated_outputs(config, config_path),
986
        show_diff=show_diff,
987
    )
988
    if failures:
1✔
989
        raise click.ClickException(
1✔
990
            f"generated files are out of date: {failures} file(s) differ or are missing"
991
        )
992

993

994
@cli.command("gen-fortran", context_settings=_CONTEXT_SETTINGS)
1✔
995
@click.option(
1✔
996
    "--config",
997
    "config_path",
998
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
999
    default=None,
1000
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1001
)
1002
def gen_fortran(config_path: Path | None) -> None:
1✔
1003
    """Generate Fortran module(s)."""
1004
    config, config_path = _load_config_checked(config_path)
1✔
1005
    logger.info("Loading config from %s", config_path)
1✔
1006
    base_dir = config_path.parent
1✔
1007
    logger.debug("Base directory: %s", base_dir)
1✔
1008
    helper_path, helper_module, helper_buffer, helper_header = _load_helper_settings(
1✔
1009
        config,
1010
        base_dir,
1011
    )
1012
    module_doc, _, _, py_style = _load_documentation_settings(config)
1✔
1013
    constants, constant_specs = _load_constants(config)
1✔
1014
    dimensions, dimension_specs = _load_dimensions(config, constants)
1✔
1015
    kind_module, kind_map, kind_allowlist = _load_kind_settings(config)
1✔
1016
    f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir)
1✔
1017
    if helper_path is not None:
1✔
1018
        try:
×
1019
            logger.info("Generating helper module at %s", helper_path)
×
1020
            generate_helper(
×
1021
                helper_path,
1022
                module_name=helper_module,
1023
                len_buf=helper_buffer,
1024
                constants=constant_specs + dimension_specs,
1025
                module_doc=module_doc,
1026
                helper_header=helper_header,
1027
            )
1028
        except ValueError as exc:
×
1029
            raise click.ClickException(str(exc)) from exc
×
1030

1031
    entries = _iter_namelists(config, base_dir)
1✔
1032
    logger.info("Found %d schema entries", len(entries))
1✔
1033
    loaded_entries: list[dict[str, Any]] = []
1✔
1034
    for entry in entries:
1✔
1035
        schema_path = entry["schema"]
1✔
1036
        if schema_path is None:
1✔
1037
            raise click.ClickException("namelists entry missing schema path")
×
1038
        try:
1✔
1039
            logger.info("Loading schema %s", schema_path)
1✔
1040
            schema = load_schema(schema_path)
1✔
1041
        except (FileNotFoundError, ValueError) as exc:
×
1042
            raise click.ClickException(str(exc)) from exc
×
1043
        loaded_entries.append({"entry": entry, "schema": schema})
1✔
1044
        mod_path = entry["mod_path"]
1✔
1045
        if mod_path is None:
1✔
1046
            continue
×
1047
        try:
1✔
1048
            logger.info("Generating Fortran module at %s", mod_path)
1✔
1049
            generate_fortran(
1✔
1050
                schema,
1051
                mod_path,
1052
                helper_module=helper_module,
1053
                kind_module=kind_module,
1054
                kind_map=kind_map,
1055
                kind_allowlist=kind_allowlist,
1056
                constants=constants,
1057
                dimensions=dimensions,
1058
                module_doc=module_doc,
1059
                f2py_handle_helpers=entry["f2py_path"] is not None,
1060
            )
1061
        except ValueError as exc:
×
1062
            raise click.ClickException(str(exc)) from exc
×
1063

1064
    _generate_f2py_outputs(
1✔
1065
        loaded_entries,
1066
        helper_module=helper_module,
1067
        helper_buffer=helper_buffer,
1068
        kind_module=kind_module,
1069
        kind_map=kind_map,
1070
        kind_allowlist=kind_allowlist,
1071
        constants=constants,
1072
        dimensions=dimensions,
1073
        f2cmap_path=f2cmap_path,
1074
        f2py_c_types=f2py_c_types,
1075
        py_style=py_style,
1076
        include_python=False,
1077
    )
1078

1079

1080
@cli.command("validate", context_settings=_CONTEXT_SETTINGS)
1✔
1081
@click.option(
1✔
1082
    "--config",
1083
    "config_path",
1084
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1085
    default=None,
1086
)
1087
@click.option(
1✔
1088
    "--schema",
1089
    "schema_paths",
1090
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1091
    multiple=True,
1092
)
1093
@click.option(
1✔
1094
    "--input",
1095
    "input_option",
1096
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1097
)
1098
@click.option(
1✔
1099
    "--constants",
1100
    "constant_args",
1101
    metavar="NAME=INT",
1102
    type=_CONSTANT_TYPE,
1103
    multiple=True,
1104
    help="Additional integer constants as NAME=INT (repeatable).",
1105
)
1106
@click.option(
1✔
1107
    "--dimensions",
1108
    "dimension_args",
1109
    metavar="NAME=INT",
1110
    type=_DIMENSION_TYPE,
1111
    multiple=True,
1112
    help="Runtime dimensions as NAME=INT (repeatable).",
1113
)
1114
@click.argument(
1✔
1115
    "input_path",
1116
    required=False,
1117
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1118
)
1119
def validate(
1✔
1120
    config_path: Path | None,
1121
    schema_paths: tuple[Path, ...],
1122
    input_option: Path | None,
1123
    constant_args: tuple[tuple[str, int], ...],
1124
    dimension_args: tuple[tuple[str, int], ...],
1125
    input_path: Path | None,
1126
) -> None:
1127
    """Validate a namelist file against schema definitions."""
1128
    if input_option is not None and input_path is not None:
1✔
1129
        raise click.ClickException("input path provided twice")
×
1130
    input_path = input_option or input_path
1✔
1131
    if input_path is None:
1✔
1132
        raise click.ClickException("input path is required")
×
1133
    constants = _parse_cli_constants(constant_args)
1✔
1134
    dimension_overrides = _parse_cli_dimensions(dimension_args)
1✔
1135
    dimensions: dict[str, int] = {}
1✔
1136
    schemas: list[dict[str, Any]] = []
1✔
1137

1138
    if schema_paths:
1✔
1139
        if config_path is not None:
1✔
1140
            config, config_path = _load_config_checked(config_path)
×
1141
            logger.info("Loading config from %s", config_path)
×
1142
            cfg_constants, _ = _load_constants(config)
×
NEW
1143
            dimensions, _ = _load_dimensions(config, cfg_constants)
×
1144
            constants = {**cfg_constants, **constants}
×
NEW
1145
            dimensions = {**dimensions, **dimension_overrides}
×
1146
        else:
1147
            dimensions = dimension_overrides
1✔
1148
        for schema_file in schema_paths:
1✔
1149
            try:
1✔
1150
                logger.info("Loading schema %s", schema_file)
1✔
1151
                schemas.append(load_schema(schema_file))
1✔
1152
            except (FileNotFoundError, ValueError) as exc:
×
1153
                raise click.ClickException(str(exc)) from exc
×
1154
        require_all = True
1✔
1155
    else:
1156
        config, config_path = _load_config_checked(config_path)
1✔
1157
        logger.info("Loading config from %s", config_path)
1✔
1158
        base_dir = config_path.parent
1✔
1159
        cfg_constants, _ = _load_constants(config)
1✔
1160
        dimensions, _ = _load_dimensions(config, cfg_constants)
1✔
1161
        constants = {**cfg_constants, **constants}
1✔
1162
        dimensions = {**dimensions, **dimension_overrides}
1✔
1163
        entries = _iter_namelists(config, base_dir)
1✔
1164
        logger.info("Found %d schema entries", len(entries))
1✔
1165
        for entry in entries:
1✔
1166
            schema_path = entry["schema"]
1✔
1167
            if schema_path is None:
1✔
1168
                raise click.ClickException("namelists entry missing schema path")
×
1169
            try:
1✔
1170
                logger.info("Loading schema %s", schema_path)
1✔
1171
                schemas.append(load_schema(schema_path))
1✔
1172
            except (FileNotFoundError, ValueError) as exc:
×
1173
                raise click.ClickException(str(exc)) from exc
×
1174
        require_all = False
1✔
1175

1176
    if not schemas:
1✔
1177
        raise click.ClickException("no schemas provided for validation")
×
1178
    _reject_constant_dimension_overlap(constants, dimensions)
1✔
1179

1180
    try:
1✔
1181
        logger.info("Reading namelist %s", input_path)
1✔
1182
        namelist_file = f90nml.read(input_path)
1✔
1183
    except Exception as exc:  # pragma: no cover - f90nml raises custom errors
1184
        raise click.ClickException(f"failed to read namelist: {exc}") from exc
1185

1186
    file_entries: dict[str, tuple[str, Any]] = {}
1✔
1187
    for name, values in namelist_file.items():
1✔
1188
        if not isinstance(name, str):
1✔
1189
            raise click.ClickException("namelist names must be strings")
×
1190
        key = name.lower()
1✔
1191
        if key in file_entries:
1✔
1192
            raise click.ClickException(f"namelist '{name}' appears multiple times")
×
1193
        file_entries[key] = (name, values)
1✔
1194

1195
    schema_entries: dict[str, dict[str, Any]] = {}
1✔
1196
    for schema in schemas:
1✔
1197
        namelist_name = schema.get("x-fortran-namelist")
1✔
1198
        if not isinstance(namelist_name, str) or not namelist_name.strip():
1✔
1199
            raise click.ClickException("schema must define non-empty 'x-fortran-namelist'")
×
1200
        key = namelist_name.lower()
1✔
1201
        if key in schema_entries:
1✔
1202
            raise click.ClickException(f"duplicate schema for namelist '{namelist_name}'")
×
1203
        schema_entries[key] = schema
1✔
1204

1205
    for key, (name, _) in file_entries.items():
1✔
1206
        if key not in schema_entries:
1✔
1207
            raise click.ClickException(f"input contains unknown namelist '{name}'")
×
1208

1209
    validated = 0
1✔
1210
    for key, schema in schema_entries.items():
1✔
1211
        if key not in file_entries:
1✔
1212
            if require_all:
×
1213
                raise click.ClickException(
×
1214
                    f"input is missing namelist '{schema.get('x-fortran-namelist')}'"
1215
                )
1216
            continue
×
1217
        try:
1✔
1218
            validate_namelist(
1✔
1219
                schema,
1220
                file_entries[key][1],
1221
                constants=constants,
1222
                dimensions=dimensions,
1223
            )
1224
        except ValueError as exc:
×
1225
            raise click.ClickException(str(exc)) from exc
×
1226
        validated += 1
1✔
1227
    logger.info("Validation completed (%d namelist%s).", validated, "" if validated == 1 else "s")
1✔
1228

1229

1230
@cli.command("gen-markdown", context_settings=_CONTEXT_SETTINGS)
1✔
1231
@click.option(
1✔
1232
    "--config",
1233
    "config_path",
1234
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1235
    default=None,
1236
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1237
)
1238
def gen_markdown(config_path: Path | None) -> None:
1✔
1239
    """Generate Markdown docs."""
1240
    config, config_path = _load_config_checked(config_path)
1✔
1241
    logger.info("Loading config from %s", config_path)
1✔
1242
    base_dir = config_path.parent
1✔
1243
    constants, _ = _load_constants(config)
1✔
1244
    dimensions, _ = _load_dimensions(config, constants)
1✔
1245
    _, md_doxygen_id_from_name, md_add_toc_statement, _ = _load_documentation_settings(
1✔
1246
        config
1247
    )
1248
    entries = _iter_namelists(config, base_dir)
1✔
1249
    logger.info("Found %d schema entries", len(entries))
1✔
1250
    for entry in entries:
1✔
1251
        schema_path = entry["schema"]
1✔
1252
        if schema_path is None:
1✔
1253
            raise click.ClickException("namelists entry missing schema path")
×
1254
        try:
1✔
1255
            logger.info("Loading schema %s", schema_path)
1✔
1256
            schema = load_schema(schema_path)
1✔
1257
        except (FileNotFoundError, ValueError) as exc:
×
1258
            raise click.ClickException(str(exc)) from exc
×
1259
        doc_path = entry["doc_path"]
1✔
1260
        if doc_path is None:
1✔
1261
            continue
×
1262
        try:
1✔
1263
            logger.info("Generating Markdown docs at %s", doc_path)
1✔
1264
            generate_docs(
1✔
1265
                schema,
1266
                doc_path,
1267
                constants=constants,
1268
                dimensions=dimensions,
1269
                md_doxygen_id_from_name=md_doxygen_id_from_name,
1270
                md_add_toc_statement=md_add_toc_statement,
1271
            )
1272
        except ValueError as exc:
×
1273
            raise click.ClickException(str(exc)) from exc
×
1274

1275

1276
@cli.command("gen-template", context_settings=_CONTEXT_SETTINGS)
1✔
1277
@click.option(
1✔
1278
    "--config",
1279
    "config_path",
1280
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1281
    default=None,
1282
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1283
)
1284
def gen_template(config_path: Path | None) -> None:
1✔
1285
    """Generate template namelist(s)."""
1286
    config, config_path = _load_config_checked(config_path)
1✔
1287
    logger.info("Loading config from %s", config_path)
1✔
1288
    base_dir = config_path.parent
1✔
1289
    constants, _ = _load_constants(config)
1✔
1290
    dimensions, _ = _load_dimensions(config, constants)
1✔
1291
    _, kind_map, kind_allowlist = _load_kind_settings(config)
1✔
1292
    templates = _iter_templates(config, base_dir)
1✔
1293
    if not templates:
1✔
1294
        raise click.ClickException("config must define non-empty 'templates'")
×
1295
    logger.info("Found %d template entries", len(templates))
1✔
1296
    for entry in templates:
1✔
1297
        try:
1✔
1298
            schemas = []
1✔
1299
            for schema_path in entry["schemas"]:
1✔
1300
                logger.info("Loading schema %s", schema_path)
1✔
1301
                schemas.append(load_schema(schema_path))
1✔
1302
            logger.info("Generating template at %s", entry["output"])
1✔
1303
            generate_template(
1✔
1304
                schemas,
1305
                entry["output"],
1306
                doc_mode=entry["doc_mode"],
1307
                value_mode=entry["value_mode"],
1308
                constants=constants,
1309
                dimensions=dimensions,
1310
                kind_map=kind_map,
1311
                kind_allowlist=kind_allowlist,
1312
                values=entry["values"],
1313
            )
1314
        except (FileNotFoundError, ValueError) as exc:
×
1315
            raise click.ClickException(str(exc)) from exc
×
1316

1317

1318
def main(argv: list[str] | None = None) -> int:
1✔
1319
    """Entry point for the CLI."""
1320
    try:
×
1321
        cli.main(args=argv, prog_name="nml-tools", standalone_mode=False)
×
1322
    except Exit as exc:
×
1323
        return exc.exit_code
×
1324
    except click.ClickException as exc:
×
1325
        exc.show()
×
1326
        return 1
×
1327
    return 0
×
1328

1329

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