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

MuellerSeb / nml-tools / 26586171340

28 May 2026 03:58PM UTC coverage: 83.499% (-0.1%) from 83.612%
26586171340

Pull #34

github

MuellerSeb
Reserve double underscores in user identifiers

Enforce the documented naming rule that double underscores are reserved for nml-tools generated identifiers. This covers config constants, runtime dimensions, CLI overrides, schema property names, derived type/module names, and derived components.

Rejecting these names prevents helper import and generated support-name collisions such as dimensions foo and foo__default producing uncompilable Fortran.

Updated README and tests for the stricter validation behavior.
Pull Request #34: Use explicit defaults for runtime dimensions

92 of 102 new or added lines in 6 files covered. (90.2%)

14 existing lines in 4 files now uncovered.

3790 of 4539 relevant lines covered (83.5%)

0.83 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