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

MuellerSeb / nml-tools / 26599959004

28 May 2026 08:20PM UTC coverage: 83.722% (+0.1%) from 83.612%
26599959004

Pull #34

github

MuellerSeb
Cover reserved identifier validation branches

Add focused tests for reserved separator validation in schema traversal, f2py kind collection, and shared config normalization.

These tests exercise the new validation paths added for generated-name collision prevention and recover the branch coverage lost by the recent stricter checks.
Pull Request #34: Use explicit defaults for runtime dimensions

102 of 109 new or added lines in 6 files covered. (93.58%)

14 existing lines in 4 files now uncovered.

3806 of 4546 relevant lines covered (83.72%)

0.84 hits per line

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

79.29
/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 collections.abc import Mapping
1✔
8
from dataclasses import dataclass
1✔
9
from difflib import unified_diff
1✔
10
from pathlib import Path
1✔
11
from typing import Any, cast
1✔
12

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

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

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

47
logger = logging.getLogger(__name__)
1✔
48

49

50
def _normalize_f90nml_values(value: Any) -> Any:
1✔
51
    """Remove parser bookkeeping while preserving nested derived values."""
52
    if isinstance(value, Mapping):
1✔
53
        return {
1✔
54
            key: _normalize_f90nml_values(item)
55
            for key, item in value.items()
56
            if not (isinstance(key, str) and key.lower() == "_start_index")
57
        }
58
    if isinstance(value, list):
1✔
59
        return [_normalize_f90nml_values(item) for item in value]
1✔
60
    return value
1✔
61
_CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
1✔
62
_DEFAULT_CONFIG = Path("nml-config.toml")
1✔
63
_PYPROJECT_CONFIG = Path("pyproject.toml")
1✔
64

65
_NamedIntegerTypeBase = cast(Any, click.ParamType)
1✔
66

67

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

71
    name = "NAME=INT"
1✔
72

73
    def __init__(self, *, label: str, positive: bool = False) -> None:
1✔
74
        self._label = label
1✔
75
        self._positive = positive
1✔
76

77
    def convert(
1✔
78
        self,
79
        value: Any,
80
        param: click.Parameter | None,
81
        ctx: click.Context | None,
82
    ) -> tuple[str, int]:
83
        if not isinstance(value, str) or "=" not in value:
1✔
84
            self.fail("must be NAME=INT", param, ctx)
1✔
85

86
        raw_name, raw_value = value.split("=", 1)
1✔
87
        name = raw_name.strip()
1✔
88
        if not name:
1✔
89
            self.fail("must use non-empty names", param, ctx)
×
90
        try:
1✔
91
            validate_user_fortran_identifier(name, label=f"{self._label} '{name}'")
1✔
92
        except ValueError as exc:
1✔
93
            self.fail(str(exc), param, ctx)
1✔
94

95
        value_text = raw_value.strip()
1✔
96
        if not value_text:
1✔
97
            self.fail(f"{self._label} '{name}' must define a value", param, ctx)
×
98
        value_digits = value_text[1:] if value_text[:1] in {"+", "-"} else value_text
1✔
99
        if not value_digits.isdigit():
1✔
100
            self.fail(f"{self._label} '{name}' value must be an integer", param, ctx)
1✔
101

102
        parsed_value = int(value_text)
1✔
103
        if self._positive and parsed_value <= 0:
1✔
104
            self.fail(f"{self._label} '{name}' value must be positive", param, ctx)
1✔
105
        return name.lower(), parsed_value
1✔
106

107

108
_CONSTANT_TYPE = NamedIntegerType(label="constant")
1✔
109
_DIMENSION_TYPE = NamedIntegerType(label="dimension", positive=True)
1✔
110

111

112
@dataclass(frozen=True)
1✔
113
class GeneratedOutput:
1✔
114
    """Generated file content for write/check commands."""
115

116
    path: Path
1✔
117
    content: str
1✔
118

119

120
def _configure_logging(verbose: int, quiet: int) -> None:
1✔
121
    base_level = logging.INFO
1✔
122
    level = base_level - (10 * verbose) + (10 * quiet)
1✔
123
    level = max(logging.DEBUG, min(logging.CRITICAL, level))
1✔
124
    logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
1✔
125

126

127
def _load_toml(path: Path) -> dict[str, Any]:
1✔
128
    with path.open("rb") as handle:
1✔
129
        data = tomllib.load(handle)
1✔
130
    if not isinstance(data, dict):
1✔
131
        raise click.ClickException("config must be a table")
×
132
    return data
1✔
133

134

135
def _load_toml_checked(path: Path) -> dict[str, Any]:
1✔
136
    try:
1✔
137
        return _load_toml(path)
1✔
138
    except FileNotFoundError as exc:
1✔
139
        raise click.ClickException(str(exc)) from exc
1✔
140
    except Exception as exc:  # pragma: no cover - tomllib may raise ValueError
141
        raise click.ClickException(f"failed to read config: {exc}") from exc
142

143

144
def _load_config_checked(path: Path | None) -> tuple[dict[str, Any], Path]:
1✔
145
    config_path = _resolve_config_path(path)
1✔
146
    raw_config = _load_toml_checked(config_path)
1✔
147
    if config_path.name == _PYPROJECT_CONFIG.name:
1✔
148
        config = _extract_pyproject_config(raw_config)
1✔
149
    else:
150
        config = raw_config
1✔
151
    _check_minimum_version(config)
1✔
152
    return config, config_path
1✔
153

154

155
def _resolve_config_path(path: Path | None) -> Path:
1✔
156
    if path is not None:
1✔
157
        return path
1✔
158
    if _DEFAULT_CONFIG.is_file():
1✔
159
        return _DEFAULT_CONFIG
1✔
160
    if _PYPROJECT_CONFIG.is_file():
1✔
161
        raw_config = _load_toml_checked(_PYPROJECT_CONFIG)
1✔
162
        if _has_pyproject_config(raw_config):
1✔
163
            return _PYPROJECT_CONFIG
1✔
164
    raise click.ClickException(
1✔
165
        "no config found; create nml-config.toml or add [tool.nml-tools] to pyproject.toml"
166
    )
167

168

169
def _has_pyproject_config(config: dict[str, Any]) -> bool:
1✔
170
    tool_raw = config.get("tool")
1✔
171
    return isinstance(tool_raw, dict) and isinstance(tool_raw.get("nml-tools"), dict)
1✔
172

173

174
def _extract_pyproject_config(config: dict[str, Any]) -> dict[str, Any]:
1✔
175
    tool_raw = config.get("tool")
1✔
176
    if not isinstance(tool_raw, dict):
1✔
177
        raise click.ClickException("pyproject.toml must define [tool.nml-tools]")
1✔
178
    nml_tools_raw = tool_raw.get("nml-tools")
1✔
179
    if not isinstance(nml_tools_raw, dict):
1✔
180
        raise click.ClickException("pyproject.toml must define [tool.nml-tools]")
1✔
181
    return nml_tools_raw
1✔
182

183

184
def _check_minimum_version(config: dict[str, Any]) -> None:
1✔
185
    minimum_raw = config.get("minimum-version")
1✔
186
    if minimum_raw is None:
1✔
187
        return
1✔
188
    if not isinstance(minimum_raw, str) or not minimum_raw.strip():
1✔
189
        raise click.ClickException("config 'minimum-version' must be a non-empty string")
1✔
190
    try:
1✔
191
        minimum = Version(minimum_raw.strip())
1✔
192
    except InvalidVersion as exc:
1✔
193
        raise click.ClickException(
1✔
194
            f"config 'minimum-version' is not a valid version: {minimum_raw}"
195
        ) from exc
196
    try:
1✔
197
        current = Version(__version__)
1✔
198
    except InvalidVersion as exc:  # pragma: no cover - package version should be valid
199
        raise click.ClickException(f"nml-tools version is not valid: {__version__}") from exc
200
    if current < minimum:
1✔
201
        raise click.ClickException(
1✔
202
            f"config requires nml-tools >= {minimum}, current version is {current}"
203
        )
204

205

206
def _resolve_optional_path(
1✔
207
    value: Any,
208
    *,
209
    base_dir: Path,
210
    key: str,
211
) -> Path | None:
212
    if value is None:
1✔
213
        return None
1✔
214
    if not isinstance(value, str):
1✔
215
        raise click.ClickException(f"config '{key}' must be a string")
×
216
    return base_dir / value
1✔
217

218

219
def _load_helper_settings(
1✔
220
    config: dict[str, Any],
221
    base_dir: Path,
222
) -> tuple[Path | None, str, int, str | None]:
223
    default_buffer = 1024
1✔
224
    helper_raw = config.get("helper")
1✔
225
    if helper_raw is None:
1✔
226
        helper_path = _resolve_optional_path(
1✔
227
            config.get("helper_path"),
228
            base_dir=base_dir,
229
            key="helper_path",
230
        )
231
        helper_module_raw = config.get("helper_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
        return helper_path, helper_module, default_buffer, None
1✔
238

239
    if not isinstance(helper_raw, dict):
1✔
240
        raise click.ClickException("config 'helper' must be a table")
×
241
    if "helper_path" in config or "helper_module" in config:
1✔
242
        logger.warning("config uses [helper]; ignoring legacy helper_path/helper_module")
×
243
    helper_path = _resolve_optional_path(
1✔
244
        helper_raw.get("path"),
245
        base_dir=base_dir,
246
        key="helper.path",
247
    )
248
    helper_module_raw = helper_raw.get("module", "nml_helper")
1✔
249
    if not isinstance(helper_module_raw, str):
1✔
250
        raise click.ClickException("config 'helper.module' must be a string")
×
251
    helper_module = helper_module_raw.strip()
1✔
252
    if not helper_module:
1✔
253
        raise click.ClickException("config 'helper.module' must be a non-empty string")
×
254
    header_raw = helper_raw.get("header")
1✔
255
    if header_raw is None:
1✔
256
        header = None
1✔
257
    else:
258
        if not isinstance(header_raw, str):
×
259
            raise click.ClickException("config 'helper.header' must be a string")
×
260
        header = header_raw.rstrip()
×
261
        if not header:
×
262
            header = None
×
263
    buffer_raw = helper_raw.get("buffer", default_buffer)
1✔
264
    if isinstance(buffer_raw, bool) or not isinstance(buffer_raw, int):
1✔
265
        raise click.ClickException("config 'helper.buffer' must be an integer")
×
266
    if buffer_raw <= 0:
1✔
267
        raise click.ClickException("config 'helper.buffer' must be positive")
×
268
    return helper_path, helper_module, buffer_raw, header
1✔
269

270

271
def _load_kind_settings(config: dict[str, Any]) -> tuple[str, dict[str, str], set[str]]:
1✔
272
    kinds_raw = config.get("kinds")
1✔
273
    if not isinstance(kinds_raw, dict):
1✔
274
        raise click.ClickException("config must define a [kinds] table")
×
275
    module_raw = kinds_raw.get("module")
1✔
276
    if not isinstance(module_raw, str) or not module_raw.strip():
1✔
277
        raise click.ClickException("config 'kinds.module' must be a non-empty string")
×
278
    module = module_raw.strip()
1✔
279

280
    map_raw = kinds_raw.get("map", {})
1✔
281
    if map_raw is None:
1✔
282
        map_raw = {}
×
283
    if not isinstance(map_raw, dict):
1✔
284
        raise click.ClickException("config 'kinds.map' must be a table")
×
285
    kind_map: dict[str, str] = {}
1✔
286
    for alias, target in map_raw.items():
1✔
287
        if not isinstance(alias, str) or not isinstance(target, str):
1✔
288
            raise click.ClickException("config 'kinds.map' keys and values must be strings")
×
289
        kind_map[alias] = target
1✔
290

291
    real_raw = kinds_raw.get("real", [])
1✔
292
    integer_raw = kinds_raw.get("integer", [])
1✔
293
    if not isinstance(real_raw, list) or not all(isinstance(item, str) for item in real_raw):
1✔
294
        raise click.ClickException("config 'kinds.real' must be a list of strings")
×
295
    if not isinstance(integer_raw, list) or not all(isinstance(item, str) for item in integer_raw):
1✔
296
        raise click.ClickException("config 'kinds.integer' must be a list of strings")
×
297
    allowlist = set(real_raw) | set(integer_raw)
1✔
298

299
    return module, kind_map, allowlist
1✔
300

301

302
def _load_f2py_settings(
1✔
303
    config: dict[str, Any],
304
    base_dir: Path,
305
) -> tuple[Path | None, F2pyCTypeMap]:
306
    f2py_raw = config.get("f2py")
1✔
307
    if f2py_raw is None:
1✔
308
        return None, F2pyCTypeMap(real={}, integer={})
1✔
309
    if not isinstance(f2py_raw, dict):
1✔
310
        raise click.ClickException("config 'f2py' must be a table")
×
311
    if "python_package" in f2py_raw:
1✔
312
        raise click.ClickException(
×
313
            "config 'f2py.python_package' is no longer supported; "
314
            "place the f2py extension next to the generated Python wrapper"
315
        )
316

317
    f2cmap_path = _resolve_optional_path(
1✔
318
        f2py_raw.get("f2cmap_path"),
319
        base_dir=base_dir,
320
        key="f2py.f2cmap_path",
321
    )
322

323
    c_types_raw = f2py_raw.get("c_types", {})
1✔
324
    if c_types_raw is None:
1✔
325
        c_types_raw = {}
×
326
    if not isinstance(c_types_raw, dict):
1✔
327
        raise click.ClickException("config 'f2py.c_types' must be a table")
×
328

329
    real = _load_f2py_ctype_table(c_types_raw, "real")
1✔
330
    integer = _load_f2py_ctype_table(c_types_raw, "integer")
1✔
331
    return f2cmap_path, F2pyCTypeMap(real=real, integer=integer)
1✔
332

333

334
def _load_f2py_ctype_table(c_types: dict[str, Any], key: str) -> dict[str, str]:
1✔
335
    raw = c_types.get(key, {})
1✔
336
    if raw is None:
1✔
337
        raw = {}
×
338
    if not isinstance(raw, dict):
1✔
339
        raise click.ClickException(f"config 'f2py.c_types.{key}' must be a table")
×
340
    values: dict[str, str] = {}
1✔
341
    for kind, c_type in raw.items():
1✔
342
        if not isinstance(kind, str) or not kind.strip():
1✔
343
            raise click.ClickException(
×
344
                f"config 'f2py.c_types.{key}' keys must be non-empty strings"
345
            )
346
        if not isinstance(c_type, str) or not c_type.strip():
1✔
347
            raise click.ClickException(
×
348
                f"config 'f2py.c_types.{key}.{kind}' must be a non-empty string"
349
            )
350
        values[kind.strip()] = c_type.strip()
1✔
351
    return values
1✔
352

353

354
def _format_constant_literal(value: int) -> tuple[str, str]:
1✔
355
    if isinstance(value, bool):
1✔
356
        raise click.ClickException("config constants must not be boolean")
×
357
    if isinstance(value, int):
1✔
358
        return "integer", str(value)
1✔
359
    raise click.ClickException("config constants must be integers")
×
360

361

362
def _load_constants(config: dict[str, Any]) -> tuple[dict[str, int], list[ConstantSpec]]:
1✔
363
    constants_raw = config.get("constants", {})
1✔
364
    if constants_raw is None:
1✔
365
        constants_raw = {}
×
366
    if not isinstance(constants_raw, dict):
1✔
367
        raise click.ClickException("config 'constants' must be a table")
×
368

369
    constants: dict[str, int] = {}
1✔
370
    specs: list[ConstantSpec] = []
1✔
371
    for name_raw, entry in constants_raw.items():
1✔
372
        if not isinstance(name_raw, str):
1✔
373
            raise click.ClickException("config constants must use string keys")
×
374
        name = name_raw.strip()
1✔
375
        if not name:
1✔
376
            raise click.ClickException("config constants must have non-empty names")
×
377
        try:
1✔
378
            validate_user_fortran_identifier(name, label=f"config constant '{name}'")
1✔
379
        except ValueError as exc:
1✔
380
            raise click.ClickException(str(exc)) from exc
1✔
381
        canonical_name = name.lower()
1✔
382
        if canonical_name in constants:
1✔
383
            raise click.ClickException(
1✔
384
                f"config constant '{name}' duplicates another constant name"
385
            )
386
        if not isinstance(entry, dict):
1✔
387
            raise click.ClickException(f"config constant '{name}' must be a table with 'value'")
×
388
        if "value" not in entry:
1✔
389
            raise click.ClickException(f"config constant '{name}' must define 'value'")
×
390
        value = entry.get("value")
1✔
391
        if isinstance(value, bool) or not isinstance(value, int):
1✔
392
            raise click.ClickException("config constants must be integers")
1✔
393
        type_spec, literal = _format_constant_literal(value)
1✔
394
        doc = entry.get("doc")
1✔
395
        if doc is not None:
1✔
396
            if not isinstance(doc, str):
1✔
397
                raise click.ClickException(f"config constant '{name}' doc must be a string")
×
398
            doc = " ".join(doc.splitlines()).strip() or None
1✔
399
        specs.append(
1✔
400
            ConstantSpec(
401
                name=canonical_name,
402
                type_spec=type_spec,
403
                value=literal,
404
                doc=doc,
405
            )
406
        )
407
        constants[canonical_name] = value
1✔
408
    return constants, specs
1✔
409

410

411
def _load_dimensions(
1✔
412
    config: dict[str, Any],
413
    constants: dict[str, int],
414
) -> tuple[dict[str, int], list[ConstantSpec]]:
415
    dimensions_raw = config.get("dimensions", {})
1✔
416
    if dimensions_raw is None:
1✔
417
        dimensions_raw = {}
×
418
    if not isinstance(dimensions_raw, dict):
1✔
419
        raise click.ClickException("config 'dimensions' must be a table")
×
420

421
    dimensions: dict[str, int] = {}
1✔
422
    specs: list[ConstantSpec] = []
1✔
423
    constant_names = {name.lower() for name in constants}
1✔
424
    for name_raw, entry in dimensions_raw.items():
1✔
425
        if not isinstance(name_raw, str):
1✔
426
            raise click.ClickException("config dimensions must use string keys")
×
427
        name = name_raw.strip()
1✔
428
        if not name:
1✔
429
            raise click.ClickException("config dimensions must have non-empty names")
×
430
        try:
1✔
431
            validate_user_fortran_identifier(name, label=f"config dimension '{name}'")
1✔
432
        except ValueError as exc:
1✔
433
            raise click.ClickException(str(exc)) from exc
1✔
434
        canonical_name = name.lower()
1✔
435
        if canonical_name in constant_names:
1✔
436
            raise click.ClickException(
1✔
437
                f"config dimension '{name}' duplicates a constant name"
438
            )
439
        if canonical_name in dimensions:
1✔
440
            raise click.ClickException(
1✔
441
                f"config dimension '{name}' duplicates another dimension name"
442
            )
443
        default_name = f"{canonical_name}__default"
1✔
444
        if default_name in constant_names:
1✔
445
            raise click.ClickException(
1✔
446
                f"config dimension '{name}' default name duplicates a constant name"
447
            )
448
        if not isinstance(entry, dict):
1✔
NEW
449
            raise click.ClickException(f"config dimension '{name}' must be a table with 'default'")
×
450
        if "value" in entry:
1✔
451
            raise click.ClickException(
1✔
452
                f"config dimension '{name}' must use 'default', not 'value'"
453
            )
454
        if "default" not in entry:
1✔
NEW
455
            raise click.ClickException(f"config dimension '{name}' must define 'default'")
×
456
        value = entry.get("default")
1✔
457
        if isinstance(value, bool) or not isinstance(value, int):
1✔
NEW
458
            raise click.ClickException(f"config dimension '{name}' default must be an integer")
×
459
        if value <= 0:
1✔
460
            raise click.ClickException(f"config dimension '{name}' default must be positive")
1✔
461
        doc = entry.get("doc")
1✔
462
        if doc is not None:
1✔
463
            if not isinstance(doc, str):
1✔
464
                raise click.ClickException(f"config dimension '{name}' doc must be a string")
×
465
            doc = " ".join(doc.splitlines()).strip() or None
1✔
466
        specs.append(
1✔
467
            ConstantSpec(
468
                name=default_name,
469
                type_spec="integer",
470
                value=str(value),
471
                doc=doc,
472
            )
473
        )
474
        dimensions[canonical_name] = value
1✔
475
    return dimensions, specs
1✔
476

477

478
def _load_bool_field(section: dict[str, Any], key: str, *, label: str) -> bool:
1✔
479
    value = section.get(key, False)
×
480
    if isinstance(value, bool):
×
481
        return value
×
482
    raise click.ClickException(f"config '{label}' must be a boolean")
×
483

484

485
def _load_documentation_settings(
1✔
486
    config: dict[str, Any],
487
) -> tuple[str | None, bool, bool, str]:
488
    doc_raw = config.get("documentation")
1✔
489
    if doc_raw is None:
1✔
490
        return None, False, False, "numpy"
1✔
491
    if not isinstance(doc_raw, dict):
×
492
        raise click.ClickException("config 'documentation' must be a table")
×
493
    module_doc = None
×
494
    if "module" in doc_raw:
×
495
        module_raw = doc_raw.get("module")
×
496
        if not isinstance(module_raw, str):
×
497
            raise click.ClickException("config 'documentation.module' must be a string")
×
498
        module_doc = module_raw.strip() or None
×
499
    md_doxygen_id_from_name = _load_bool_field(
×
500
        doc_raw,
501
        "md_doxygen_id_from_name",
502
        label="documentation.md_doxygen_id_from_name",
503
    )
504
    md_add_toc_statement = _load_bool_field(
×
505
        doc_raw,
506
        "md_add_toc_statement",
507
        label="documentation.md_add_toc_statement",
508
    )
509
    py_style_raw = doc_raw.get("py-style", "numpy")
×
510
    if not isinstance(py_style_raw, str):
×
511
        raise click.ClickException("config 'documentation.py-style' must be a string")
×
512
    py_style = py_style_raw.strip().lower()
×
513
    if py_style not in {"numpy", "doxygen"}:
×
514
        raise click.ClickException(
×
515
            "config 'documentation.py-style' must be 'numpy' or 'doxygen'"
516
        )
517
    return module_doc, md_doxygen_id_from_name, md_add_toc_statement, py_style
×
518

519

520
def _parse_cli_constants(values: tuple[tuple[str, int], ...]) -> dict[str, int]:
1✔
521
    constants: dict[str, int] = {}
1✔
522
    for name, value in values:
1✔
523
        if name in constants:
1✔
524
            raise click.ClickException(f"constant '{name}' duplicates another constant name")
1✔
525
        constants[name] = value
1✔
526
    return constants
1✔
527

528

529
def _parse_cli_dimensions(values: tuple[tuple[str, int], ...]) -> dict[str, int]:
1✔
530
    dimensions: dict[str, int] = {}
1✔
531
    for name, value in values:
1✔
532
        if name in dimensions:
1✔
533
            raise click.ClickException(f"dimension '{name}' duplicates another dimension name")
1✔
534
        if value <= 0:
1✔
535
            raise click.ClickException(f"dimension '{name}' value must be positive")
×
536
        dimensions[name] = value
1✔
537
    return dimensions
1✔
538

539

540
def _reject_constant_dimension_overlap(
1✔
541
    constants: dict[str, int],
542
    dimensions: dict[str, int],
543
) -> None:
544
    duplicate_names = constant_dimension_overlap(constants, dimensions)
1✔
545
    if duplicate_names:
1✔
546
        raise click.ClickException(
×
547
            "constants and dimensions must not share names: " + ", ".join(duplicate_names)
548
        )
549

550

551
def _iter_namelists(config: dict[str, Any], base_dir: Path) -> list[dict[str, Any]]:
1✔
552
    raw_entries = config.get("namelists")
1✔
553
    if raw_entries is None and "nml-files" in config:
1✔
554
        raise click.ClickException("config uses deprecated 'nml-files'; rename to 'namelists'")
×
555
    if not isinstance(raw_entries, list) or not raw_entries:
1✔
556
        raise click.ClickException("config must define non-empty 'namelists'")
×
557

558
    entries: list[dict[str, Path | None]] = []
1✔
559
    for entry in raw_entries:
1✔
560
        if not isinstance(entry, dict):
1✔
561
            raise click.ClickException("each namelists entry must be a table")
×
562
        schema_raw = entry.get("schema")
1✔
563
        if not isinstance(schema_raw, str):
1✔
564
            raise click.ClickException("namelists entry must define string 'schema'")
×
565
        schema_path = base_dir / schema_raw
1✔
566
        f2py_path = _resolve_optional_path(
1✔
567
            entry.get("f2py_path"),
568
            base_dir=base_dir,
569
            key="f2py_path",
570
        )
571
        py_path = _resolve_optional_path(
1✔
572
            entry.get("py_path"),
573
            base_dir=base_dir,
574
            key="py_path",
575
        )
576
        mod_path = _resolve_optional_path(
1✔
577
            entry.get("mod_path"),
578
            base_dir=base_dir,
579
            key="mod_path",
580
        )
581
        if f2py_path is not None and mod_path is None:
1✔
582
            raise click.ClickException(
×
583
                "namelists entry with 'f2py_path' must define 'mod_path'"
584
            )
585
        if py_path is not None and f2py_path is None:
1✔
586
            raise click.ClickException("namelists entry with 'py_path' must define 'f2py_path'")
×
587
        entries.append(
1✔
588
            {
589
                "schema": schema_path,
590
                "mod_path": mod_path,
591
                "doc_path": _resolve_optional_path(
592
                    entry.get("doc_path"),
593
                    base_dir=base_dir,
594
                    key="doc_path",
595
                ),
596
                "temp_path": _resolve_optional_path(
597
                    entry.get("temp_path"),
598
                    base_dir=base_dir,
599
                    key="temp_path",
600
                ),
601
                "f2py_path": f2py_path,
602
                "py_path": py_path,
603
            }
604
        )
605
    return entries
1✔
606

607

608
def _iter_templates(config: dict[str, Any], base_dir: Path) -> list[dict[str, Any]]:
1✔
609
    raw_entries = config.get("templates")
1✔
610
    if raw_entries is None:
1✔
611
        return []
1✔
612
    if not isinstance(raw_entries, list) or not raw_entries:
1✔
613
        raise click.ClickException("config 'templates' must be a non-empty list")
×
614

615
    entries: list[dict[str, Any]] = []
1✔
616
    for entry in raw_entries:
1✔
617
        if not isinstance(entry, dict):
1✔
618
            raise click.ClickException("each templates entry must be a table")
×
619
        output_raw = entry.get("output")
1✔
620
        if not isinstance(output_raw, str):
1✔
621
            raise click.ClickException("templates entry must define string 'output'")
×
622
        output_path = base_dir / output_raw
1✔
623

624
        schemas_raw = entry.get("schemas")
1✔
625
        if not isinstance(schemas_raw, list) or not schemas_raw:
1✔
626
            raise click.ClickException("templates entry must define non-empty 'schemas'")
×
627
        if not all(isinstance(item, str) for item in schemas_raw):
1✔
628
            raise click.ClickException("templates 'schemas' entries must be strings")
×
629
        schema_paths = [base_dir / item for item in schemas_raw]
1✔
630

631
        doc_mode = entry.get("doc_mode", "plain")
1✔
632
        if not isinstance(doc_mode, str):
1✔
633
            raise click.ClickException("templates 'doc_mode' must be a string")
×
634
        value_mode = entry.get("value_mode", "empty")
1✔
635
        if not isinstance(value_mode, str):
1✔
636
            raise click.ClickException("templates 'value_mode' must be a string")
×
637
        values_raw = entry.get("values", {})
1✔
638
        if values_raw is None:
1✔
639
            values_raw = {}
×
640
        if not isinstance(values_raw, dict):
1✔
641
            raise click.ClickException("templates 'values' must be a table")
×
642

643
        entries.append(
1✔
644
            {
645
                "output": output_path,
646
                "schemas": schema_paths,
647
                "doc_mode": doc_mode,
648
                "value_mode": value_mode,
649
                "values": values_raw,
650
            }
651
        )
652
    return entries
1✔
653

654

655
def _collect_generated_outputs(
1✔
656
    config: dict[str, Any],
657
    config_path: Path,
658
) -> list[GeneratedOutput]:
659
    base_dir = config_path.parent
1✔
660
    logger.debug("Base directory: %s", base_dir)
1✔
661
    helper_path, helper_module, helper_buffer, helper_header = _load_helper_settings(
1✔
662
        config,
663
        base_dir,
664
    )
665
    (
1✔
666
        module_doc,
667
        md_doxygen_id_from_name,
668
        md_add_toc_statement,
669
        py_style,
670
    ) = _load_documentation_settings(config)
671
    constants, constant_specs = _load_constants(config)
1✔
672
    dimensions, dimension_specs = _load_dimensions(config, constants)
1✔
673
    kind_module, kind_map, kind_allowlist = _load_kind_settings(config)
1✔
674
    f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir)
1✔
675
    resolver = SchemaResolver()
1✔
676
    outputs: list[GeneratedOutput] = []
1✔
677

678
    entries = _iter_namelists(config, base_dir)
1✔
679
    logger.debug("Found %d schema entries", len(entries))
1✔
680
    loaded_entries: list[dict[str, Any]] = []
1✔
681
    for namelist_entry in entries:
1✔
682
        schema_path = namelist_entry["schema"]
1✔
683
        if schema_path is None:
1✔
684
            raise click.ClickException("namelists entry missing schema path")
×
685
        try:
1✔
686
            logger.debug("Loading schema %s", schema_path)
1✔
687
            schema = load_schema(schema_path, resolver=resolver)
1✔
688
        except (FileNotFoundError, ValueError) as exc:
×
689
            raise click.ClickException(str(exc)) from exc
×
690
        loaded_entries.append({"entry": namelist_entry, "schema": schema})
1✔
691

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

716
        doc_path = namelist_entry["doc_path"]
1✔
717
        if doc_path is not None:
1✔
718
            try:
1✔
719
                logger.debug("Rendering Markdown docs at %s", doc_path)
1✔
720
                outputs.append(
1✔
721
                    GeneratedOutput(
722
                        doc_path,
723
                        render_docs(
724
                            schema,
725
                            constants=constants,
726
                            dimensions=dimensions,
727
                            md_doxygen_id_from_name=md_doxygen_id_from_name,
728
                            md_add_toc_statement=md_add_toc_statement,
729
                        ),
730
                    )
731
                )
732
            except ValueError as exc:
×
733
                raise click.ClickException(str(exc)) from exc
×
734

735
    try:
1✔
736
        local_derived_types = collect_local_derived_types(
1✔
737
            [loaded["schema"] for loaded in loaded_entries],
738
            constants=constants,
739
        )
740
        if local_derived_types and helper_path is None:
1✔
741
            raise ValueError("locally generated derived types require a configured helper output")
×
742
        if helper_path is not None:
1✔
743
            logger.debug("Rendering helper module at %s", helper_path)
1✔
744
            outputs.append(
1✔
745
                GeneratedOutput(
746
                    helper_path,
747
                    render_helper(
748
                        file_name=helper_path.name,
749
                        module_name=helper_module,
750
                        len_buf=helper_buffer,
751
                        constants=constant_specs + dimension_specs,
752
                        local_derived_types=local_derived_types,
753
                        kind_module=kind_module,
754
                        kind_map=kind_map,
755
                        kind_allowlist=kind_allowlist,
756
                        module_doc=module_doc,
757
                        helper_header=helper_header,
758
                    ),
759
                )
760
            )
761
    except ValueError as exc:
×
762
        raise click.ClickException(str(exc)) from exc
×
763

764
    outputs.extend(
1✔
765
        _collect_f2py_outputs(
766
            loaded_entries,
767
            helper_module=helper_module,
768
            helper_buffer=helper_buffer,
769
            kind_module=kind_module,
770
            kind_map=kind_map,
771
            kind_allowlist=kind_allowlist,
772
            constants=constants,
773
            dimensions=dimensions,
774
            f2cmap_path=f2cmap_path,
775
            f2py_c_types=f2py_c_types,
776
            py_style=py_style,
777
            include_python=True,
778
        )
779
    )
780

781
    template_entries = _iter_templates(config, base_dir)
1✔
782
    if template_entries:
1✔
783
        logger.debug("Found %d template entries", len(template_entries))
1✔
784
    for template_entry in template_entries:
1✔
785
        try:
1✔
786
            schemas = []
1✔
787
            for schema_path in template_entry["schemas"]:
1✔
788
                logger.debug("Loading schema %s", schema_path)
1✔
789
                schemas.append(load_schema(schema_path, resolver=resolver))
1✔
790
            logger.debug("Rendering template at %s", template_entry["output"])
1✔
791
            outputs.append(
1✔
792
                GeneratedOutput(
793
                    template_entry["output"],
794
                    render_template(
795
                        schemas,
796
                        doc_mode=template_entry["doc_mode"],
797
                        value_mode=template_entry["value_mode"],
798
                        constants=constants,
799
                        dimensions=dimensions,
800
                        kind_map=kind_map,
801
                        kind_allowlist=kind_allowlist,
802
                        values=template_entry["values"],
803
                    ),
804
                )
805
            )
806
        except (FileNotFoundError, ValueError) as exc:
×
807
            raise click.ClickException(str(exc)) from exc
×
808

809
    return outputs
1✔
810

811

812
def _collect_f2py_outputs(
1✔
813
    loaded_entries: list[dict[str, Any]],
814
    *,
815
    helper_module: str,
816
    helper_buffer: int,
817
    kind_module: str,
818
    kind_map: dict[str, str],
819
    kind_allowlist: set[str],
820
    constants: dict[str, int],
821
    dimensions: dict[str, int],
822
    f2cmap_path: Path | None,
823
    f2py_c_types: F2pyCTypeMap,
824
    py_style: str,
825
    include_python: bool,
826
) -> list[GeneratedOutput]:
827
    outputs: list[GeneratedOutput] = []
1✔
828
    f2py_groups: dict[Path, list[dict[str, Any]]] = {}
1✔
829
    py_groups: dict[Path, list[tuple[dict[str, Any], Path]]] = {}
1✔
830
    for loaded in loaded_entries:
1✔
831
        entry = loaded["entry"]
1✔
832
        schema = loaded["schema"]
1✔
833
        f2py_path = entry["f2py_path"]
1✔
834
        if f2py_path is None:
1✔
835
            continue
1✔
836
        f2py_groups.setdefault(f2py_path, []).append(schema)
1✔
837
        py_path = entry["py_path"]
1✔
838
        if include_python and py_path is not None:
1✔
839
            py_groups.setdefault(py_path, []).append((schema, f2py_path))
1✔
840

841
    for f2py_path, schemas in f2py_groups.items():
1✔
842
        try:
1✔
843
            logger.debug("Rendering f2py wrappers at %s", f2py_path)
1✔
844
            outputs.append(
1✔
845
                GeneratedOutput(
846
                    f2py_path,
847
                    render_f2py_wrappers(
848
                        schemas,
849
                        file_name=f2py_path.name,
850
                        helper_module=helper_module,
851
                        kind_module=kind_module,
852
                        kind_map=kind_map,
853
                        kind_allowlist=kind_allowlist,
854
                        constants=constants,
855
                        dimensions=dimensions,
856
                        errmsg_len=helper_buffer,
857
                    ),
858
                )
859
            )
860
        except ValueError as exc:
×
861
            raise click.ClickException(str(exc)) from exc
×
862

863
    if f2cmap_path is not None:
1✔
864
        try:
1✔
865
            usage = merge_f2py_kind_usage(
1✔
866
                collect_f2py_kind_usage(schemas, constants=constants, dimensions=dimensions)
867
                for schemas in f2py_groups.values()
868
            )
869
            logger.debug("Rendering f2py kind map at %s", f2cmap_path)
1✔
870
            outputs.append(
1✔
871
                GeneratedOutput(
872
                    f2cmap_path,
873
                    render_f2cmap(usage, f2py_c_types),
874
                )
875
            )
876
        except ValueError as exc:
×
877
            raise click.ClickException(str(exc)) from exc
×
878

879
    if not include_python:
1✔
880
        return outputs
1✔
881
    for py_path, entries in py_groups.items():
1✔
882
        try:
1✔
883
            logger.debug("Rendering Python f2py wrappers at %s", py_path)
1✔
884
            specs = [
1✔
885
                (
886
                    build_f2py_namelist_spec(
887
                        schema,
888
                        helper_module=helper_module,
889
                        kind_module=kind_module,
890
                        kind_map=kind_map,
891
                        kind_allowlist=kind_allowlist,
892
                        constants=constants,
893
                        dimensions=dimensions,
894
                        errmsg_len=helper_buffer,
895
                    ),
896
                    f2py_path.stem,
897
                )
898
                for schema, f2py_path in entries
899
            ]
900
            outputs.append(
1✔
901
                GeneratedOutput(
902
                    py_path,
903
                    render_python_wrappers(
904
                        specs,
905
                        py_style=py_style,
906
                    ),
907
                )
908
            )
909
        except ValueError as exc:
×
910
            raise click.ClickException(str(exc)) from exc
×
911
    return outputs
1✔
912

913

914
def _generate_f2py_outputs(
1✔
915
    loaded_entries: list[dict[str, Any]],
916
    *,
917
    helper_module: str,
918
    helper_buffer: int,
919
    kind_module: str,
920
    kind_map: dict[str, str],
921
    kind_allowlist: set[str],
922
    constants: dict[str, int],
923
    dimensions: dict[str, int],
924
    f2cmap_path: Path | None,
925
    f2py_c_types: F2pyCTypeMap,
926
    py_style: str,
927
    include_python: bool,
928
) -> None:
929
    _write_generated_outputs(
1✔
930
        _collect_f2py_outputs(
931
            loaded_entries,
932
            helper_module=helper_module,
933
            helper_buffer=helper_buffer,
934
            kind_module=kind_module,
935
            kind_map=kind_map,
936
            kind_allowlist=kind_allowlist,
937
            constants=constants,
938
            dimensions=dimensions,
939
            f2cmap_path=f2cmap_path,
940
            f2py_c_types=f2py_c_types,
941
            py_style=py_style,
942
            include_python=include_python,
943
        )
944
    )
945

946

947
def _write_generated_outputs(outputs: list[GeneratedOutput]) -> None:
1✔
948
    for output in outputs:
1✔
949
        logger.info("Writing generated file %s", output.path)
1✔
950
        output.path.parent.mkdir(parents=True, exist_ok=True)
1✔
951
        output.path.write_text(output.content, encoding="ascii")
1✔
952

953

954
def _check_generated_outputs(outputs: list[GeneratedOutput], *, show_diff: bool) -> int:
1✔
955
    failed = 0
1✔
956
    for output in outputs:
1✔
957
        if not output.path.exists():
1✔
958
            failed += 1
1✔
959
            click.echo(f"MISSING: {output.path}", err=True)
1✔
960
            continue
1✔
961
        current = output.path.read_text(encoding="ascii")
1✔
962
        if current != output.content:
1✔
963
            failed += 1
1✔
964
            click.echo(f"DIFF: {output.path}", err=True)
1✔
965
            if show_diff:
1✔
966
                diff = unified_diff(
1✔
967
                    current.splitlines(keepends=True),
968
                    output.content.splitlines(keepends=True),
969
                    fromfile=f"current {output.path}",
970
                    tofile=f"generated {output.path}",
971
                )
972
                for line in diff:
1✔
973
                    click.echo(line, nl=False)
1✔
974
            continue
1✔
975
        logger.debug("OK: %s", output.path)
1✔
976
    return failed
1✔
977

978

979
@click.group(context_settings=_CONTEXT_SETTINGS)
1✔
980
@click.version_option(__version__, "-V", "--version", prog_name="nml-tools")
1✔
981
@click.option("--verbose", "-v", count=True, help="Increase verbosity (repeatable).")
1✔
982
@click.option("--quiet", "-q", count=True, help="Decrease verbosity (repeatable).")
1✔
983
def cli(verbose: int, quiet: int) -> None:
1✔
984
    """nml-tools command line interface."""
985
    _configure_logging(verbose, quiet)
1✔
986

987

988
@cli.command("generate", context_settings=_CONTEXT_SETTINGS)
1✔
989
@click.option(
1✔
990
    "--config",
991
    "config_path",
992
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
993
    default=None,
994
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
995
)
996
def generate(config_path: Path | None) -> None:
1✔
997
    """Generate outputs from a configuration file."""
998
    config, config_path = _load_config_checked(config_path)
1✔
999
    logger.info("Loading config from %s", config_path)
1✔
1000
    _write_generated_outputs(_collect_generated_outputs(config, config_path))
1✔
1001

1002

1003
@cli.command("check", context_settings=_CONTEXT_SETTINGS)
1✔
1004
@click.option(
1✔
1005
    "--config",
1006
    "config_path",
1007
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1008
    default=None,
1009
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1010
)
1011
@click.option(
1✔
1012
    "--diff",
1013
    "show_diff",
1014
    is_flag=True,
1015
    help="Show unified diffs for generated files that differ.",
1016
)
1017
def check(config_path: Path | None, show_diff: bool) -> None:
1✔
1018
    """Check that configured generated files are up to date."""
1019
    config, config_path = _load_config_checked(config_path)
1✔
1020
    logger.debug("Loading config from %s", config_path)
1✔
1021
    failures = _check_generated_outputs(
1✔
1022
        _collect_generated_outputs(config, config_path),
1023
        show_diff=show_diff,
1024
    )
1025
    if failures:
1✔
1026
        raise click.ClickException(
1✔
1027
            f"generated files are out of date: {failures} file(s) differ or are missing"
1028
        )
1029

1030

1031
@cli.command("gen-fortran", context_settings=_CONTEXT_SETTINGS)
1✔
1032
@click.option(
1✔
1033
    "--config",
1034
    "config_path",
1035
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1036
    default=None,
1037
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1038
)
1039
def gen_fortran(config_path: Path | None) -> None:
1✔
1040
    """Generate Fortran module(s)."""
1041
    config, config_path = _load_config_checked(config_path)
1✔
1042
    logger.info("Loading config from %s", config_path)
1✔
1043
    base_dir = config_path.parent
1✔
1044
    logger.debug("Base directory: %s", base_dir)
1✔
1045
    helper_path, helper_module, helper_buffer, helper_header = _load_helper_settings(
1✔
1046
        config,
1047
        base_dir,
1048
    )
1049
    module_doc, _, _, py_style = _load_documentation_settings(config)
1✔
1050
    constants, constant_specs = _load_constants(config)
1✔
1051
    dimensions, dimension_specs = _load_dimensions(config, constants)
1✔
1052
    kind_module, kind_map, kind_allowlist = _load_kind_settings(config)
1✔
1053
    f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir)
1✔
1054
    resolver = SchemaResolver()
1✔
1055
    entries = _iter_namelists(config, base_dir)
1✔
1056
    logger.info("Found %d schema entries", len(entries))
1✔
1057
    loaded_entries: list[dict[str, Any]] = []
1✔
1058
    for entry in entries:
1✔
1059
        schema_path = entry["schema"]
1✔
1060
        if schema_path is None:
1✔
1061
            raise click.ClickException("namelists entry missing schema path")
×
1062
        try:
1✔
1063
            logger.info("Loading schema %s", schema_path)
1✔
1064
            schema = load_schema(schema_path, resolver=resolver)
1✔
1065
        except (FileNotFoundError, ValueError) as exc:
×
1066
            raise click.ClickException(str(exc)) from exc
×
1067
        loaded_entries.append({"entry": entry, "schema": schema})
1✔
1068
        mod_path = entry["mod_path"]
1✔
1069
        if mod_path is None:
1✔
1070
            continue
×
1071
        try:
1✔
1072
            logger.info("Generating Fortran module at %s", mod_path)
1✔
1073
            generate_fortran(
1✔
1074
                schema,
1075
                mod_path,
1076
                helper_module=helper_module,
1077
                kind_module=kind_module,
1078
                kind_map=kind_map,
1079
                kind_allowlist=kind_allowlist,
1080
                constants=constants,
1081
                dimensions=dimensions,
1082
                module_doc=module_doc,
1083
                f2py_handle_helpers=entry["f2py_path"] is not None,
1084
            )
1085
        except ValueError as exc:
×
1086
            raise click.ClickException(str(exc)) from exc
×
1087

1088
    try:
1✔
1089
        local_derived_types = collect_local_derived_types(
1✔
1090
            [loaded["schema"] for loaded in loaded_entries],
1091
            constants=constants,
1092
        )
1093
        if local_derived_types and helper_path is None:
1✔
1094
            raise ValueError("locally generated derived types require a configured helper output")
×
1095
        if helper_path is not None:
1✔
1096
            logger.info("Generating helper module at %s", helper_path)
×
1097
            generate_helper(
×
1098
                helper_path,
1099
                module_name=helper_module,
1100
                len_buf=helper_buffer,
1101
                constants=constant_specs + dimension_specs,
1102
                local_derived_types=local_derived_types,
1103
                kind_module=kind_module,
1104
                kind_map=kind_map,
1105
                kind_allowlist=kind_allowlist,
1106
                module_doc=module_doc,
1107
                helper_header=helper_header,
1108
            )
1109
    except ValueError as exc:
×
1110
        raise click.ClickException(str(exc)) from exc
×
1111

1112
    _generate_f2py_outputs(
1✔
1113
        loaded_entries,
1114
        helper_module=helper_module,
1115
        helper_buffer=helper_buffer,
1116
        kind_module=kind_module,
1117
        kind_map=kind_map,
1118
        kind_allowlist=kind_allowlist,
1119
        constants=constants,
1120
        dimensions=dimensions,
1121
        f2cmap_path=f2cmap_path,
1122
        f2py_c_types=f2py_c_types,
1123
        py_style=py_style,
1124
        include_python=False,
1125
    )
1126

1127

1128
@cli.command("validate", context_settings=_CONTEXT_SETTINGS)
1✔
1129
@click.option(
1✔
1130
    "--config",
1131
    "config_path",
1132
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1133
    default=None,
1134
)
1135
@click.option(
1✔
1136
    "--schema",
1137
    "schema_paths",
1138
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1139
    multiple=True,
1140
)
1141
@click.option(
1✔
1142
    "--input",
1143
    "input_option",
1144
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1145
)
1146
@click.option(
1✔
1147
    "--constants",
1148
    "constant_args",
1149
    metavar="NAME=INT",
1150
    type=_CONSTANT_TYPE,
1151
    multiple=True,
1152
    help="Additional integer constants as NAME=INT (repeatable).",
1153
)
1154
@click.option(
1✔
1155
    "--dimensions",
1156
    "dimension_args",
1157
    metavar="NAME=INT",
1158
    type=_DIMENSION_TYPE,
1159
    multiple=True,
1160
    help="Runtime dimensions as NAME=INT (repeatable).",
1161
)
1162
@click.argument(
1✔
1163
    "input_path",
1164
    required=False,
1165
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1166
)
1167
def validate(
1✔
1168
    config_path: Path | None,
1169
    schema_paths: tuple[Path, ...],
1170
    input_option: Path | None,
1171
    constant_args: tuple[tuple[str, int], ...],
1172
    dimension_args: tuple[tuple[str, int], ...],
1173
    input_path: Path | None,
1174
) -> None:
1175
    """Validate a namelist file against schema definitions."""
1176
    if input_option is not None and input_path is not None:
1✔
1177
        raise click.ClickException("input path provided twice")
×
1178
    input_path = input_option or input_path
1✔
1179
    if input_path is None:
1✔
1180
        raise click.ClickException("input path is required")
×
1181
    constants = _parse_cli_constants(constant_args)
1✔
1182
    dimension_overrides = _parse_cli_dimensions(dimension_args)
1✔
1183
    dimensions: dict[str, int] = {}
1✔
1184
    schemas: list[dict[str, Any]] = []
1✔
1185
    resolver = SchemaResolver()
1✔
1186

1187
    if schema_paths:
1✔
1188
        if config_path is not None:
1✔
1189
            config, config_path = _load_config_checked(config_path)
×
1190
            logger.info("Loading config from %s", config_path)
×
1191
            cfg_constants, _ = _load_constants(config)
×
1192
            dimensions, _ = _load_dimensions(config, cfg_constants)
×
1193
            constants = {**cfg_constants, **constants}
×
1194
            dimensions = {**dimensions, **dimension_overrides}
×
1195
        else:
1196
            dimensions = dimension_overrides
1✔
1197
        for schema_file in schema_paths:
1✔
1198
            try:
1✔
1199
                logger.info("Loading schema %s", schema_file)
1✔
1200
                schemas.append(load_schema(schema_file, resolver=resolver))
1✔
1201
            except (FileNotFoundError, ValueError) as exc:
×
1202
                raise click.ClickException(str(exc)) from exc
×
1203
        require_all = True
1✔
1204
    else:
1205
        config, config_path = _load_config_checked(config_path)
1✔
1206
        logger.info("Loading config from %s", config_path)
1✔
1207
        base_dir = config_path.parent
1✔
1208
        cfg_constants, _ = _load_constants(config)
1✔
1209
        dimensions, _ = _load_dimensions(config, cfg_constants)
1✔
1210
        constants = {**cfg_constants, **constants}
1✔
1211
        dimensions = {**dimensions, **dimension_overrides}
1✔
1212
        entries = _iter_namelists(config, base_dir)
1✔
1213
        logger.info("Found %d schema entries", len(entries))
1✔
1214
        for entry in entries:
1✔
1215
            schema_path = entry["schema"]
1✔
1216
            if schema_path is None:
1✔
1217
                raise click.ClickException("namelists entry missing schema path")
×
1218
            try:
1✔
1219
                logger.info("Loading schema %s", schema_path)
1✔
1220
                schemas.append(load_schema(schema_path, resolver=resolver))
1✔
1221
            except (FileNotFoundError, ValueError) as exc:
×
1222
                raise click.ClickException(str(exc)) from exc
×
1223
        require_all = False
1✔
1224

1225
    if not schemas:
1✔
1226
        raise click.ClickException("no schemas provided for validation")
×
1227
    _reject_constant_dimension_overlap(constants, dimensions)
1✔
1228

1229
    try:
1✔
1230
        logger.info("Reading namelist %s", input_path)
1✔
1231
        namelist_file = f90nml.read(input_path)
1✔
1232
    except Exception as exc:  # pragma: no cover - f90nml raises custom errors
1233
        raise click.ClickException(f"failed to read namelist: {exc}") from exc
1234

1235
    file_entries: dict[str, tuple[str, Any]] = {}
1✔
1236
    for name, values in namelist_file.items():
1✔
1237
        if not isinstance(name, str):
1✔
1238
            raise click.ClickException("namelist names must be strings")
×
1239
        key = name.lower()
1✔
1240
        if key in file_entries:
1✔
1241
            raise click.ClickException(f"namelist '{name}' appears multiple times")
×
1242
        file_entries[key] = (name, values)
1✔
1243

1244
    schema_entries: dict[str, dict[str, Any]] = {}
1✔
1245
    for schema in schemas:
1✔
1246
        namelist_name = schema.get("x-fortran-namelist")
1✔
1247
        if not isinstance(namelist_name, str) or not namelist_name.strip():
1✔
1248
            raise click.ClickException("schema must define non-empty 'x-fortran-namelist'")
×
1249
        key = namelist_name.lower()
1✔
1250
        if key in schema_entries:
1✔
1251
            raise click.ClickException(f"duplicate schema for namelist '{namelist_name}'")
×
1252
        schema_entries[key] = schema
1✔
1253

1254
    for key, (name, _) in file_entries.items():
1✔
1255
        if key not in schema_entries:
1✔
1256
            raise click.ClickException(f"input contains unknown namelist '{name}'")
×
1257

1258
    validated = 0
1✔
1259
    for key, schema in schema_entries.items():
1✔
1260
        if key not in file_entries:
1✔
1261
            if require_all:
×
1262
                raise click.ClickException(
×
1263
                    f"input is missing namelist '{schema.get('x-fortran-namelist')}'"
1264
                )
1265
            continue
×
1266
        try:
1✔
1267
            validate_namelist(
1✔
1268
                schema,
1269
                _normalize_f90nml_values(file_entries[key][1]),
1270
                constants=constants,
1271
                dimensions=dimensions,
1272
            )
1273
        except ValueError as exc:
×
1274
            raise click.ClickException(str(exc)) from exc
×
1275
        validated += 1
1✔
1276
    logger.info("Validation completed (%d namelist%s).", validated, "" if validated == 1 else "s")
1✔
1277

1278

1279
@cli.command("gen-markdown", context_settings=_CONTEXT_SETTINGS)
1✔
1280
@click.option(
1✔
1281
    "--config",
1282
    "config_path",
1283
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1284
    default=None,
1285
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1286
)
1287
def gen_markdown(config_path: Path | None) -> None:
1✔
1288
    """Generate Markdown docs."""
1289
    config, config_path = _load_config_checked(config_path)
1✔
1290
    logger.info("Loading config from %s", config_path)
1✔
1291
    base_dir = config_path.parent
1✔
1292
    constants, _ = _load_constants(config)
1✔
1293
    dimensions, _ = _load_dimensions(config, constants)
1✔
1294
    _, md_doxygen_id_from_name, md_add_toc_statement, _ = _load_documentation_settings(
1✔
1295
        config
1296
    )
1297
    entries = _iter_namelists(config, base_dir)
1✔
1298
    resolver = SchemaResolver()
1✔
1299
    logger.info("Found %d schema entries", len(entries))
1✔
1300
    for entry in entries:
1✔
1301
        schema_path = entry["schema"]
1✔
1302
        if schema_path is None:
1✔
1303
            raise click.ClickException("namelists entry missing schema path")
×
1304
        try:
1✔
1305
            logger.info("Loading schema %s", schema_path)
1✔
1306
            schema = load_schema(schema_path, resolver=resolver)
1✔
1307
        except (FileNotFoundError, ValueError) as exc:
×
1308
            raise click.ClickException(str(exc)) from exc
×
1309
        doc_path = entry["doc_path"]
1✔
1310
        if doc_path is None:
1✔
1311
            continue
×
1312
        try:
1✔
1313
            logger.info("Generating Markdown docs at %s", doc_path)
1✔
1314
            generate_docs(
1✔
1315
                schema,
1316
                doc_path,
1317
                constants=constants,
1318
                dimensions=dimensions,
1319
                md_doxygen_id_from_name=md_doxygen_id_from_name,
1320
                md_add_toc_statement=md_add_toc_statement,
1321
            )
1322
        except ValueError as exc:
×
1323
            raise click.ClickException(str(exc)) from exc
×
1324

1325

1326
@cli.command("gen-template", context_settings=_CONTEXT_SETTINGS)
1✔
1327
@click.option(
1✔
1328
    "--config",
1329
    "config_path",
1330
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1331
    default=None,
1332
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1333
)
1334
def gen_template(config_path: Path | None) -> None:
1✔
1335
    """Generate template namelist(s)."""
1336
    config, config_path = _load_config_checked(config_path)
1✔
1337
    logger.info("Loading config from %s", config_path)
1✔
1338
    base_dir = config_path.parent
1✔
1339
    constants, _ = _load_constants(config)
1✔
1340
    dimensions, _ = _load_dimensions(config, constants)
1✔
1341
    _, kind_map, kind_allowlist = _load_kind_settings(config)
1✔
1342
    templates = _iter_templates(config, base_dir)
1✔
1343
    resolver = SchemaResolver()
1✔
1344
    if not templates:
1✔
1345
        raise click.ClickException("config must define non-empty 'templates'")
×
1346
    logger.info("Found %d template entries", len(templates))
1✔
1347
    for entry in templates:
1✔
1348
        try:
1✔
1349
            schemas = []
1✔
1350
            for schema_path in entry["schemas"]:
1✔
1351
                logger.info("Loading schema %s", schema_path)
1✔
1352
                schemas.append(load_schema(schema_path, resolver=resolver))
1✔
1353
            logger.info("Generating template at %s", entry["output"])
1✔
1354
            generate_template(
1✔
1355
                schemas,
1356
                entry["output"],
1357
                doc_mode=entry["doc_mode"],
1358
                value_mode=entry["value_mode"],
1359
                constants=constants,
1360
                dimensions=dimensions,
1361
                kind_map=kind_map,
1362
                kind_allowlist=kind_allowlist,
1363
                values=entry["values"],
1364
            )
1365
        except (FileNotFoundError, ValueError) as exc:
×
1366
            raise click.ClickException(str(exc)) from exc
×
1367

1368

1369
def main(argv: list[str] | None = None) -> int:
1✔
1370
    """Entry point for the CLI."""
1371
    try:
×
1372
        cli.main(args=argv, prog_name="nml-tools", standalone_mode=False)
×
1373
    except Exit as exc:
×
1374
        return exc.exit_code
×
1375
    except click.ClickException as exc:
×
1376
        exc.show()
×
1377
        return 1
×
1378
    return 0
×
1379

1380

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