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

MuellerSeb / nml-tools / 26884157701

03 Jun 2026 12:16PM UTC coverage: 84.165% (+0.2%) from 83.989%
26884157701

Pull #38

github

MuellerSeb
Add optional namelist config names

Allow each configured namelist entry to declare the expected namelist name and validate it against the resolved schema x-fortran-namelist value case-insensitively.

Keep schema names canonical for registry lookups and generation while improving config readability for file profile and template namelist lists. Document the field and cover matching, omitted names, invalid names, mismatches, and profile/template usage.
Pull Request #38: Support legacy template schema inclusion and namelist name checks

41 of 43 new or added lines in 1 file covered. (95.35%)

1 existing line in 1 file now uncovered.

3949 of 4692 relevant lines covered (84.16%)

0.84 hits per line

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

81.57
/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
            try:
1✔
593
                validate_user_fortran_identifier(
1✔
594
                    name_raw.strip(),
595
                    label=f"namelists entry name '{name_raw.strip()}'",
596
                )
597
            except ValueError as exc:
1✔
598
                raise click.ClickException(str(exc)) from exc
1✔
599
        f2py_path = _resolve_optional_path(
1✔
600
            entry.get("f2py_path"),
601
            base_dir=base_dir,
602
            key="f2py_path",
603
        )
604
        py_path = _resolve_optional_path(
1✔
605
            entry.get("py_path"),
606
            base_dir=base_dir,
607
            key="py_path",
608
        )
609
        mod_path = _resolve_optional_path(
1✔
610
            entry.get("mod_path"),
611
            base_dir=base_dir,
612
            key="mod_path",
613
        )
614
        if f2py_path is not None and mod_path is None:
1✔
615
            raise click.ClickException(
×
616
                "namelists entry with 'f2py_path' must define 'mod_path'"
617
            )
618
        if py_path is not None and f2py_path is None:
1✔
619
            raise click.ClickException("namelists entry with 'py_path' must define 'f2py_path'")
×
620
        entries.append(
1✔
621
            {
622
                "name": name_raw.strip() if isinstance(name_raw, str) else None,
623
                "schema": schema_path,
624
                "mod_path": mod_path,
625
                "doc_path": _resolve_optional_path(
626
                    entry.get("doc_path"),
627
                    base_dir=base_dir,
628
                    key="doc_path",
629
                ),
630
                "temp_path": _resolve_optional_path(
631
                    entry.get("temp_path"),
632
                    base_dir=base_dir,
633
                    key="temp_path",
634
                ),
635
                "f2py_path": f2py_path,
636
                "py_path": py_path,
637
            }
638
        )
639
    return entries
1✔
640

641

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

682

683
def _namelist_registry_by_key(
1✔
684
    registry: list[LoadedNamelist],
685
) -> dict[str, LoadedNamelist]:
686
    return {loaded.key: loaded for loaded in registry}
1✔
687

688

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

712

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

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

750

751
def _optional_string(
1✔
752
    entry: dict[str, Any],
753
    key: str,
754
    *,
755
    label: str,
756
) -> str | None:
757
    value = entry.get(key)
1✔
758
    if value is None:
1✔
759
        return None
1✔
760
    if not isinstance(value, str):
1✔
761
        raise click.ClickException(f"{label} '{key}' must be a string")
1✔
762
    return value
1✔
763

764

765
def _iter_file_profiles(
1✔
766
    config: dict[str, Any],
767
    registry: dict[str, LoadedNamelist],
768
) -> dict[str, FileProfile]:
769
    raw_entries = config.get("file_profiles")
1✔
770
    if raw_entries is None:
1✔
771
        return {}
1✔
772
    if not isinstance(raw_entries, list) or not raw_entries:
1✔
773
        raise click.ClickException("config 'file_profiles' must be a non-empty list")
×
774

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

807

808
def _iter_templates(
1✔
809
    config: dict[str, Any],
810
    base_dir: Path,
811
    registry: dict[str, LoadedNamelist],
812
    profiles: dict[str, FileProfile],
813
) -> list[dict[str, Any]]:
814
    raw_entries = config.get("templates")
1✔
815
    if raw_entries is None:
1✔
816
        return []
1✔
817
    if not isinstance(raw_entries, list) or not raw_entries:
1✔
818
        raise click.ClickException("config 'templates' must be a non-empty list")
×
819

820
    entries: list[dict[str, Any]] = []
1✔
821
    for entry in raw_entries:
1✔
822
        if not isinstance(entry, dict):
1✔
823
            raise click.ClickException("each templates entry must be a table")
×
824
        if "output" in entry:
1✔
825
            raise click.ClickException("templates use 'path', not deprecated 'output'")
1✔
826
        path_raw = entry.get("path")
1✔
827
        if not isinstance(path_raw, str):
1✔
828
            raise click.ClickException("templates entry must define string 'path'")
×
829
        output_path = base_dir / path_raw
1✔
830

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

871
        doc_mode = entry.get("doc_mode", "plain")
1✔
872
        if not isinstance(doc_mode, str):
1✔
873
            raise click.ClickException("templates 'doc_mode' must be a string")
×
874
        value_mode = entry.get("value_mode", "empty")
1✔
875
        if not isinstance(value_mode, str):
1✔
876
            raise click.ClickException("templates 'value_mode' must be a string")
×
877
        values_raw = entry.get("values", {})
1✔
878
        if values_raw is None:
1✔
879
            values_raw = {}
×
880
        if not isinstance(values_raw, dict):
1✔
881
            raise click.ClickException("templates 'values' must be a table")
×
882

883
        entries.append(
1✔
884
            {
885
                "path": output_path,
886
                "schemas": [loaded.schema for loaded in loaded_namelists],
887
                "doc_mode": doc_mode,
888
                "value_mode": value_mode,
889
                "title": title,
890
                "description": description,
891
                "values": values_raw,
892
            }
893
        )
894
    return entries
1✔
895

896

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

920
    loaded_namelists = _load_namelist_registry(config, base_dir, resolver)
1✔
921
    loaded_by_key = _namelist_registry_by_key(loaded_namelists)
1✔
922
    profiles = _iter_file_profiles(config, loaded_by_key)
1✔
923
    logger.debug("Found %d schema entries", len(loaded_namelists))
1✔
924
    loaded_entries: list[dict[str, Any]] = [
1✔
925
        {"entry": loaded.entry, "schema": loaded.schema} for loaded in loaded_namelists
926
    ]
927
    for loaded in loaded_namelists:
1✔
928
        namelist_entry = loaded.entry
1✔
929
        schema = loaded.schema
1✔
930

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

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

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

1003
    outputs.extend(
1✔
1004
        _collect_f2py_outputs(
1005
            loaded_entries,
1006
            helper_module=helper_module,
1007
            helper_buffer=helper_buffer,
1008
            kind_module=kind_module,
1009
            kind_map=kind_map,
1010
            kind_allowlist=kind_allowlist,
1011
            constants=constants,
1012
            dimensions=dimensions,
1013
            f2cmap_path=f2cmap_path,
1014
            f2py_c_types=f2py_c_types,
1015
            py_style=py_style,
1016
            include_python=True,
1017
        )
1018
    )
1019

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

1046
    return outputs
1✔
1047

1048

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

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

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

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

1150

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

1183

1184
def _write_generated_outputs(outputs: list[GeneratedOutput]) -> None:
1✔
1185
    for output in outputs:
1✔
1186
        logger.info("Writing generated file %s", output.path)
1✔
1187
        output.path.parent.mkdir(parents=True, exist_ok=True)
1✔
1188
        output.path.write_text(output.content, encoding="ascii")
1✔
1189

1190

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

1215

1216
@click.group(context_settings=_CONTEXT_SETTINGS)
1✔
1217
@click.version_option(__version__, "-V", "--version", prog_name="nml-tools")
1✔
1218
@click.option("--verbose", "-v", count=True, help="Increase verbosity (repeatable).")
1✔
1219
@click.option("--quiet", "-q", count=True, help="Decrease verbosity (repeatable).")
1✔
1220
def cli(verbose: int, quiet: int) -> None:
1✔
1221
    """nml-tools command line interface."""
1222
    _configure_logging(verbose, quiet)
1✔
1223

1224

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

1239

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

1267

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

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

1349
    _generate_f2py_outputs(
1✔
1350
        loaded_entries,
1351
        helper_module=helper_module,
1352
        helper_buffer=helper_buffer,
1353
        kind_module=kind_module,
1354
        kind_map=kind_map,
1355
        kind_allowlist=kind_allowlist,
1356
        constants=constants,
1357
        dimensions=dimensions,
1358
        f2cmap_path=f2cmap_path,
1359
        f2py_c_types=f2py_c_types,
1360
        py_style=py_style,
1361
        include_python=False,
1362
    )
1363

1364

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

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

1469
    if not schemas:
1✔
1470
        raise click.ClickException("no schemas provided for validation")
×
1471
    _reject_constant_dimension_overlap(constants, dimensions)
1✔
1472

1473
    try:
1✔
1474
        logger.info("Reading namelist %s", input_path)
1✔
1475
        namelist_file = f90nml.read(input_path)
1✔
1476
    except Exception as exc:  # pragma: no cover - f90nml raises custom errors
1477
        raise click.ClickException(f"failed to read namelist: {exc}") from exc
1478

1479
    file_entries: dict[str, tuple[str, Any]] = {}
1✔
1480
    for name, values in namelist_file.items():
1✔
1481
        if not isinstance(name, str):
1✔
1482
            raise click.ClickException("namelist names must be strings")
×
1483
        key = name.lower()
1✔
1484
        if key in file_entries:
1✔
1485
            raise click.ClickException(f"namelist '{name}' appears multiple times")
×
1486
        file_entries[key] = (name, values)
1✔
1487

1488
    schema_entries: dict[str, dict[str, Any]] = {}
1✔
1489
    for schema in schemas:
1✔
1490
        namelist_name = schema.get("x-fortran-namelist")
1✔
1491
        if not isinstance(namelist_name, str) or not namelist_name.strip():
1✔
1492
            raise click.ClickException("schema must define non-empty 'x-fortran-namelist'")
×
1493
        key = namelist_name.lower()
1✔
1494
        if key in schema_entries:
1✔
1495
            raise click.ClickException(f"duplicate schema for namelist '{namelist_name}'")
×
1496
        schema_entries[key] = schema
1✔
1497

1498
    for key, (name, _) in file_entries.items():
1✔
1499
        if key not in schema_entries:
1✔
1500
            raise click.ClickException(f"input contains unknown namelist '{name}'")
1✔
1501

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

1522

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

1569

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

1613

1614
def main(argv: list[str] | None = None) -> int:
1✔
1615
    """Entry point for the CLI."""
1616
    try:
×
1617
        cli.main(args=argv, prog_name="nml-tools", standalone_mode=False)
×
1618
    except Exit as exc:
×
1619
        return exc.exit_code
×
1620
    except click.ClickException as exc:
×
1621
        exc.show()
×
1622
        return 1
×
1623
    return 0
×
1624

1625

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