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

MuellerSeb / nml-tools / 26886054107

03 Jun 2026 12:53PM UTC coverage: 84.226% (+0.2%) from 83.989%
26886054107

Pull #38

github

MuellerSeb
Validate schema namelist identifiers consistently

Apply the user Fortran identifier checks to x-fortran-namelist during schema normalization and raw validation/generation entry points. This closes the inconsistency where optional config names were stricter than omitted names.

Keep [[namelists]].name as a readability/sanity-check field only: it must be a non-empty string and match the canonical schema namelist name case-insensitively, but it is not independently identifier-validated.

Update README wording and add regression tests for invalid schema namelist names with and without configured names.
Pull Request #38: Support legacy template schema inclusion and namelist name checks

56 of 59 new or added lines in 6 files covered. (94.92%)

1 existing line in 1 file now uncovered.

3962 of 4704 relevant lines covered (84.23%)

0.84 hits per line

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

81.72
/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
@dataclass(frozen=True)
1✔
121
class LoadedNamelist:
1✔
122
    """Configured namelist entry with its resolved schema."""
123

124
    entry: dict[str, Any]
1✔
125
    schema: dict[str, Any]
1✔
126
    name: str
1✔
127
    key: str
1✔
128

129

130
@dataclass(frozen=True)
1✔
131
class FileProfile:
1✔
132
    """Project-specific logical namelist file profile."""
133

134
    name: str
1✔
135
    key: str
1✔
136
    default_file: str
1✔
137
    namelists: list[str]
1✔
138
    title: str | None = None
1✔
139
    description: str | None = None
1✔
140

141

142
def _configure_logging(verbose: int, quiet: int) -> None:
1✔
143
    base_level = logging.INFO
1✔
144
    level = base_level - (10 * verbose) + (10 * quiet)
1✔
145
    level = max(logging.DEBUG, min(logging.CRITICAL, level))
1✔
146
    logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
1✔
147

148

149
def _load_toml(path: Path) -> dict[str, Any]:
1✔
150
    with path.open("rb") as handle:
1✔
151
        data = tomllib.load(handle)
1✔
152
    if not isinstance(data, dict):
1✔
153
        raise click.ClickException("config must be a table")
×
154
    return data
1✔
155

156

157
def _load_toml_checked(path: Path) -> dict[str, Any]:
1✔
158
    try:
1✔
159
        return _load_toml(path)
1✔
160
    except FileNotFoundError as exc:
1✔
161
        raise click.ClickException(str(exc)) from exc
1✔
162
    except Exception as exc:  # pragma: no cover - tomllib may raise ValueError
163
        raise click.ClickException(f"failed to read config: {exc}") from exc
164

165

166
def _load_config_checked(path: Path | None) -> tuple[dict[str, Any], Path]:
1✔
167
    config_path = _resolve_config_path(path)
1✔
168
    raw_config = _load_toml_checked(config_path)
1✔
169
    if config_path.name == _PYPROJECT_CONFIG.name:
1✔
170
        config = _extract_pyproject_config(raw_config)
1✔
171
    else:
172
        config = raw_config
1✔
173
    _check_minimum_version(config)
1✔
174
    return config, config_path
1✔
175

176

177
def _resolve_config_path(path: Path | None) -> Path:
1✔
178
    if path is not None:
1✔
179
        return path
1✔
180
    if _DEFAULT_CONFIG.is_file():
1✔
181
        return _DEFAULT_CONFIG
1✔
182
    if _PYPROJECT_CONFIG.is_file():
1✔
183
        raw_config = _load_toml_checked(_PYPROJECT_CONFIG)
1✔
184
        if _has_pyproject_config(raw_config):
1✔
185
            return _PYPROJECT_CONFIG
1✔
186
    raise click.ClickException(
1✔
187
        "no config found; create nml-config.toml or add [tool.nml-tools] to pyproject.toml"
188
    )
189

190

191
def _has_pyproject_config(config: dict[str, Any]) -> bool:
1✔
192
    tool_raw = config.get("tool")
1✔
193
    return isinstance(tool_raw, dict) and isinstance(tool_raw.get("nml-tools"), dict)
1✔
194

195

196
def _extract_pyproject_config(config: dict[str, Any]) -> dict[str, Any]:
1✔
197
    tool_raw = config.get("tool")
1✔
198
    if not isinstance(tool_raw, dict):
1✔
199
        raise click.ClickException("pyproject.toml must define [tool.nml-tools]")
1✔
200
    nml_tools_raw = tool_raw.get("nml-tools")
1✔
201
    if not isinstance(nml_tools_raw, dict):
1✔
202
        raise click.ClickException("pyproject.toml must define [tool.nml-tools]")
1✔
203
    return nml_tools_raw
1✔
204

205

206
def _check_minimum_version(config: dict[str, Any]) -> None:
1✔
207
    minimum_raw = config.get("minimum-version")
1✔
208
    if minimum_raw is None:
1✔
209
        return
1✔
210
    if not isinstance(minimum_raw, str) or not minimum_raw.strip():
1✔
211
        raise click.ClickException("config 'minimum-version' must be a non-empty string")
1✔
212
    try:
1✔
213
        minimum = Version(minimum_raw.strip())
1✔
214
    except InvalidVersion as exc:
1✔
215
        raise click.ClickException(
1✔
216
            f"config 'minimum-version' is not a valid version: {minimum_raw}"
217
        ) from exc
218
    try:
1✔
219
        current = Version(__version__)
1✔
220
    except InvalidVersion as exc:  # pragma: no cover - package version should be valid
221
        raise click.ClickException(f"nml-tools version is not valid: {__version__}") from exc
222
    if current < minimum:
1✔
223
        raise click.ClickException(
1✔
224
            f"config requires nml-tools >= {minimum}, current version is {current}"
225
        )
226

227

228
def _resolve_optional_path(
1✔
229
    value: Any,
230
    *,
231
    base_dir: Path,
232
    key: str,
233
) -> Path | None:
234
    if value is None:
1✔
235
        return None
1✔
236
    if not isinstance(value, str):
1✔
237
        raise click.ClickException(f"config '{key}' must be a string")
×
238
    return base_dir / value
1✔
239

240

241
def _load_helper_settings(
1✔
242
    config: dict[str, Any],
243
    base_dir: Path,
244
) -> tuple[Path | None, str, int, str | None]:
245
    default_buffer = 1024
1✔
246
    helper_raw = config.get("helper")
1✔
247
    if helper_raw is None:
1✔
248
        helper_path = _resolve_optional_path(
1✔
249
            config.get("helper_path"),
250
            base_dir=base_dir,
251
            key="helper_path",
252
        )
253
        helper_module_raw = config.get("helper_module", "nml_helper")
1✔
254
        if not isinstance(helper_module_raw, str):
1✔
255
            raise click.ClickException("config 'helper_module' must be a string")
×
256
        helper_module = helper_module_raw.strip()
1✔
257
        if not helper_module:
1✔
258
            raise click.ClickException("config 'helper_module' must be a non-empty string")
×
259
        return helper_path, helper_module, default_buffer, None
1✔
260

261
    if not isinstance(helper_raw, dict):
1✔
262
        raise click.ClickException("config 'helper' must be a table")
×
263
    if "helper_path" in config or "helper_module" in config:
1✔
264
        logger.warning("config uses [helper]; ignoring legacy helper_path/helper_module")
×
265
    helper_path = _resolve_optional_path(
1✔
266
        helper_raw.get("path"),
267
        base_dir=base_dir,
268
        key="helper.path",
269
    )
270
    helper_module_raw = helper_raw.get("module", "nml_helper")
1✔
271
    if not isinstance(helper_module_raw, str):
1✔
272
        raise click.ClickException("config 'helper.module' must be a string")
×
273
    helper_module = helper_module_raw.strip()
1✔
274
    if not helper_module:
1✔
275
        raise click.ClickException("config 'helper.module' must be a non-empty string")
×
276
    header_raw = helper_raw.get("header")
1✔
277
    if header_raw is None:
1✔
278
        header = None
1✔
279
    else:
280
        if not isinstance(header_raw, str):
×
281
            raise click.ClickException("config 'helper.header' must be a string")
×
282
        header = header_raw.rstrip()
×
283
        if not header:
×
284
            header = None
×
285
    buffer_raw = helper_raw.get("buffer", default_buffer)
1✔
286
    if isinstance(buffer_raw, bool) or not isinstance(buffer_raw, int):
1✔
287
        raise click.ClickException("config 'helper.buffer' must be an integer")
×
288
    if buffer_raw <= 0:
1✔
289
        raise click.ClickException("config 'helper.buffer' must be positive")
×
290
    return helper_path, helper_module, buffer_raw, header
1✔
291

292

293
def _load_kind_settings(config: dict[str, Any]) -> tuple[str, dict[str, str], set[str]]:
1✔
294
    kinds_raw = config.get("kinds")
1✔
295
    if not isinstance(kinds_raw, dict):
1✔
296
        raise click.ClickException("config must define a [kinds] table")
×
297
    module_raw = kinds_raw.get("module")
1✔
298
    if not isinstance(module_raw, str) or not module_raw.strip():
1✔
299
        raise click.ClickException("config 'kinds.module' must be a non-empty string")
×
300
    module = module_raw.strip()
1✔
301

302
    map_raw = kinds_raw.get("map", {})
1✔
303
    if map_raw is None:
1✔
304
        map_raw = {}
×
305
    if not isinstance(map_raw, dict):
1✔
306
        raise click.ClickException("config 'kinds.map' must be a table")
×
307
    kind_map: dict[str, str] = {}
1✔
308
    for alias, target in map_raw.items():
1✔
309
        if not isinstance(alias, str) or not isinstance(target, str):
1✔
310
            raise click.ClickException("config 'kinds.map' keys and values must be strings")
×
311
        kind_map[alias] = target
1✔
312

313
    real_raw = kinds_raw.get("real", [])
1✔
314
    integer_raw = kinds_raw.get("integer", [])
1✔
315
    if not isinstance(real_raw, list) or not all(isinstance(item, str) for item in real_raw):
1✔
316
        raise click.ClickException("config 'kinds.real' must be a list of strings")
×
317
    if not isinstance(integer_raw, list) or not all(isinstance(item, str) for item in integer_raw):
1✔
318
        raise click.ClickException("config 'kinds.integer' must be a list of strings")
×
319
    allowlist = set(real_raw) | set(integer_raw)
1✔
320

321
    return module, kind_map, allowlist
1✔
322

323

324
def _load_f2py_settings(
1✔
325
    config: dict[str, Any],
326
    base_dir: Path,
327
) -> tuple[Path | None, F2pyCTypeMap]:
328
    f2py_raw = config.get("f2py")
1✔
329
    if f2py_raw is None:
1✔
330
        return None, F2pyCTypeMap(real={}, integer={})
1✔
331
    if not isinstance(f2py_raw, dict):
1✔
332
        raise click.ClickException("config 'f2py' must be a table")
×
333
    if "python_package" in f2py_raw:
1✔
334
        raise click.ClickException(
×
335
            "config 'f2py.python_package' is no longer supported; "
336
            "place the f2py extension next to the generated Python wrapper"
337
        )
338

339
    f2cmap_path = _resolve_optional_path(
1✔
340
        f2py_raw.get("f2cmap_path"),
341
        base_dir=base_dir,
342
        key="f2py.f2cmap_path",
343
    )
344

345
    c_types_raw = f2py_raw.get("c_types", {})
1✔
346
    if c_types_raw is None:
1✔
347
        c_types_raw = {}
×
348
    if not isinstance(c_types_raw, dict):
1✔
349
        raise click.ClickException("config 'f2py.c_types' must be a table")
×
350

351
    real = _load_f2py_ctype_table(c_types_raw, "real")
1✔
352
    integer = _load_f2py_ctype_table(c_types_raw, "integer")
1✔
353
    return f2cmap_path, F2pyCTypeMap(real=real, integer=integer)
1✔
354

355

356
def _load_f2py_ctype_table(c_types: dict[str, Any], key: str) -> dict[str, str]:
1✔
357
    raw = c_types.get(key, {})
1✔
358
    if raw is None:
1✔
359
        raw = {}
×
360
    if not isinstance(raw, dict):
1✔
361
        raise click.ClickException(f"config 'f2py.c_types.{key}' must be a table")
×
362
    values: dict[str, str] = {}
1✔
363
    for kind, c_type in raw.items():
1✔
364
        if not isinstance(kind, str) or not kind.strip():
1✔
365
            raise click.ClickException(
×
366
                f"config 'f2py.c_types.{key}' keys must be non-empty strings"
367
            )
368
        if not isinstance(c_type, str) or not c_type.strip():
1✔
369
            raise click.ClickException(
×
370
                f"config 'f2py.c_types.{key}.{kind}' must be a non-empty string"
371
            )
372
        values[kind.strip()] = c_type.strip()
1✔
373
    return values
1✔
374

375

376
def _format_constant_literal(value: int) -> tuple[str, str]:
1✔
377
    if isinstance(value, bool):
1✔
378
        raise click.ClickException("config constants must not be boolean")
×
379
    if isinstance(value, int):
1✔
380
        return "integer", str(value)
1✔
381
    raise click.ClickException("config constants must be integers")
×
382

383

384
def _load_constants(config: dict[str, Any]) -> tuple[dict[str, int], list[ConstantSpec]]:
1✔
385
    constants_raw = config.get("constants", {})
1✔
386
    if constants_raw is None:
1✔
387
        constants_raw = {}
×
388
    if not isinstance(constants_raw, dict):
1✔
389
        raise click.ClickException("config 'constants' must be a table")
×
390

391
    constants: dict[str, int] = {}
1✔
392
    specs: list[ConstantSpec] = []
1✔
393
    for name_raw, entry in constants_raw.items():
1✔
394
        if not isinstance(name_raw, str):
1✔
395
            raise click.ClickException("config constants must use string keys")
×
396
        name = name_raw.strip()
1✔
397
        if not name:
1✔
398
            raise click.ClickException("config constants must have non-empty names")
×
399
        try:
1✔
400
            validate_user_fortran_identifier(name, label=f"config constant '{name}'")
1✔
401
        except ValueError as exc:
1✔
402
            raise click.ClickException(str(exc)) from exc
1✔
403
        canonical_name = name.lower()
1✔
404
        if canonical_name in constants:
1✔
405
            raise click.ClickException(
1✔
406
                f"config constant '{name}' duplicates another constant name"
407
            )
408
        if not isinstance(entry, dict):
1✔
409
            raise click.ClickException(f"config constant '{name}' must be a table with 'value'")
×
410
        if "value" not in entry:
1✔
411
            raise click.ClickException(f"config constant '{name}' must define 'value'")
×
412
        value = entry.get("value")
1✔
413
        if isinstance(value, bool) or not isinstance(value, int):
1✔
414
            raise click.ClickException("config constants must be integers")
1✔
415
        type_spec, literal = _format_constant_literal(value)
1✔
416
        doc = entry.get("doc")
1✔
417
        if doc is not None:
1✔
418
            if not isinstance(doc, str):
1✔
419
                raise click.ClickException(f"config constant '{name}' doc must be a string")
×
420
            doc = " ".join(doc.splitlines()).strip() or None
1✔
421
        specs.append(
1✔
422
            ConstantSpec(
423
                name=canonical_name,
424
                type_spec=type_spec,
425
                value=literal,
426
                doc=doc,
427
            )
428
        )
429
        constants[canonical_name] = value
1✔
430
    return constants, specs
1✔
431

432

433
def _load_dimensions(
1✔
434
    config: dict[str, Any],
435
    constants: dict[str, int],
436
) -> tuple[dict[str, int], list[ConstantSpec]]:
437
    dimensions_raw = config.get("dimensions", {})
1✔
438
    if dimensions_raw is None:
1✔
439
        dimensions_raw = {}
×
440
    if not isinstance(dimensions_raw, dict):
1✔
441
        raise click.ClickException("config 'dimensions' must be a table")
×
442

443
    dimensions: dict[str, int] = {}
1✔
444
    specs: list[ConstantSpec] = []
1✔
445
    constant_names = {name.lower() for name in constants}
1✔
446
    for name_raw, entry in dimensions_raw.items():
1✔
447
        if not isinstance(name_raw, str):
1✔
448
            raise click.ClickException("config dimensions must use string keys")
×
449
        name = name_raw.strip()
1✔
450
        if not name:
1✔
451
            raise click.ClickException("config dimensions must have non-empty names")
×
452
        try:
1✔
453
            validate_user_fortran_identifier(name, label=f"config dimension '{name}'")
1✔
454
        except ValueError as exc:
1✔
455
            raise click.ClickException(str(exc)) from exc
1✔
456
        canonical_name = name.lower()
1✔
457
        if canonical_name in constant_names:
1✔
458
            raise click.ClickException(
1✔
459
                f"config dimension '{name}' duplicates a constant name"
460
            )
461
        if canonical_name in dimensions:
1✔
462
            raise click.ClickException(
1✔
463
                f"config dimension '{name}' duplicates another dimension name"
464
            )
465
        default_name = f"{canonical_name}__default"
1✔
466
        if default_name in constant_names:
1✔
467
            raise click.ClickException(
1✔
468
                f"config dimension '{name}' default name duplicates a constant name"
469
            )
470
        if not isinstance(entry, dict):
1✔
471
            raise click.ClickException(f"config dimension '{name}' must be a table with 'default'")
×
472
        if "value" in entry:
1✔
473
            raise click.ClickException(
1✔
474
                f"config dimension '{name}' must use 'default', not 'value'"
475
            )
476
        if "default" not in entry:
1✔
477
            raise click.ClickException(f"config dimension '{name}' must define 'default'")
×
478
        value = entry.get("default")
1✔
479
        if isinstance(value, bool) or not isinstance(value, int):
1✔
480
            raise click.ClickException(f"config dimension '{name}' default must be an integer")
×
481
        if value <= 0:
1✔
482
            raise click.ClickException(f"config dimension '{name}' default must be positive")
1✔
483
        doc = entry.get("doc")
1✔
484
        if doc is not None:
1✔
485
            if not isinstance(doc, str):
1✔
486
                raise click.ClickException(f"config dimension '{name}' doc must be a string")
×
487
            doc = " ".join(doc.splitlines()).strip() or None
1✔
488
        specs.append(
1✔
489
            ConstantSpec(
490
                name=default_name,
491
                type_spec="integer",
492
                value=str(value),
493
                doc=doc,
494
            )
495
        )
496
        dimensions[canonical_name] = value
1✔
497
    return dimensions, specs
1✔
498

499

500
def _load_bool_field(section: dict[str, Any], key: str, *, label: str) -> bool:
1✔
501
    value = section.get(key, False)
×
502
    if isinstance(value, bool):
×
503
        return value
×
504
    raise click.ClickException(f"config '{label}' must be a boolean")
×
505

506

507
def _load_documentation_settings(
1✔
508
    config: dict[str, Any],
509
) -> tuple[str | None, bool, bool, str]:
510
    doc_raw = config.get("documentation")
1✔
511
    if doc_raw is None:
1✔
512
        return None, False, False, "numpy"
1✔
513
    if not isinstance(doc_raw, dict):
×
514
        raise click.ClickException("config 'documentation' must be a table")
×
515
    module_doc = None
×
516
    if "module" in doc_raw:
×
517
        module_raw = doc_raw.get("module")
×
518
        if not isinstance(module_raw, str):
×
519
            raise click.ClickException("config 'documentation.module' must be a string")
×
520
        module_doc = module_raw.strip() or None
×
521
    md_doxygen_id_from_name = _load_bool_field(
×
522
        doc_raw,
523
        "md_doxygen_id_from_name",
524
        label="documentation.md_doxygen_id_from_name",
525
    )
526
    md_add_toc_statement = _load_bool_field(
×
527
        doc_raw,
528
        "md_add_toc_statement",
529
        label="documentation.md_add_toc_statement",
530
    )
531
    py_style_raw = doc_raw.get("py-style", "numpy")
×
532
    if not isinstance(py_style_raw, str):
×
533
        raise click.ClickException("config 'documentation.py-style' must be a string")
×
534
    py_style = py_style_raw.strip().lower()
×
535
    if py_style not in {"numpy", "doxygen"}:
×
536
        raise click.ClickException(
×
537
            "config 'documentation.py-style' must be 'numpy' or 'doxygen'"
538
        )
539
    return module_doc, md_doxygen_id_from_name, md_add_toc_statement, py_style
×
540

541

542
def _parse_cli_constants(values: tuple[tuple[str, int], ...]) -> dict[str, int]:
1✔
543
    constants: dict[str, int] = {}
1✔
544
    for name, value in values:
1✔
545
        if name in constants:
1✔
546
            raise click.ClickException(f"constant '{name}' duplicates another constant name")
1✔
547
        constants[name] = value
1✔
548
    return constants
1✔
549

550

551
def _parse_cli_dimensions(values: tuple[tuple[str, int], ...]) -> dict[str, int]:
1✔
552
    dimensions: dict[str, int] = {}
1✔
553
    for name, value in values:
1✔
554
        if name in dimensions:
1✔
555
            raise click.ClickException(f"dimension '{name}' duplicates another dimension name")
1✔
556
        if value <= 0:
1✔
557
            raise click.ClickException(f"dimension '{name}' value must be positive")
×
558
        dimensions[name] = value
1✔
559
    return dimensions
1✔
560

561

562
def _reject_constant_dimension_overlap(
1✔
563
    constants: dict[str, int],
564
    dimensions: dict[str, int],
565
) -> None:
566
    duplicate_names = constant_dimension_overlap(constants, dimensions)
1✔
567
    if duplicate_names:
1✔
568
        raise click.ClickException(
×
569
            "constants and dimensions must not share names: " + ", ".join(duplicate_names)
570
        )
571

572

573
def _iter_namelists(config: dict[str, Any], base_dir: Path) -> list[dict[str, Any]]:
1✔
574
    raw_entries = config.get("namelists")
1✔
575
    if raw_entries is None and "nml-files" in config:
1✔
576
        raise click.ClickException("config uses deprecated 'nml-files'; rename to 'namelists'")
×
577
    if not isinstance(raw_entries, list) or not raw_entries:
1✔
578
        raise click.ClickException("config must define non-empty 'namelists'")
×
579

580
    entries: list[dict[str, Any]] = []
1✔
581
    for entry in raw_entries:
1✔
582
        if not isinstance(entry, dict):
1✔
583
            raise click.ClickException("each namelists entry must be a table")
×
584
        schema_raw = entry.get("schema")
1✔
585
        if not isinstance(schema_raw, str):
1✔
586
            raise click.ClickException("namelists entry must define string 'schema'")
×
587
        schema_path = base_dir / schema_raw
1✔
588
        name_raw = entry.get("name")
1✔
589
        if name_raw is not None:
1✔
590
            if not isinstance(name_raw, str) or not name_raw.strip():
1✔
591
                raise click.ClickException("namelists entry 'name' must be a non-empty string")
1✔
592
        f2py_path = _resolve_optional_path(
1✔
593
            entry.get("f2py_path"),
594
            base_dir=base_dir,
595
            key="f2py_path",
596
        )
597
        py_path = _resolve_optional_path(
1✔
598
            entry.get("py_path"),
599
            base_dir=base_dir,
600
            key="py_path",
601
        )
602
        mod_path = _resolve_optional_path(
1✔
603
            entry.get("mod_path"),
604
            base_dir=base_dir,
605
            key="mod_path",
606
        )
607
        if f2py_path is not None and mod_path is None:
1✔
608
            raise click.ClickException(
×
609
                "namelists entry with 'f2py_path' must define 'mod_path'"
610
            )
611
        if py_path is not None and f2py_path is None:
1✔
612
            raise click.ClickException("namelists entry with 'py_path' must define 'f2py_path'")
×
613
        entries.append(
1✔
614
            {
615
                "name": name_raw.strip() if isinstance(name_raw, str) else None,
616
                "schema": schema_path,
617
                "mod_path": mod_path,
618
                "doc_path": _resolve_optional_path(
619
                    entry.get("doc_path"),
620
                    base_dir=base_dir,
621
                    key="doc_path",
622
                ),
623
                "temp_path": _resolve_optional_path(
624
                    entry.get("temp_path"),
625
                    base_dir=base_dir,
626
                    key="temp_path",
627
                ),
628
                "f2py_path": f2py_path,
629
                "py_path": py_path,
630
            }
631
        )
632
    return entries
1✔
633

634

635
def _load_namelist_registry(
1✔
636
    config: dict[str, Any],
637
    base_dir: Path,
638
    resolver: SchemaResolver,
639
) -> list[LoadedNamelist]:
640
    entries = _iter_namelists(config, base_dir)
1✔
641
    loaded: list[LoadedNamelist] = []
1✔
642
    seen: dict[str, str] = {}
1✔
643
    for entry in entries:
1✔
644
        schema_path = entry["schema"]
1✔
645
        if schema_path is None:
1✔
646
            raise click.ClickException("namelists entry missing schema path")
×
647
        try:
1✔
648
            logger.debug("Loading schema %s", schema_path)
1✔
649
            schema = load_schema(schema_path, resolver=resolver)
1✔
650
        except (FileNotFoundError, ValueError) as exc:
1✔
651
            raise click.ClickException(str(exc)) from exc
1✔
652
        namelist_name = schema.get("x-fortran-namelist")
1✔
653
        if not isinstance(namelist_name, str) or not namelist_name.strip():
1✔
654
            raise click.ClickException("schema must define non-empty 'x-fortran-namelist'")
×
655
        configured_name = entry.get("name")
1✔
656
        if isinstance(configured_name, str) and configured_name.lower() != namelist_name.lower():
1✔
657
            raise click.ClickException(
1✔
658
                f"namelists entry name '{configured_name}' does not match "
659
                f"schema x-fortran-namelist '{namelist_name}'"
660
            )
661
        key = namelist_name.lower()
1✔
662
        if key in seen:
1✔
663
            raise click.ClickException(f"duplicate schema for namelist '{namelist_name}'")
×
664
        seen[key] = namelist_name
1✔
665
        loaded.append(
1✔
666
            LoadedNamelist(
667
                entry=entry,
668
                schema=schema,
669
                name=namelist_name,
670
                key=key,
671
            )
672
        )
673
    return loaded
1✔
674

675

676
def _namelist_registry_by_key(
1✔
677
    registry: list[LoadedNamelist],
678
) -> dict[str, LoadedNamelist]:
679
    return {loaded.key: loaded for loaded in registry}
1✔
680

681

682
def _resolve_namelist_members(
1✔
683
    raw_names: Any,
684
    *,
685
    registry: dict[str, LoadedNamelist],
686
    label: str,
687
) -> list[LoadedNamelist]:
688
    if not isinstance(raw_names, list) or not raw_names:
1✔
689
        raise click.ClickException(f"{label} must define non-empty 'namelists'")
×
690
    resolved: list[LoadedNamelist] = []
1✔
691
    seen: set[str] = set()
1✔
692
    for raw_name in raw_names:
1✔
693
        if not isinstance(raw_name, str) or not raw_name.strip():
1✔
694
            raise click.ClickException(f"{label} 'namelists' entries must be strings")
×
695
        key = raw_name.lower()
1✔
696
        if key in seen:
1✔
697
            raise click.ClickException(f"{label} namelist '{raw_name}' duplicates another name")
1✔
698
        seen.add(key)
1✔
699
        loaded = registry.get(key)
1✔
700
        if loaded is None:
1✔
701
            raise click.ClickException(f"{label} references unknown namelist '{raw_name}'")
1✔
702
        resolved.append(loaded)
1✔
703
    return resolved
1✔
704

705

706
def _resolve_template_schema_members(
1✔
707
    raw_paths: Any,
708
    *,
709
    base_dir: Path,
710
    registry: dict[str, LoadedNamelist],
711
) -> list[LoadedNamelist]:
712
    """Resolve legacy template schema paths to configured namelist entries."""
713
    if not isinstance(raw_paths, list) or not raw_paths:
1✔
NEW
714
        raise click.ClickException("templates 'schemas' must be a non-empty list")
×
715
    by_schema_path: dict[Path, LoadedNamelist] = {}
1✔
716
    for loaded in registry.values():
1✔
717
        schema_path = loaded.entry["schema"]
1✔
718
        if isinstance(schema_path, Path):
1✔
719
            by_schema_path[schema_path.resolve()] = loaded
1✔
720

721
    resolved: list[LoadedNamelist] = []
1✔
722
    seen: set[Path] = set()
1✔
723
    for raw_path in raw_paths:
1✔
724
        if not isinstance(raw_path, str) or not raw_path.strip():
1✔
NEW
725
            raise click.ClickException("templates 'schemas' entries must be strings")
×
726
        schema_path = Path(raw_path)
1✔
727
        if not schema_path.is_absolute():
1✔
728
            schema_path = base_dir / schema_path
1✔
729
        canonical_path = schema_path.resolve()
1✔
730
        if canonical_path in seen:
1✔
731
            raise click.ClickException(
1✔
732
                f"templates schema '{raw_path}' duplicates another schema"
733
            )
734
        seen.add(canonical_path)
1✔
735
        matched = by_schema_path.get(canonical_path)
1✔
736
        if matched is None:
1✔
737
            raise click.ClickException(
1✔
738
                f"templates schema '{raw_path}' does not match a configured namelist schema"
739
            )
740
        resolved.append(matched)
1✔
741
    return resolved
1✔
742

743

744
def _optional_string(
1✔
745
    entry: dict[str, Any],
746
    key: str,
747
    *,
748
    label: str,
749
) -> str | None:
750
    value = entry.get(key)
1✔
751
    if value is None:
1✔
752
        return None
1✔
753
    if not isinstance(value, str):
1✔
754
        raise click.ClickException(f"{label} '{key}' must be a string")
1✔
755
    return value
1✔
756

757

758
def _iter_file_profiles(
1✔
759
    config: dict[str, Any],
760
    registry: dict[str, LoadedNamelist],
761
) -> dict[str, FileProfile]:
762
    raw_entries = config.get("file_profiles")
1✔
763
    if raw_entries is None:
1✔
764
        return {}
1✔
765
    if not isinstance(raw_entries, list) or not raw_entries:
1✔
766
        raise click.ClickException("config 'file_profiles' must be a non-empty list")
×
767

768
    profiles: dict[str, FileProfile] = {}
1✔
769
    for entry in raw_entries:
1✔
770
        if not isinstance(entry, dict):
1✔
771
            raise click.ClickException("each file_profiles entry must be a table")
×
772
        name = entry.get("name")
1✔
773
        if not isinstance(name, str) or not name.strip():
1✔
774
            raise click.ClickException("file_profiles entry must define string 'name'")
1✔
775
        key = name.lower()
1✔
776
        if key in profiles:
1✔
777
            raise click.ClickException(f"file profile '{name}' duplicates another profile")
1✔
778
        default_file = entry.get("default_file")
1✔
779
        if not isinstance(default_file, str) or not default_file.strip():
1✔
780
            raise click.ClickException("file_profiles entry must define string 'default_file'")
1✔
781
        members = _resolve_namelist_members(
1✔
782
            entry.get("namelists"),
783
            registry=registry,
784
            label=f"file profile '{name}'",
785
        )
786
        profiles[key] = FileProfile(
1✔
787
            name=name,
788
            key=key,
789
            default_file=default_file,
790
            namelists=[member.key for member in members],
791
            title=_optional_string(entry, "title", label=f"file profile '{name}'"),
792
            description=_optional_string(
793
                entry,
794
                "description",
795
                label=f"file profile '{name}'",
796
            ),
797
        )
798
    return profiles
1✔
799

800

801
def _iter_templates(
1✔
802
    config: dict[str, Any],
803
    base_dir: Path,
804
    registry: dict[str, LoadedNamelist],
805
    profiles: dict[str, FileProfile],
806
) -> list[dict[str, Any]]:
807
    raw_entries = config.get("templates")
1✔
808
    if raw_entries is None:
1✔
809
        return []
1✔
810
    if not isinstance(raw_entries, list) or not raw_entries:
1✔
811
        raise click.ClickException("config 'templates' must be a non-empty list")
×
812

813
    entries: list[dict[str, Any]] = []
1✔
814
    for entry in raw_entries:
1✔
815
        if not isinstance(entry, dict):
1✔
816
            raise click.ClickException("each templates entry must be a table")
×
817
        if "output" in entry:
1✔
818
            raise click.ClickException("templates use 'path', not deprecated 'output'")
1✔
819
        path_raw = entry.get("path")
1✔
820
        if not isinstance(path_raw, str):
1✔
821
            raise click.ClickException("templates entry must define string 'path'")
×
822
        output_path = base_dir / path_raw
1✔
823

824
        profile_raw = entry.get("profile")
1✔
825
        has_profile = profile_raw is not None
1✔
826
        has_namelists = "namelists" in entry
1✔
827
        has_schemas = "schemas" in entry
1✔
828
        if has_schemas and (has_profile or has_namelists):
1✔
829
            raise click.ClickException(
1✔
830
                "templates entry with 'schemas' must not define 'profile' or 'namelists'"
831
            )
832
        if not has_schemas and has_profile == has_namelists:
1✔
UNCOV
833
            raise click.ClickException(
×
834
                "templates entry must define exactly one of 'profile' or 'namelists'"
835
            )
836
        title = _optional_string(entry, "title", label="templates entry")
1✔
837
        description = _optional_string(entry, "description", label="templates entry")
1✔
838
        if has_profile:
1✔
839
            if not isinstance(profile_raw, str) or not profile_raw.strip():
1✔
840
                raise click.ClickException("templates 'profile' must be a string")
×
841
            profile = profiles.get(profile_raw.lower())
1✔
842
            if profile is None:
1✔
843
                raise click.ClickException(
×
844
                    f"templates entry references unknown profile '{profile_raw}'"
845
                )
846
            loaded_namelists = [registry[key] for key in profile.namelists]
1✔
847
            if title is None:
1✔
848
                title = profile.title
1✔
849
            if description is None:
1✔
850
                description = profile.description
×
851
        elif has_schemas:
1✔
852
            loaded_namelists = _resolve_template_schema_members(
1✔
853
                entry.get("schemas"),
854
                base_dir=base_dir,
855
                registry=registry,
856
            )
857
        else:
858
            loaded_namelists = _resolve_namelist_members(
1✔
859
                entry.get("namelists"),
860
                registry=registry,
861
                label="templates entry",
862
            )
863

864
        doc_mode = entry.get("doc_mode", "plain")
1✔
865
        if not isinstance(doc_mode, str):
1✔
866
            raise click.ClickException("templates 'doc_mode' must be a string")
×
867
        value_mode = entry.get("value_mode", "empty")
1✔
868
        if not isinstance(value_mode, str):
1✔
869
            raise click.ClickException("templates 'value_mode' must be a string")
×
870
        values_raw = entry.get("values", {})
1✔
871
        if values_raw is None:
1✔
872
            values_raw = {}
×
873
        if not isinstance(values_raw, dict):
1✔
874
            raise click.ClickException("templates 'values' must be a table")
×
875

876
        entries.append(
1✔
877
            {
878
                "path": output_path,
879
                "schemas": [loaded.schema for loaded in loaded_namelists],
880
                "doc_mode": doc_mode,
881
                "value_mode": value_mode,
882
                "title": title,
883
                "description": description,
884
                "values": values_raw,
885
            }
886
        )
887
    return entries
1✔
888

889

890
def _collect_generated_outputs(
1✔
891
    config: dict[str, Any],
892
    config_path: Path,
893
) -> list[GeneratedOutput]:
894
    base_dir = config_path.parent
1✔
895
    logger.debug("Base directory: %s", base_dir)
1✔
896
    helper_path, helper_module, helper_buffer, helper_header = _load_helper_settings(
1✔
897
        config,
898
        base_dir,
899
    )
900
    (
1✔
901
        module_doc,
902
        md_doxygen_id_from_name,
903
        md_add_toc_statement,
904
        py_style,
905
    ) = _load_documentation_settings(config)
906
    constants, constant_specs = _load_constants(config)
1✔
907
    dimensions, dimension_specs = _load_dimensions(config, constants)
1✔
908
    kind_module, kind_map, kind_allowlist = _load_kind_settings(config)
1✔
909
    f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir)
1✔
910
    resolver = SchemaResolver()
1✔
911
    outputs: list[GeneratedOutput] = []
1✔
912

913
    loaded_namelists = _load_namelist_registry(config, base_dir, resolver)
1✔
914
    loaded_by_key = _namelist_registry_by_key(loaded_namelists)
1✔
915
    profiles = _iter_file_profiles(config, loaded_by_key)
1✔
916
    logger.debug("Found %d schema entries", len(loaded_namelists))
1✔
917
    loaded_entries: list[dict[str, Any]] = [
1✔
918
        {"entry": loaded.entry, "schema": loaded.schema} for loaded in loaded_namelists
919
    ]
920
    for loaded in loaded_namelists:
1✔
921
        namelist_entry = loaded.entry
1✔
922
        schema = loaded.schema
1✔
923

924
        mod_path = namelist_entry["mod_path"]
1✔
925
        if mod_path is not None:
1✔
926
            try:
1✔
927
                logger.debug("Rendering Fortran module at %s", mod_path)
1✔
928
                outputs.append(
1✔
929
                    GeneratedOutput(
930
                        mod_path,
931
                        render_fortran(
932
                            schema,
933
                            file_name=mod_path.name,
934
                            helper_module=helper_module,
935
                            kind_module=kind_module,
936
                            kind_map=kind_map,
937
                            kind_allowlist=kind_allowlist,
938
                            constants=constants,
939
                            dimensions=dimensions,
940
                            module_doc=module_doc,
941
                            f2py_handle_helpers=namelist_entry["f2py_path"] is not None,
942
                        ),
943
                    )
944
                )
945
            except ValueError as exc:
×
946
                raise click.ClickException(str(exc)) from exc
×
947

948
        doc_path = namelist_entry["doc_path"]
1✔
949
        if doc_path is not None:
1✔
950
            try:
1✔
951
                logger.debug("Rendering Markdown docs at %s", doc_path)
1✔
952
                outputs.append(
1✔
953
                    GeneratedOutput(
954
                        doc_path,
955
                        render_docs(
956
                            schema,
957
                            constants=constants,
958
                            dimensions=dimensions,
959
                            md_doxygen_id_from_name=md_doxygen_id_from_name,
960
                            md_add_toc_statement=md_add_toc_statement,
961
                        ),
962
                    )
963
                )
964
            except ValueError as exc:
×
965
                raise click.ClickException(str(exc)) from exc
×
966

967
    try:
1✔
968
        local_derived_types = collect_local_derived_types(
1✔
969
            [loaded.schema for loaded in loaded_namelists],
970
            constants=constants,
971
        )
972
        if local_derived_types and helper_path is None:
1✔
973
            raise ValueError("locally generated derived types require a configured helper output")
×
974
        if helper_path is not None:
1✔
975
            logger.debug("Rendering helper module at %s", helper_path)
1✔
976
            outputs.append(
1✔
977
                GeneratedOutput(
978
                    helper_path,
979
                    render_helper(
980
                        file_name=helper_path.name,
981
                        module_name=helper_module,
982
                        len_buf=helper_buffer,
983
                        constants=constant_specs + dimension_specs,
984
                        local_derived_types=local_derived_types,
985
                        kind_module=kind_module,
986
                        kind_map=kind_map,
987
                        kind_allowlist=kind_allowlist,
988
                        module_doc=module_doc,
989
                        helper_header=helper_header,
990
                    ),
991
                )
992
            )
993
    except ValueError as exc:
×
994
        raise click.ClickException(str(exc)) from exc
×
995

996
    outputs.extend(
1✔
997
        _collect_f2py_outputs(
998
            loaded_entries,
999
            helper_module=helper_module,
1000
            helper_buffer=helper_buffer,
1001
            kind_module=kind_module,
1002
            kind_map=kind_map,
1003
            kind_allowlist=kind_allowlist,
1004
            constants=constants,
1005
            dimensions=dimensions,
1006
            f2cmap_path=f2cmap_path,
1007
            f2py_c_types=f2py_c_types,
1008
            py_style=py_style,
1009
            include_python=True,
1010
        )
1011
    )
1012

1013
    template_entries = _iter_templates(config, base_dir, loaded_by_key, profiles)
1✔
1014
    if template_entries:
1✔
1015
        logger.debug("Found %d template entries", len(template_entries))
1✔
1016
    for template_entry in template_entries:
1✔
1017
        try:
1✔
1018
            logger.debug("Rendering template at %s", template_entry["path"])
1✔
1019
            outputs.append(
1✔
1020
                GeneratedOutput(
1021
                    template_entry["path"],
1022
                    render_template(
1023
                        template_entry["schemas"],
1024
                        doc_mode=template_entry["doc_mode"],
1025
                        value_mode=template_entry["value_mode"],
1026
                        title=template_entry["title"],
1027
                        description=template_entry["description"],
1028
                        constants=constants,
1029
                        dimensions=dimensions,
1030
                        kind_map=kind_map,
1031
                        kind_allowlist=kind_allowlist,
1032
                        values=template_entry["values"],
1033
                    ),
1034
                )
1035
            )
1036
        except ValueError as exc:
×
1037
            raise click.ClickException(str(exc)) from exc
×
1038

1039
    return outputs
1✔
1040

1041

1042
def _collect_f2py_outputs(
1✔
1043
    loaded_entries: list[dict[str, Any]],
1044
    *,
1045
    helper_module: str,
1046
    helper_buffer: int,
1047
    kind_module: str,
1048
    kind_map: dict[str, str],
1049
    kind_allowlist: set[str],
1050
    constants: dict[str, int],
1051
    dimensions: dict[str, int],
1052
    f2cmap_path: Path | None,
1053
    f2py_c_types: F2pyCTypeMap,
1054
    py_style: str,
1055
    include_python: bool,
1056
) -> list[GeneratedOutput]:
1057
    outputs: list[GeneratedOutput] = []
1✔
1058
    f2py_groups: dict[Path, list[dict[str, Any]]] = {}
1✔
1059
    py_groups: dict[Path, list[tuple[dict[str, Any], Path]]] = {}
1✔
1060
    for loaded in loaded_entries:
1✔
1061
        entry = loaded["entry"]
1✔
1062
        schema = loaded["schema"]
1✔
1063
        f2py_path = entry["f2py_path"]
1✔
1064
        if f2py_path is None:
1✔
1065
            continue
1✔
1066
        f2py_groups.setdefault(f2py_path, []).append(schema)
1✔
1067
        py_path = entry["py_path"]
1✔
1068
        if include_python and py_path is not None:
1✔
1069
            py_groups.setdefault(py_path, []).append((schema, f2py_path))
1✔
1070

1071
    for f2py_path, schemas in f2py_groups.items():
1✔
1072
        try:
1✔
1073
            logger.debug("Rendering f2py wrappers at %s", f2py_path)
1✔
1074
            outputs.append(
1✔
1075
                GeneratedOutput(
1076
                    f2py_path,
1077
                    render_f2py_wrappers(
1078
                        schemas,
1079
                        file_name=f2py_path.name,
1080
                        helper_module=helper_module,
1081
                        kind_module=kind_module,
1082
                        kind_map=kind_map,
1083
                        kind_allowlist=kind_allowlist,
1084
                        constants=constants,
1085
                        dimensions=dimensions,
1086
                        errmsg_len=helper_buffer,
1087
                    ),
1088
                )
1089
            )
1090
        except ValueError as exc:
×
1091
            raise click.ClickException(str(exc)) from exc
×
1092

1093
    if f2cmap_path is not None:
1✔
1094
        try:
1✔
1095
            usage = merge_f2py_kind_usage(
1✔
1096
                collect_f2py_kind_usage(schemas, constants=constants, dimensions=dimensions)
1097
                for schemas in f2py_groups.values()
1098
            )
1099
            logger.debug("Rendering f2py kind map at %s", f2cmap_path)
1✔
1100
            outputs.append(
1✔
1101
                GeneratedOutput(
1102
                    f2cmap_path,
1103
                    render_f2cmap(usage, f2py_c_types),
1104
                )
1105
            )
1106
        except ValueError as exc:
×
1107
            raise click.ClickException(str(exc)) from exc
×
1108

1109
    if not include_python:
1✔
1110
        return outputs
1✔
1111
    for py_path, entries in py_groups.items():
1✔
1112
        try:
1✔
1113
            logger.debug("Rendering Python f2py wrappers at %s", py_path)
1✔
1114
            specs = [
1✔
1115
                (
1116
                    build_f2py_namelist_spec(
1117
                        schema,
1118
                        helper_module=helper_module,
1119
                        kind_module=kind_module,
1120
                        kind_map=kind_map,
1121
                        kind_allowlist=kind_allowlist,
1122
                        constants=constants,
1123
                        dimensions=dimensions,
1124
                        errmsg_len=helper_buffer,
1125
                    ),
1126
                    f2py_path.stem,
1127
                )
1128
                for schema, f2py_path in entries
1129
            ]
1130
            outputs.append(
1✔
1131
                GeneratedOutput(
1132
                    py_path,
1133
                    render_python_wrappers(
1134
                        specs,
1135
                        py_style=py_style,
1136
                    ),
1137
                )
1138
            )
1139
        except ValueError as exc:
×
1140
            raise click.ClickException(str(exc)) from exc
×
1141
    return outputs
1✔
1142

1143

1144
def _generate_f2py_outputs(
1✔
1145
    loaded_entries: list[dict[str, Any]],
1146
    *,
1147
    helper_module: str,
1148
    helper_buffer: int,
1149
    kind_module: str,
1150
    kind_map: dict[str, str],
1151
    kind_allowlist: set[str],
1152
    constants: dict[str, int],
1153
    dimensions: dict[str, int],
1154
    f2cmap_path: Path | None,
1155
    f2py_c_types: F2pyCTypeMap,
1156
    py_style: str,
1157
    include_python: bool,
1158
) -> None:
1159
    _write_generated_outputs(
1✔
1160
        _collect_f2py_outputs(
1161
            loaded_entries,
1162
            helper_module=helper_module,
1163
            helper_buffer=helper_buffer,
1164
            kind_module=kind_module,
1165
            kind_map=kind_map,
1166
            kind_allowlist=kind_allowlist,
1167
            constants=constants,
1168
            dimensions=dimensions,
1169
            f2cmap_path=f2cmap_path,
1170
            f2py_c_types=f2py_c_types,
1171
            py_style=py_style,
1172
            include_python=include_python,
1173
        )
1174
    )
1175

1176

1177
def _write_generated_outputs(outputs: list[GeneratedOutput]) -> None:
1✔
1178
    for output in outputs:
1✔
1179
        logger.info("Writing generated file %s", output.path)
1✔
1180
        output.path.parent.mkdir(parents=True, exist_ok=True)
1✔
1181
        output.path.write_text(output.content, encoding="ascii")
1✔
1182

1183

1184
def _check_generated_outputs(outputs: list[GeneratedOutput], *, show_diff: bool) -> int:
1✔
1185
    failed = 0
1✔
1186
    for output in outputs:
1✔
1187
        if not output.path.exists():
1✔
1188
            failed += 1
1✔
1189
            click.echo(f"MISSING: {output.path}", err=True)
1✔
1190
            continue
1✔
1191
        current = output.path.read_text(encoding="ascii")
1✔
1192
        if current != output.content:
1✔
1193
            failed += 1
1✔
1194
            click.echo(f"DIFF: {output.path}", err=True)
1✔
1195
            if show_diff:
1✔
1196
                diff = unified_diff(
1✔
1197
                    current.splitlines(keepends=True),
1198
                    output.content.splitlines(keepends=True),
1199
                    fromfile=f"current {output.path}",
1200
                    tofile=f"generated {output.path}",
1201
                )
1202
                for line in diff:
1✔
1203
                    click.echo(line, nl=False)
1✔
1204
            continue
1✔
1205
        logger.debug("OK: %s", output.path)
1✔
1206
    return failed
1✔
1207

1208

1209
@click.group(context_settings=_CONTEXT_SETTINGS)
1✔
1210
@click.version_option(__version__, "-V", "--version", prog_name="nml-tools")
1✔
1211
@click.option("--verbose", "-v", count=True, help="Increase verbosity (repeatable).")
1✔
1212
@click.option("--quiet", "-q", count=True, help="Decrease verbosity (repeatable).")
1✔
1213
def cli(verbose: int, quiet: int) -> None:
1✔
1214
    """nml-tools command line interface."""
1215
    _configure_logging(verbose, quiet)
1✔
1216

1217

1218
@cli.command("generate", context_settings=_CONTEXT_SETTINGS)
1✔
1219
@click.option(
1✔
1220
    "--config",
1221
    "config_path",
1222
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1223
    default=None,
1224
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1225
)
1226
def generate(config_path: Path | None) -> None:
1✔
1227
    """Generate outputs from a configuration file."""
1228
    config, config_path = _load_config_checked(config_path)
1✔
1229
    logger.info("Loading config from %s", config_path)
1✔
1230
    _write_generated_outputs(_collect_generated_outputs(config, config_path))
1✔
1231

1232

1233
@cli.command("check", context_settings=_CONTEXT_SETTINGS)
1✔
1234
@click.option(
1✔
1235
    "--config",
1236
    "config_path",
1237
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1238
    default=None,
1239
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1240
)
1241
@click.option(
1✔
1242
    "--diff",
1243
    "show_diff",
1244
    is_flag=True,
1245
    help="Show unified diffs for generated files that differ.",
1246
)
1247
def check(config_path: Path | None, show_diff: bool) -> None:
1✔
1248
    """Check that configured generated files are up to date."""
1249
    config, config_path = _load_config_checked(config_path)
1✔
1250
    logger.debug("Loading config from %s", config_path)
1✔
1251
    failures = _check_generated_outputs(
1✔
1252
        _collect_generated_outputs(config, config_path),
1253
        show_diff=show_diff,
1254
    )
1255
    if failures:
1✔
1256
        raise click.ClickException(
1✔
1257
            f"generated files are out of date: {failures} file(s) differ or are missing"
1258
        )
1259

1260

1261
@cli.command("gen-fortran", context_settings=_CONTEXT_SETTINGS)
1✔
1262
@click.option(
1✔
1263
    "--config",
1264
    "config_path",
1265
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1266
    default=None,
1267
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1268
)
1269
def gen_fortran(config_path: Path | None) -> None:
1✔
1270
    """Generate Fortran module(s)."""
1271
    config, config_path = _load_config_checked(config_path)
1✔
1272
    logger.info("Loading config from %s", config_path)
1✔
1273
    base_dir = config_path.parent
1✔
1274
    logger.debug("Base directory: %s", base_dir)
1✔
1275
    helper_path, helper_module, helper_buffer, helper_header = _load_helper_settings(
1✔
1276
        config,
1277
        base_dir,
1278
    )
1279
    module_doc, _, _, py_style = _load_documentation_settings(config)
1✔
1280
    constants, constant_specs = _load_constants(config)
1✔
1281
    dimensions, dimension_specs = _load_dimensions(config, constants)
1✔
1282
    kind_module, kind_map, kind_allowlist = _load_kind_settings(config)
1✔
1283
    f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir)
1✔
1284
    resolver = SchemaResolver()
1✔
1285
    entries = _iter_namelists(config, base_dir)
1✔
1286
    logger.info("Found %d schema entries", len(entries))
1✔
1287
    loaded_entries: list[dict[str, Any]] = []
1✔
1288
    for entry in entries:
1✔
1289
        schema_path = entry["schema"]
1✔
1290
        if schema_path is None:
1✔
1291
            raise click.ClickException("namelists entry missing schema path")
×
1292
        try:
1✔
1293
            logger.info("Loading schema %s", schema_path)
1✔
1294
            schema = load_schema(schema_path, resolver=resolver)
1✔
1295
        except (FileNotFoundError, ValueError) as exc:
×
1296
            raise click.ClickException(str(exc)) from exc
×
1297
        loaded_entries.append({"entry": entry, "schema": schema})
1✔
1298
        mod_path = entry["mod_path"]
1✔
1299
        if mod_path is None:
1✔
1300
            continue
×
1301
        try:
1✔
1302
            logger.info("Generating Fortran module at %s", mod_path)
1✔
1303
            generate_fortran(
1✔
1304
                schema,
1305
                mod_path,
1306
                helper_module=helper_module,
1307
                kind_module=kind_module,
1308
                kind_map=kind_map,
1309
                kind_allowlist=kind_allowlist,
1310
                constants=constants,
1311
                dimensions=dimensions,
1312
                module_doc=module_doc,
1313
                f2py_handle_helpers=entry["f2py_path"] is not None,
1314
            )
1315
        except ValueError as exc:
×
1316
            raise click.ClickException(str(exc)) from exc
×
1317

1318
    try:
1✔
1319
        local_derived_types = collect_local_derived_types(
1✔
1320
            [loaded["schema"] for loaded in loaded_entries],
1321
            constants=constants,
1322
        )
1323
        if local_derived_types and helper_path is None:
1✔
1324
            raise ValueError("locally generated derived types require a configured helper output")
×
1325
        if helper_path is not None:
1✔
1326
            logger.info("Generating helper module at %s", helper_path)
×
1327
            generate_helper(
×
1328
                helper_path,
1329
                module_name=helper_module,
1330
                len_buf=helper_buffer,
1331
                constants=constant_specs + dimension_specs,
1332
                local_derived_types=local_derived_types,
1333
                kind_module=kind_module,
1334
                kind_map=kind_map,
1335
                kind_allowlist=kind_allowlist,
1336
                module_doc=module_doc,
1337
                helper_header=helper_header,
1338
            )
1339
    except ValueError as exc:
×
1340
        raise click.ClickException(str(exc)) from exc
×
1341

1342
    _generate_f2py_outputs(
1✔
1343
        loaded_entries,
1344
        helper_module=helper_module,
1345
        helper_buffer=helper_buffer,
1346
        kind_module=kind_module,
1347
        kind_map=kind_map,
1348
        kind_allowlist=kind_allowlist,
1349
        constants=constants,
1350
        dimensions=dimensions,
1351
        f2cmap_path=f2cmap_path,
1352
        f2py_c_types=f2py_c_types,
1353
        py_style=py_style,
1354
        include_python=False,
1355
    )
1356

1357

1358
@cli.command("validate", context_settings=_CONTEXT_SETTINGS)
1✔
1359
@click.option(
1✔
1360
    "--config",
1361
    "config_path",
1362
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1363
    default=None,
1364
)
1365
@click.option(
1✔
1366
    "--schema",
1367
    "schema_paths",
1368
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1369
    multiple=True,
1370
)
1371
@click.option(
1✔
1372
    "--input",
1373
    "input_option",
1374
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1375
)
1376
@click.option(
1✔
1377
    "--profile",
1378
    "profile_name",
1379
    help="Validate against one configured file profile.",
1380
)
1381
@click.option(
1✔
1382
    "--constants",
1383
    "constant_args",
1384
    metavar="NAME=INT",
1385
    type=_CONSTANT_TYPE,
1386
    multiple=True,
1387
    help="Additional integer constants as NAME=INT (repeatable).",
1388
)
1389
@click.option(
1✔
1390
    "--dimensions",
1391
    "dimension_args",
1392
    metavar="NAME=INT",
1393
    type=_DIMENSION_TYPE,
1394
    multiple=True,
1395
    help="Runtime dimensions as NAME=INT (repeatable).",
1396
)
1397
@click.argument(
1✔
1398
    "input_path",
1399
    required=False,
1400
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1401
)
1402
def validate(
1✔
1403
    config_path: Path | None,
1404
    schema_paths: tuple[Path, ...],
1405
    input_option: Path | None,
1406
    profile_name: str | None,
1407
    constant_args: tuple[tuple[str, int], ...],
1408
    dimension_args: tuple[tuple[str, int], ...],
1409
    input_path: Path | None,
1410
) -> None:
1411
    """Validate a namelist file against schema definitions."""
1412
    if input_option is not None and input_path is not None:
1✔
1413
        raise click.ClickException("input path provided twice")
×
1414
    input_path = input_option or input_path
1✔
1415
    if input_path is None:
1✔
1416
        raise click.ClickException("input path is required")
×
1417
    constants = _parse_cli_constants(constant_args)
1✔
1418
    dimension_overrides = _parse_cli_dimensions(dimension_args)
1✔
1419
    dimensions: dict[str, int] = {}
1✔
1420
    schemas: list[dict[str, Any]] = []
1✔
1421
    resolver = SchemaResolver()
1✔
1422

1423
    if schema_paths:
1✔
1424
        if profile_name is not None:
1✔
1425
            raise click.ClickException("--profile can only be used with config-based validation")
1✔
1426
        if config_path is not None:
1✔
1427
            config, config_path = _load_config_checked(config_path)
×
1428
            logger.info("Loading config from %s", config_path)
×
1429
            cfg_constants, _ = _load_constants(config)
×
1430
            dimensions, _ = _load_dimensions(config, cfg_constants)
×
1431
            constants = {**cfg_constants, **constants}
×
1432
            dimensions = {**dimensions, **dimension_overrides}
×
1433
        else:
1434
            dimensions = dimension_overrides
1✔
1435
        for schema_file in schema_paths:
1✔
1436
            try:
1✔
1437
                logger.info("Loading schema %s", schema_file)
1✔
1438
                schemas.append(load_schema(schema_file, resolver=resolver))
1✔
1439
            except (FileNotFoundError, ValueError) as exc:
×
1440
                raise click.ClickException(str(exc)) from exc
×
1441
        require_all = True
1✔
1442
    else:
1443
        config, config_path = _load_config_checked(config_path)
1✔
1444
        logger.info("Loading config from %s", config_path)
1✔
1445
        base_dir = config_path.parent
1✔
1446
        cfg_constants, _ = _load_constants(config)
1✔
1447
        dimensions, _ = _load_dimensions(config, cfg_constants)
1✔
1448
        constants = {**cfg_constants, **constants}
1✔
1449
        dimensions = {**dimensions, **dimension_overrides}
1✔
1450
        loaded_namelists = _load_namelist_registry(config, base_dir, resolver)
1✔
1451
        loaded_by_key = _namelist_registry_by_key(loaded_namelists)
1✔
1452
        if profile_name is not None:
1✔
1453
            profiles = _iter_file_profiles(config, loaded_by_key)
1✔
1454
            profile = profiles.get(profile_name.lower())
1✔
1455
            if profile is None:
1✔
1456
                raise click.ClickException(f"unknown file profile '{profile_name}'")
×
1457
            loaded_namelists = [loaded_by_key[key] for key in profile.namelists]
1✔
1458
        logger.info("Found %d schema entries", len(loaded_namelists))
1✔
1459
        schemas = [loaded.schema for loaded in loaded_namelists]
1✔
1460
        require_all = False
1✔
1461

1462
    if not schemas:
1✔
1463
        raise click.ClickException("no schemas provided for validation")
×
1464
    _reject_constant_dimension_overlap(constants, dimensions)
1✔
1465

1466
    try:
1✔
1467
        logger.info("Reading namelist %s", input_path)
1✔
1468
        namelist_file = f90nml.read(input_path)
1✔
1469
    except Exception as exc:  # pragma: no cover - f90nml raises custom errors
1470
        raise click.ClickException(f"failed to read namelist: {exc}") from exc
1471

1472
    file_entries: dict[str, tuple[str, Any]] = {}
1✔
1473
    for name, values in namelist_file.items():
1✔
1474
        if not isinstance(name, str):
1✔
1475
            raise click.ClickException("namelist names must be strings")
×
1476
        key = name.lower()
1✔
1477
        if key in file_entries:
1✔
1478
            raise click.ClickException(f"namelist '{name}' appears multiple times")
×
1479
        file_entries[key] = (name, values)
1✔
1480

1481
    schema_entries: dict[str, dict[str, Any]] = {}
1✔
1482
    for schema in schemas:
1✔
1483
        namelist_name = schema.get("x-fortran-namelist")
1✔
1484
        if not isinstance(namelist_name, str) or not namelist_name.strip():
1✔
1485
            raise click.ClickException("schema must define non-empty 'x-fortran-namelist'")
×
1486
        key = namelist_name.lower()
1✔
1487
        if key in schema_entries:
1✔
1488
            raise click.ClickException(f"duplicate schema for namelist '{namelist_name}'")
×
1489
        schema_entries[key] = schema
1✔
1490

1491
    for key, (name, _) in file_entries.items():
1✔
1492
        if key not in schema_entries:
1✔
1493
            raise click.ClickException(f"input contains unknown namelist '{name}'")
1✔
1494

1495
    validated = 0
1✔
1496
    for key, schema in schema_entries.items():
1✔
1497
        if key not in file_entries:
1✔
1498
            if require_all:
×
1499
                raise click.ClickException(
×
1500
                    f"input is missing namelist '{schema.get('x-fortran-namelist')}'"
1501
                )
1502
            continue
×
1503
        try:
1✔
1504
            validate_namelist(
1✔
1505
                schema,
1506
                _normalize_f90nml_values(file_entries[key][1]),
1507
                constants=constants,
1508
                dimensions=dimensions,
1509
            )
1510
        except ValueError as exc:
×
1511
            raise click.ClickException(str(exc)) from exc
×
1512
        validated += 1
1✔
1513
    logger.info("Validation completed (%d namelist%s).", validated, "" if validated == 1 else "s")
1✔
1514

1515

1516
@cli.command("gen-markdown", context_settings=_CONTEXT_SETTINGS)
1✔
1517
@click.option(
1✔
1518
    "--config",
1519
    "config_path",
1520
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1521
    default=None,
1522
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1523
)
1524
def gen_markdown(config_path: Path | None) -> None:
1✔
1525
    """Generate Markdown docs."""
1526
    config, config_path = _load_config_checked(config_path)
1✔
1527
    logger.info("Loading config from %s", config_path)
1✔
1528
    base_dir = config_path.parent
1✔
1529
    constants, _ = _load_constants(config)
1✔
1530
    dimensions, _ = _load_dimensions(config, constants)
1✔
1531
    _, md_doxygen_id_from_name, md_add_toc_statement, _ = _load_documentation_settings(
1✔
1532
        config
1533
    )
1534
    entries = _iter_namelists(config, base_dir)
1✔
1535
    resolver = SchemaResolver()
1✔
1536
    logger.info("Found %d schema entries", len(entries))
1✔
1537
    for entry in entries:
1✔
1538
        schema_path = entry["schema"]
1✔
1539
        if schema_path is None:
1✔
1540
            raise click.ClickException("namelists entry missing schema path")
×
1541
        try:
1✔
1542
            logger.info("Loading schema %s", schema_path)
1✔
1543
            schema = load_schema(schema_path, resolver=resolver)
1✔
1544
        except (FileNotFoundError, ValueError) as exc:
×
1545
            raise click.ClickException(str(exc)) from exc
×
1546
        doc_path = entry["doc_path"]
1✔
1547
        if doc_path is None:
1✔
1548
            continue
×
1549
        try:
1✔
1550
            logger.info("Generating Markdown docs at %s", doc_path)
1✔
1551
            generate_docs(
1✔
1552
                schema,
1553
                doc_path,
1554
                constants=constants,
1555
                dimensions=dimensions,
1556
                md_doxygen_id_from_name=md_doxygen_id_from_name,
1557
                md_add_toc_statement=md_add_toc_statement,
1558
            )
1559
        except ValueError as exc:
×
1560
            raise click.ClickException(str(exc)) from exc
×
1561

1562

1563
@cli.command("gen-template", context_settings=_CONTEXT_SETTINGS)
1✔
1564
@click.option(
1✔
1565
    "--config",
1566
    "config_path",
1567
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1568
    default=None,
1569
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1570
)
1571
def gen_template(config_path: Path | None) -> None:
1✔
1572
    """Generate template namelist(s)."""
1573
    config, config_path = _load_config_checked(config_path)
1✔
1574
    logger.info("Loading config from %s", config_path)
1✔
1575
    base_dir = config_path.parent
1✔
1576
    constants, _ = _load_constants(config)
1✔
1577
    dimensions, _ = _load_dimensions(config, constants)
1✔
1578
    _, kind_map, kind_allowlist = _load_kind_settings(config)
1✔
1579
    resolver = SchemaResolver()
1✔
1580
    loaded_namelists = _load_namelist_registry(config, base_dir, resolver)
1✔
1581
    loaded_by_key = _namelist_registry_by_key(loaded_namelists)
1✔
1582
    profiles = _iter_file_profiles(config, loaded_by_key)
1✔
1583
    templates = _iter_templates(config, base_dir, loaded_by_key, profiles)
1✔
1584
    if not templates:
1✔
1585
        raise click.ClickException("config must define non-empty 'templates'")
×
1586
    logger.info("Found %d template entries", len(templates))
1✔
1587
    for entry in templates:
1✔
1588
        try:
1✔
1589
            logger.info("Generating template at %s", entry["path"])
1✔
1590
            generate_template(
1✔
1591
                entry["schemas"],
1592
                entry["path"],
1593
                doc_mode=entry["doc_mode"],
1594
                value_mode=entry["value_mode"],
1595
                title=entry["title"],
1596
                description=entry["description"],
1597
                constants=constants,
1598
                dimensions=dimensions,
1599
                kind_map=kind_map,
1600
                kind_allowlist=kind_allowlist,
1601
                values=entry["values"],
1602
            )
1603
        except ValueError as exc:
×
1604
            raise click.ClickException(str(exc)) from exc
×
1605

1606

1607
def main(argv: list[str] | None = None) -> int:
1✔
1608
    """Entry point for the CLI."""
1609
    try:
×
1610
        cli.main(args=argv, prog_name="nml-tools", standalone_mode=False)
×
1611
    except Exit as exc:
×
1612
        return exc.exit_code
×
1613
    except click.ClickException as exc:
×
1614
        exc.show()
×
1615
        return 1
×
1616
    return 0
×
1617

1618

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