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

MuellerSeb / nml-tools / 26585010608

28 May 2026 03:34PM UTC coverage: 83.464% (-0.1%) from 83.612%
26585010608

push

github

MuellerSeb
Use explicit defaults for runtime dimensions

Change the dimensions config table from value to default so it matches how runtime extents are used in generated code.

Generate helper constants as <dimension>__default and store mutable runtime extents directly on the namelist type under the configured dimension name. Reject collisions with namelist properties or existing constants instead of aliasing around them.

Refresh documentation, fixtures, and examples for the new runtime-dimension naming model.

15 of 18 new or added lines in 2 files covered. (83.33%)

7 existing lines in 1 file now uncovered.

3735 of 4475 relevant lines covered (83.46%)

0.83 hits per line

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

78.86
/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, is_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
        if not is_fortran_identifier(name):
1✔
91
            self.fail(f"{self._label} '{name}' must be a valid identifier", param, ctx)
1✔
92

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

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

105

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

109

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

114
    path: Path
1✔
115
    content: str
1✔
116

117

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

124

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

132

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

141

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

152

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

166

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

171

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

181

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

203

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

216

217
def _load_helper_settings(
1✔
218
    config: dict[str, Any],
219
    base_dir: Path,
220
) -> tuple[Path | None, str, int, str | None]:
221
    default_buffer = 1024
1✔
222
    helper_raw = config.get("helper")
1✔
223
    if helper_raw is None:
1✔
224
        helper_path = _resolve_optional_path(
1✔
225
            config.get("helper_path"),
226
            base_dir=base_dir,
227
            key="helper_path",
228
        )
229
        helper_module_raw = config.get("helper_module", "nml_helper")
1✔
230
        if not isinstance(helper_module_raw, str):
1✔
231
            raise click.ClickException("config 'helper_module' must be a string")
×
232
        helper_module = helper_module_raw.strip()
1✔
233
        if not helper_module:
1✔
234
            raise click.ClickException("config 'helper_module' must be a non-empty string")
×
235
        return helper_path, helper_module, default_buffer, None
1✔
236

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

268

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

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

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

297
    return module, kind_map, allowlist
1✔
298

299

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

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

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

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

331

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

351

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

359

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

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

408

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

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

475

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

482

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

517

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

526

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

537

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

548

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

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

605

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

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

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

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

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

652

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

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

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

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

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

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

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

807
    return outputs
1✔
808

809

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

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

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

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

911

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

944

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

951

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

976

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

985

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

1000

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

1028

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

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

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

1125

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

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

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

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

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

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

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

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

1276

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

1323

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

1366

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

1378

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