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

MuellerSeb / nml-tools / 26169996067

20 May 2026 02:41PM UTC coverage: 79.35% (+5.0%) from 74.315%
26169996067

Pull #27

github

MuellerSeb
Align integer-only constant API typing
Pull Request #27: Add Runtime Array Dimensions

466 of 520 new or added lines in 7 files covered. (89.62%)

4 existing lines in 2 files now uncovered.

2663 of 3356 relevant lines covered (79.35%)

0.79 hits per line

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

78.33
/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 re
1✔
7
import sys
1✔
8
from dataclasses import dataclass
1✔
9
from difflib import unified_diff
1✔
10
from pathlib import Path
1✔
11
from typing import TYPE_CHECKING, Any
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 ._version import __version__
1✔
19
from .codegen_f2py import (
1✔
20
    F2pyCTypeMap,
21
    build_f2py_namelist_spec,
22
    collect_f2py_kind_usage,
23
    merge_f2py_kind_usage,
24
    render_f2cmap,
25
    render_f2py_wrappers,
26
    render_python_wrappers,
27
)
28
from .codegen_fortran import (
1✔
29
    ConstantSpec,
30
    generate_fortran,
31
    generate_helper,
32
    render_fortran,
33
    render_helper,
34
)
35
from .codegen_markdown import generate_docs, render_docs
1✔
36
from .codegen_template import generate_template, render_template
1✔
37
from .schema import load_schema
1✔
38
from .validate import validate_namelist
1✔
39

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

45
logger = logging.getLogger(__name__)
1✔
46
_FORTRAN_IDENTIFIER = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
1✔
47
_CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
1✔
48
_DEFAULT_CONFIG = Path("nml-config.toml")
1✔
49
_PYPROJECT_CONFIG = Path("pyproject.toml")
1✔
50

51
if TYPE_CHECKING:
52
    _NamedIntegerTypeBase = click.ParamType[tuple[str, int]]
53
else:
54
    _NamedIntegerTypeBase = click.ParamType
1✔
55

56

57
class NamedIntegerType(_NamedIntegerTypeBase):
1✔
58
    """Parse NAME=INT values for CLI options."""
59

60
    name = "NAME=INT"
1✔
61

62
    def __init__(self, *, label: str, positive: bool = False) -> None:
1✔
63
        self._label = label
1✔
64
        self._positive = positive
1✔
65

66
    def convert(
1✔
67
        self,
68
        value: Any,
69
        param: click.Parameter | None,
70
        ctx: click.Context | None,
71
    ) -> tuple[str, int]:
72
        if not isinstance(value, str) or "=" not in value:
1✔
73
            self.fail("must be NAME=INT", param, ctx)
1✔
74

75
        raw_name, raw_value = value.split("=", 1)
1✔
76
        name = raw_name.strip()
1✔
77
        if not name:
1✔
NEW
78
            self.fail("must use non-empty names", param, ctx)
×
79
        if not _FORTRAN_IDENTIFIER.match(name):
1✔
80
            self.fail(f"{self._label} '{name}' must be a valid identifier", param, ctx)
1✔
81

82
        value_text = raw_value.strip()
1✔
83
        if not value_text:
1✔
NEW
84
            self.fail(f"{self._label} '{name}' must define a value", param, ctx)
×
85
        if not re.fullmatch(r"[+-]?\d+", value_text):
1✔
86
            self.fail(f"{self._label} '{name}' value must be an integer", param, ctx)
1✔
87

88
        parsed_value = int(value_text)
1✔
89
        if self._positive and parsed_value <= 0:
1✔
90
            self.fail(f"{self._label} '{name}' value must be positive", param, ctx)
1✔
91
        return name.lower(), parsed_value
1✔
92

93

94
_CONSTANT_TYPE = NamedIntegerType(label="constant")
1✔
95
_DIMENSION_TYPE = NamedIntegerType(label="dimension", positive=True)
1✔
96

97

98
@dataclass(frozen=True)
1✔
99
class GeneratedOutput:
1✔
100
    """Generated file content for write/check commands."""
101

102
    path: Path
1✔
103
    content: str
1✔
104

105

106
def _configure_logging(verbose: int, quiet: int) -> None:
1✔
107
    base_level = logging.INFO
1✔
108
    level = base_level - (10 * verbose) + (10 * quiet)
1✔
109
    level = max(logging.DEBUG, min(logging.CRITICAL, level))
1✔
110
    logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
1✔
111

112

113
def _load_toml(path: Path) -> dict[str, Any]:
1✔
114
    with path.open("rb") as handle:
1✔
115
        data = tomllib.load(handle)
1✔
116
    if not isinstance(data, dict):
1✔
117
        raise click.ClickException("config must be a table")
×
118
    return data
1✔
119

120

121
def _load_toml_checked(path: Path) -> dict[str, Any]:
1✔
122
    try:
1✔
123
        return _load_toml(path)
1✔
124
    except FileNotFoundError as exc:
1✔
125
        raise click.ClickException(str(exc)) from exc
1✔
126
    except Exception as exc:  # pragma: no cover - tomllib may raise ValueError
127
        raise click.ClickException(f"failed to read config: {exc}") from exc
128

129

130
def _load_config_checked(path: Path | None) -> tuple[dict[str, Any], Path]:
1✔
131
    config_path = _resolve_config_path(path)
1✔
132
    raw_config = _load_toml_checked(config_path)
1✔
133
    if config_path.name == _PYPROJECT_CONFIG.name:
1✔
134
        config = _extract_pyproject_config(raw_config)
1✔
135
    else:
136
        config = raw_config
1✔
137
    _check_minimum_version(config)
1✔
138
    return config, config_path
1✔
139

140

141
def _resolve_config_path(path: Path | None) -> Path:
1✔
142
    if path is not None:
1✔
143
        return path
1✔
144
    if _DEFAULT_CONFIG.is_file():
1✔
145
        return _DEFAULT_CONFIG
1✔
146
    if _PYPROJECT_CONFIG.is_file():
1✔
147
        raw_config = _load_toml_checked(_PYPROJECT_CONFIG)
1✔
148
        if _has_pyproject_config(raw_config):
1✔
149
            return _PYPROJECT_CONFIG
1✔
150
    raise click.ClickException(
1✔
151
        "no config found; create nml-config.toml or add [tool.nml-tools] to pyproject.toml"
152
    )
153

154

155
def _has_pyproject_config(config: dict[str, Any]) -> bool:
1✔
156
    tool_raw = config.get("tool")
1✔
157
    return isinstance(tool_raw, dict) and isinstance(tool_raw.get("nml-tools"), dict)
1✔
158

159

160
def _extract_pyproject_config(config: dict[str, Any]) -> dict[str, Any]:
1✔
161
    tool_raw = config.get("tool")
1✔
162
    if not isinstance(tool_raw, dict):
1✔
163
        raise click.ClickException("pyproject.toml must define [tool.nml-tools]")
1✔
164
    nml_tools_raw = tool_raw.get("nml-tools")
1✔
165
    if not isinstance(nml_tools_raw, dict):
1✔
166
        raise click.ClickException("pyproject.toml must define [tool.nml-tools]")
1✔
167
    return nml_tools_raw
1✔
168

169

170
def _check_minimum_version(config: dict[str, Any]) -> None:
1✔
171
    minimum_raw = config.get("minimum-version")
1✔
172
    if minimum_raw is None:
1✔
173
        return
1✔
174
    if not isinstance(minimum_raw, str) or not minimum_raw.strip():
1✔
175
        raise click.ClickException("config 'minimum-version' must be a non-empty string")
1✔
176
    try:
1✔
177
        minimum = Version(minimum_raw.strip())
1✔
178
    except InvalidVersion as exc:
1✔
179
        raise click.ClickException(
1✔
180
            f"config 'minimum-version' is not a valid version: {minimum_raw}"
181
        ) from exc
182
    try:
1✔
183
        current = Version(__version__)
1✔
184
    except InvalidVersion as exc:  # pragma: no cover - package version should be valid
185
        raise click.ClickException(f"nml-tools version is not valid: {__version__}") from exc
186
    if current < minimum:
1✔
187
        raise click.ClickException(
1✔
188
            f"config requires nml-tools >= {minimum}, current version is {current}"
189
        )
190

191

192
def _resolve_optional_path(
1✔
193
    value: Any,
194
    *,
195
    base_dir: Path,
196
    key: str,
197
) -> Path | None:
198
    if value is None:
1✔
199
        return None
1✔
200
    if not isinstance(value, str):
1✔
201
        raise click.ClickException(f"config '{key}' must be a string")
×
202
    return base_dir / value
1✔
203

204

205
def _load_helper_settings(
1✔
206
    config: dict[str, Any],
207
    base_dir: Path,
208
) -> tuple[Path | None, str, int, str | None]:
209
    default_buffer = 1024
1✔
210
    helper_raw = config.get("helper")
1✔
211
    if helper_raw is None:
1✔
212
        helper_path = _resolve_optional_path(
1✔
213
            config.get("helper_path"),
214
            base_dir=base_dir,
215
            key="helper_path",
216
        )
217
        helper_module_raw = config.get("helper_module", "nml_helper")
1✔
218
        if not isinstance(helper_module_raw, str):
1✔
219
            raise click.ClickException("config 'helper_module' must be a string")
×
220
        helper_module = helper_module_raw.strip()
1✔
221
        if not helper_module:
1✔
222
            raise click.ClickException("config 'helper_module' must be a non-empty string")
×
223
        return helper_path, helper_module, default_buffer, None
1✔
224

225
    if not isinstance(helper_raw, dict):
1✔
226
        raise click.ClickException("config 'helper' must be a table")
×
227
    if "helper_path" in config or "helper_module" in config:
1✔
228
        logger.warning("config uses [helper]; ignoring legacy helper_path/helper_module")
×
229
    helper_path = _resolve_optional_path(
1✔
230
        helper_raw.get("path"),
231
        base_dir=base_dir,
232
        key="helper.path",
233
    )
234
    helper_module_raw = helper_raw.get("module", "nml_helper")
1✔
235
    if not isinstance(helper_module_raw, str):
1✔
236
        raise click.ClickException("config 'helper.module' must be a string")
×
237
    helper_module = helper_module_raw.strip()
1✔
238
    if not helper_module:
1✔
239
        raise click.ClickException("config 'helper.module' must be a non-empty string")
×
240
    header_raw = helper_raw.get("header")
1✔
241
    if header_raw is None:
1✔
242
        header = None
1✔
243
    else:
244
        if not isinstance(header_raw, str):
×
245
            raise click.ClickException("config 'helper.header' must be a string")
×
246
        header = header_raw.rstrip()
×
247
        if not header:
×
248
            header = None
×
249
    buffer_raw = helper_raw.get("buffer", default_buffer)
1✔
250
    if isinstance(buffer_raw, bool) or not isinstance(buffer_raw, int):
1✔
251
        raise click.ClickException("config 'helper.buffer' must be an integer")
×
252
    if buffer_raw <= 0:
1✔
253
        raise click.ClickException("config 'helper.buffer' must be positive")
×
254
    return helper_path, helper_module, buffer_raw, header
1✔
255

256

257
def _load_kind_settings(config: dict[str, Any]) -> tuple[str, dict[str, str], set[str]]:
1✔
258
    kinds_raw = config.get("kinds")
1✔
259
    if not isinstance(kinds_raw, dict):
1✔
260
        raise click.ClickException("config must define a [kinds] table")
×
261
    module_raw = kinds_raw.get("module")
1✔
262
    if not isinstance(module_raw, str) or not module_raw.strip():
1✔
263
        raise click.ClickException("config 'kinds.module' must be a non-empty string")
×
264
    module = module_raw.strip()
1✔
265

266
    map_raw = kinds_raw.get("map", {})
1✔
267
    if map_raw is None:
1✔
268
        map_raw = {}
×
269
    if not isinstance(map_raw, dict):
1✔
270
        raise click.ClickException("config 'kinds.map' must be a table")
×
271
    kind_map: dict[str, str] = {}
1✔
272
    for alias, target in map_raw.items():
1✔
273
        if not isinstance(alias, str) or not isinstance(target, str):
1✔
274
            raise click.ClickException("config 'kinds.map' keys and values must be strings")
×
275
        kind_map[alias] = target
1✔
276

277
    real_raw = kinds_raw.get("real", [])
1✔
278
    integer_raw = kinds_raw.get("integer", [])
1✔
279
    if not isinstance(real_raw, list) or not all(isinstance(item, str) for item in real_raw):
1✔
280
        raise click.ClickException("config 'kinds.real' must be a list of strings")
×
281
    if not isinstance(integer_raw, list) or not all(isinstance(item, str) for item in integer_raw):
1✔
282
        raise click.ClickException("config 'kinds.integer' must be a list of strings")
×
283
    allowlist = set(real_raw) | set(integer_raw)
1✔
284

285
    return module, kind_map, allowlist
1✔
286

287

288
def _load_f2py_settings(
1✔
289
    config: dict[str, Any],
290
    base_dir: Path,
291
) -> tuple[Path | None, F2pyCTypeMap]:
292
    f2py_raw = config.get("f2py")
1✔
293
    if f2py_raw is None:
1✔
294
        return None, F2pyCTypeMap(real={}, integer={})
1✔
295
    if not isinstance(f2py_raw, dict):
1✔
296
        raise click.ClickException("config 'f2py' must be a table")
×
297
    if "python_package" in f2py_raw:
1✔
298
        raise click.ClickException(
×
299
            "config 'f2py.python_package' is no longer supported; "
300
            "place the f2py extension next to the generated Python wrapper"
301
        )
302

303
    f2cmap_path = _resolve_optional_path(
1✔
304
        f2py_raw.get("f2cmap_path"),
305
        base_dir=base_dir,
306
        key="f2py.f2cmap_path",
307
    )
308

309
    c_types_raw = f2py_raw.get("c_types", {})
1✔
310
    if c_types_raw is None:
1✔
311
        c_types_raw = {}
×
312
    if not isinstance(c_types_raw, dict):
1✔
313
        raise click.ClickException("config 'f2py.c_types' must be a table")
×
314

315
    real = _load_f2py_ctype_table(c_types_raw, "real")
1✔
316
    integer = _load_f2py_ctype_table(c_types_raw, "integer")
1✔
317
    return f2cmap_path, F2pyCTypeMap(real=real, integer=integer)
1✔
318

319

320
def _load_f2py_ctype_table(c_types: dict[str, Any], key: str) -> dict[str, str]:
1✔
321
    raw = c_types.get(key, {})
1✔
322
    if raw is None:
1✔
323
        raw = {}
×
324
    if not isinstance(raw, dict):
1✔
325
        raise click.ClickException(f"config 'f2py.c_types.{key}' must be a table")
×
326
    values: dict[str, str] = {}
1✔
327
    for kind, c_type in raw.items():
1✔
328
        if not isinstance(kind, str) or not kind.strip():
1✔
329
            raise click.ClickException(
×
330
                f"config 'f2py.c_types.{key}' keys must be non-empty strings"
331
            )
332
        if not isinstance(c_type, str) or not c_type.strip():
1✔
333
            raise click.ClickException(
×
334
                f"config 'f2py.c_types.{key}.{kind}' must be a non-empty string"
335
            )
336
        values[kind.strip()] = c_type.strip()
1✔
337
    return values
1✔
338

339

340
def _format_constant_literal(value: int) -> tuple[str, str]:
1✔
341
    if isinstance(value, bool):
1✔
342
        raise click.ClickException("config constants must not be boolean")
×
343
    if isinstance(value, int):
1✔
344
        return "integer", str(value)
1✔
NEW
345
    raise click.ClickException("config constants must be integers")
×
346

347

348
def _load_constants(config: dict[str, Any]) -> tuple[dict[str, int], list[ConstantSpec]]:
1✔
349
    constants_raw = config.get("constants", {})
1✔
350
    if constants_raw is None:
1✔
351
        constants_raw = {}
×
352
    if not isinstance(constants_raw, dict):
1✔
353
        raise click.ClickException("config 'constants' must be a table")
×
354

355
    constants: dict[str, int] = {}
1✔
356
    specs: list[ConstantSpec] = []
1✔
357
    for name_raw, entry in constants_raw.items():
1✔
358
        if not isinstance(name_raw, str):
1✔
359
            raise click.ClickException("config constants must use string keys")
×
360
        name = name_raw.strip()
1✔
361
        if not name:
1✔
362
            raise click.ClickException("config constants must have non-empty names")
×
363
        if not _FORTRAN_IDENTIFIER.match(name):
1✔
364
            raise click.ClickException(
×
365
                f"config constant '{name}' must be a valid Fortran identifier"
366
            )
367
        canonical_name = name.lower()
1✔
368
        if canonical_name in constants:
1✔
369
            raise click.ClickException(
1✔
370
                f"config constant '{name}' duplicates another constant name"
371
            )
372
        if not isinstance(entry, dict):
1✔
373
            raise click.ClickException(f"config constant '{name}' must be a table with 'value'")
×
374
        if "value" not in entry:
1✔
375
            raise click.ClickException(f"config constant '{name}' must define 'value'")
×
376
        value = entry.get("value")
1✔
377
        if isinstance(value, bool) or not isinstance(value, int):
1✔
378
            raise click.ClickException("config constants must be integers")
1✔
379
        type_spec, literal = _format_constant_literal(value)
1✔
380
        doc = entry.get("doc")
1✔
381
        if doc is not None:
1✔
382
            if not isinstance(doc, str):
1✔
383
                raise click.ClickException(f"config constant '{name}' doc must be a string")
×
384
            doc = " ".join(doc.splitlines()).strip() or None
1✔
385
        specs.append(
1✔
386
            ConstantSpec(
387
                name=canonical_name,
388
                type_spec=type_spec,
389
                value=literal,
390
                doc=doc,
391
            )
392
        )
393
        constants[canonical_name] = value
1✔
394
    return constants, specs
1✔
395

396

397
def _load_dimensions(
1✔
398
    config: dict[str, Any],
399
    constants: dict[str, int],
400
) -> tuple[dict[str, int], list[ConstantSpec]]:
401
    dimensions_raw = config.get("dimensions", {})
1✔
402
    if dimensions_raw is None:
1✔
NEW
403
        dimensions_raw = {}
×
404
    if not isinstance(dimensions_raw, dict):
1✔
NEW
405
        raise click.ClickException("config 'dimensions' must be a table")
×
406

407
    dimensions: dict[str, int] = {}
1✔
408
    specs: list[ConstantSpec] = []
1✔
409
    constant_names = {name.lower() for name in constants}
1✔
410
    for name_raw, entry in dimensions_raw.items():
1✔
411
        if not isinstance(name_raw, str):
1✔
NEW
412
            raise click.ClickException("config dimensions must use string keys")
×
413
        name = name_raw.strip()
1✔
414
        if not name:
1✔
NEW
415
            raise click.ClickException("config dimensions must have non-empty names")
×
416
        if not _FORTRAN_IDENTIFIER.match(name):
1✔
NEW
417
            raise click.ClickException(
×
418
                f"config dimension '{name}' must be a valid Fortran identifier"
419
            )
420
        canonical_name = name.lower()
1✔
421
        if canonical_name in constant_names:
1✔
422
            raise click.ClickException(
1✔
423
                f"config dimension '{name}' duplicates a constant name"
424
            )
425
        if canonical_name in dimensions:
1✔
426
            raise click.ClickException(
1✔
427
                f"config dimension '{name}' duplicates another dimension name"
428
            )
429
        if not isinstance(entry, dict):
1✔
NEW
430
            raise click.ClickException(f"config dimension '{name}' must be a table with 'value'")
×
431
        if "value" not in entry:
1✔
NEW
432
            raise click.ClickException(f"config dimension '{name}' must define 'value'")
×
433
        value = entry.get("value")
1✔
434
        if isinstance(value, bool) or not isinstance(value, int):
1✔
NEW
435
            raise click.ClickException(f"config dimension '{name}' value must be an integer")
×
436
        if value <= 0:
1✔
437
            raise click.ClickException(f"config dimension '{name}' value must be positive")
1✔
438
        doc = entry.get("doc")
1✔
439
        if doc is not None:
1✔
440
            if not isinstance(doc, str):
1✔
NEW
441
                raise click.ClickException(f"config dimension '{name}' doc must be a string")
×
442
            doc = " ".join(doc.splitlines()).strip() or None
1✔
443
        specs.append(
1✔
444
            ConstantSpec(
445
                name=canonical_name,
446
                type_spec="integer",
447
                value=str(value),
448
                doc=doc,
449
            )
450
        )
451
        dimensions[canonical_name] = value
1✔
452
    return dimensions, specs
1✔
453

454

455
def _load_bool_field(section: dict[str, Any], key: str, *, label: str) -> bool:
1✔
456
    value = section.get(key, False)
×
457
    if isinstance(value, bool):
×
458
        return value
×
459
    raise click.ClickException(f"config '{label}' must be a boolean")
×
460

461

462
def _load_documentation_settings(
1✔
463
    config: dict[str, Any],
464
) -> tuple[str | None, bool, bool, str]:
465
    doc_raw = config.get("documentation")
1✔
466
    if doc_raw is None:
1✔
467
        return None, False, False, "numpy"
1✔
468
    if not isinstance(doc_raw, dict):
×
469
        raise click.ClickException("config 'documentation' must be a table")
×
470
    module_doc = None
×
471
    if "module" in doc_raw:
×
472
        module_raw = doc_raw.get("module")
×
473
        if not isinstance(module_raw, str):
×
474
            raise click.ClickException("config 'documentation.module' must be a string")
×
475
        module_doc = module_raw.strip() or None
×
476
    md_doxygen_id_from_name = _load_bool_field(
×
477
        doc_raw,
478
        "md_doxygen_id_from_name",
479
        label="documentation.md_doxygen_id_from_name",
480
    )
481
    md_add_toc_statement = _load_bool_field(
×
482
        doc_raw,
483
        "md_add_toc_statement",
484
        label="documentation.md_add_toc_statement",
485
    )
486
    py_style_raw = doc_raw.get("py-style", "numpy")
×
487
    if not isinstance(py_style_raw, str):
×
488
        raise click.ClickException("config 'documentation.py-style' must be a string")
×
489
    py_style = py_style_raw.strip().lower()
×
490
    if py_style not in {"numpy", "doxygen"}:
×
491
        raise click.ClickException(
×
492
            "config 'documentation.py-style' must be 'numpy' or 'doxygen'"
493
        )
494
    return module_doc, md_doxygen_id_from_name, md_add_toc_statement, py_style
×
495

496

497
def _parse_cli_constants(values: tuple[tuple[str, int], ...]) -> dict[str, int]:
1✔
498
    constants: dict[str, int] = {}
1✔
499
    for name, value in values:
1✔
500
        if name in constants:
1✔
501
            raise click.ClickException(f"constant '{name}' duplicates another constant name")
1✔
502
        constants[name] = value
1✔
503
    return constants
1✔
504

505

506
def _parse_cli_dimensions(values: tuple[tuple[str, int], ...]) -> dict[str, int]:
1✔
507
    dimensions: dict[str, int] = {}
1✔
508
    for name, value in values:
1✔
509
        if name in dimensions:
1✔
510
            raise click.ClickException(f"dimension '{name}' duplicates another dimension name")
1✔
511
        if value <= 0:
1✔
NEW
512
            raise click.ClickException(f"dimension '{name}' value must be positive")
×
513
        dimensions[name] = value
1✔
514
    return dimensions
1✔
515

516

517
def _reject_constant_dimension_overlap(
1✔
518
    constants: dict[str, int],
519
    dimensions: dict[str, int],
520
) -> None:
521
    duplicate_names = sorted(
1✔
522
        {name.lower() for name in constants} & {name.lower() for name in dimensions}
523
    )
524
    if duplicate_names:
1✔
NEW
525
        raise click.ClickException(
×
526
            "constants and dimensions must not share names: " + ", ".join(duplicate_names)
527
        )
528

529

530
def _iter_namelists(config: dict[str, Any], base_dir: Path) -> list[dict[str, Any]]:
1✔
531
    raw_entries = config.get("namelists")
1✔
532
    if raw_entries is None and "nml-files" in config:
1✔
533
        raise click.ClickException("config uses deprecated 'nml-files'; rename to 'namelists'")
×
534
    if not isinstance(raw_entries, list) or not raw_entries:
1✔
535
        raise click.ClickException("config must define non-empty 'namelists'")
×
536

537
    entries: list[dict[str, Path | None]] = []
1✔
538
    for entry in raw_entries:
1✔
539
        if not isinstance(entry, dict):
1✔
540
            raise click.ClickException("each namelists entry must be a table")
×
541
        schema_raw = entry.get("schema")
1✔
542
        if not isinstance(schema_raw, str):
1✔
543
            raise click.ClickException("namelists entry must define string 'schema'")
×
544
        schema_path = base_dir / schema_raw
1✔
545
        f2py_path = _resolve_optional_path(
1✔
546
            entry.get("f2py_path"),
547
            base_dir=base_dir,
548
            key="f2py_path",
549
        )
550
        py_path = _resolve_optional_path(
1✔
551
            entry.get("py_path"),
552
            base_dir=base_dir,
553
            key="py_path",
554
        )
555
        mod_path = _resolve_optional_path(
1✔
556
            entry.get("mod_path"),
557
            base_dir=base_dir,
558
            key="mod_path",
559
        )
560
        if f2py_path is not None and mod_path is None:
1✔
561
            raise click.ClickException(
×
562
                "namelists entry with 'f2py_path' must define 'mod_path'"
563
            )
564
        if py_path is not None and f2py_path is None:
1✔
565
            raise click.ClickException("namelists entry with 'py_path' must define 'f2py_path'")
×
566
        entries.append(
1✔
567
            {
568
                "schema": schema_path,
569
                "mod_path": mod_path,
570
                "doc_path": _resolve_optional_path(
571
                    entry.get("doc_path"),
572
                    base_dir=base_dir,
573
                    key="doc_path",
574
                ),
575
                "temp_path": _resolve_optional_path(
576
                    entry.get("temp_path"),
577
                    base_dir=base_dir,
578
                    key="temp_path",
579
                ),
580
                "f2py_path": f2py_path,
581
                "py_path": py_path,
582
            }
583
        )
584
    return entries
1✔
585

586

587
def _iter_templates(config: dict[str, Any], base_dir: Path) -> list[dict[str, Any]]:
1✔
588
    raw_entries = config.get("templates")
1✔
589
    if raw_entries is None:
1✔
590
        return []
1✔
591
    if not isinstance(raw_entries, list) or not raw_entries:
1✔
592
        raise click.ClickException("config 'templates' must be a non-empty list")
×
593

594
    entries: list[dict[str, Any]] = []
1✔
595
    for entry in raw_entries:
1✔
596
        if not isinstance(entry, dict):
1✔
597
            raise click.ClickException("each templates entry must be a table")
×
598
        output_raw = entry.get("output")
1✔
599
        if not isinstance(output_raw, str):
1✔
600
            raise click.ClickException("templates entry must define string 'output'")
×
601
        output_path = base_dir / output_raw
1✔
602

603
        schemas_raw = entry.get("schemas")
1✔
604
        if not isinstance(schemas_raw, list) or not schemas_raw:
1✔
605
            raise click.ClickException("templates entry must define non-empty 'schemas'")
×
606
        if not all(isinstance(item, str) for item in schemas_raw):
1✔
607
            raise click.ClickException("templates 'schemas' entries must be strings")
×
608
        schema_paths = [base_dir / item for item in schemas_raw]
1✔
609

610
        doc_mode = entry.get("doc_mode", "plain")
1✔
611
        if not isinstance(doc_mode, str):
1✔
612
            raise click.ClickException("templates 'doc_mode' must be a string")
×
613
        value_mode = entry.get("value_mode", "empty")
1✔
614
        if not isinstance(value_mode, str):
1✔
615
            raise click.ClickException("templates 'value_mode' must be a string")
×
616
        values_raw = entry.get("values", {})
1✔
617
        if values_raw is None:
1✔
618
            values_raw = {}
×
619
        if not isinstance(values_raw, dict):
1✔
620
            raise click.ClickException("templates 'values' must be a table")
×
621

622
        entries.append(
1✔
623
            {
624
                "output": output_path,
625
                "schemas": schema_paths,
626
                "doc_mode": doc_mode,
627
                "value_mode": value_mode,
628
                "values": values_raw,
629
            }
630
        )
631
    return entries
1✔
632

633

634
def _collect_generated_outputs(
1✔
635
    config: dict[str, Any],
636
    config_path: Path,
637
) -> list[GeneratedOutput]:
638
    base_dir = config_path.parent
1✔
639
    logger.debug("Base directory: %s", base_dir)
1✔
640
    helper_path, helper_module, helper_buffer, helper_header = _load_helper_settings(
1✔
641
        config,
642
        base_dir,
643
    )
644
    (
1✔
645
        module_doc,
646
        md_doxygen_id_from_name,
647
        md_add_toc_statement,
648
        py_style,
649
    ) = _load_documentation_settings(config)
650
    constants, constant_specs = _load_constants(config)
1✔
651
    dimensions, dimension_specs = _load_dimensions(config, constants)
1✔
652
    kind_module, kind_map, kind_allowlist = _load_kind_settings(config)
1✔
653
    f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir)
1✔
654
    outputs: list[GeneratedOutput] = []
1✔
655

656
    if helper_path is not None:
1✔
657
        try:
1✔
658
            logger.debug("Rendering helper module at %s", helper_path)
1✔
659
            outputs.append(
1✔
660
                GeneratedOutput(
661
                    helper_path,
662
                    render_helper(
663
                        file_name=helper_path.name,
664
                        module_name=helper_module,
665
                        len_buf=helper_buffer,
666
                        constants=constant_specs + dimension_specs,
667
                        module_doc=module_doc,
668
                        helper_header=helper_header,
669
                    ),
670
                )
671
            )
672
        except ValueError as exc:
×
673
            raise click.ClickException(str(exc)) from exc
×
674

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

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

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

732
    outputs.extend(
1✔
733
        _collect_f2py_outputs(
734
            loaded_entries,
735
            helper_module=helper_module,
736
            helper_buffer=helper_buffer,
737
            kind_module=kind_module,
738
            kind_map=kind_map,
739
            kind_allowlist=kind_allowlist,
740
            constants=constants,
741
            dimensions=dimensions,
742
            f2cmap_path=f2cmap_path,
743
            f2py_c_types=f2py_c_types,
744
            py_style=py_style,
745
            include_python=True,
746
        )
747
    )
748

749
    template_entries = _iter_templates(config, base_dir)
1✔
750
    if template_entries:
1✔
751
        logger.debug("Found %d template entries", len(template_entries))
1✔
752
    for template_entry in template_entries:
1✔
753
        try:
1✔
754
            schemas = []
1✔
755
            for schema_path in template_entry["schemas"]:
1✔
756
                logger.debug("Loading schema %s", schema_path)
1✔
757
                schemas.append(load_schema(schema_path))
1✔
758
            logger.debug("Rendering template at %s", template_entry["output"])
1✔
759
            outputs.append(
1✔
760
                GeneratedOutput(
761
                    template_entry["output"],
762
                    render_template(
763
                        schemas,
764
                        doc_mode=template_entry["doc_mode"],
765
                        value_mode=template_entry["value_mode"],
766
                        constants=constants,
767
                        dimensions=dimensions,
768
                        kind_map=kind_map,
769
                        kind_allowlist=kind_allowlist,
770
                        values=template_entry["values"],
771
                    ),
772
                )
773
            )
774
        except (FileNotFoundError, ValueError) as exc:
×
775
            raise click.ClickException(str(exc)) from exc
×
776

777
    return outputs
1✔
778

779

780
def _collect_f2py_outputs(
1✔
781
    loaded_entries: list[dict[str, Any]],
782
    *,
783
    helper_module: str,
784
    helper_buffer: int,
785
    kind_module: str,
786
    kind_map: dict[str, str],
787
    kind_allowlist: set[str],
788
    constants: dict[str, int],
789
    dimensions: dict[str, int],
790
    f2cmap_path: Path | None,
791
    f2py_c_types: F2pyCTypeMap,
792
    py_style: str,
793
    include_python: bool,
794
) -> list[GeneratedOutput]:
795
    outputs: list[GeneratedOutput] = []
1✔
796
    f2py_groups: dict[Path, list[dict[str, Any]]] = {}
1✔
797
    py_groups: dict[Path, list[tuple[dict[str, Any], Path]]] = {}
1✔
798
    for loaded in loaded_entries:
1✔
799
        entry = loaded["entry"]
1✔
800
        schema = loaded["schema"]
1✔
801
        f2py_path = entry["f2py_path"]
1✔
802
        if f2py_path is None:
1✔
803
            continue
1✔
804
        f2py_groups.setdefault(f2py_path, []).append(schema)
1✔
805
        py_path = entry["py_path"]
1✔
806
        if include_python and py_path is not None:
1✔
807
            py_groups.setdefault(py_path, []).append((schema, f2py_path))
1✔
808

809
    for f2py_path, schemas in f2py_groups.items():
1✔
810
        try:
1✔
811
            logger.debug("Rendering f2py wrappers at %s", f2py_path)
1✔
812
            outputs.append(
1✔
813
                GeneratedOutput(
814
                    f2py_path,
815
                    render_f2py_wrappers(
816
                        schemas,
817
                        file_name=f2py_path.name,
818
                        helper_module=helper_module,
819
                        kind_module=kind_module,
820
                        kind_map=kind_map,
821
                        kind_allowlist=kind_allowlist,
822
                        constants=constants,
823
                        dimensions=dimensions,
824
                        errmsg_len=helper_buffer,
825
                    ),
826
                )
827
            )
828
        except ValueError as exc:
×
829
            raise click.ClickException(str(exc)) from exc
×
830

831
    if f2cmap_path is not None:
1✔
832
        try:
1✔
833
            usage = merge_f2py_kind_usage(
1✔
834
                collect_f2py_kind_usage(schemas, constants=constants, dimensions=dimensions)
835
                for schemas in f2py_groups.values()
836
            )
837
            logger.debug("Rendering f2py kind map at %s", f2cmap_path)
1✔
838
            outputs.append(
1✔
839
                GeneratedOutput(
840
                    f2cmap_path,
841
                    render_f2cmap(usage, f2py_c_types),
842
                )
843
            )
844
        except ValueError as exc:
×
845
            raise click.ClickException(str(exc)) from exc
×
846

847
    if not include_python:
1✔
848
        return outputs
1✔
849
    for py_path, entries in py_groups.items():
1✔
850
        try:
1✔
851
            logger.debug("Rendering Python f2py wrappers at %s", py_path)
1✔
852
            specs = [
1✔
853
                (
854
                    build_f2py_namelist_spec(
855
                        schema,
856
                        helper_module=helper_module,
857
                        kind_module=kind_module,
858
                        kind_map=kind_map,
859
                        kind_allowlist=kind_allowlist,
860
                        constants=constants,
861
                        dimensions=dimensions,
862
                        errmsg_len=helper_buffer,
863
                    ),
864
                    f2py_path.stem,
865
                )
866
                for schema, f2py_path in entries
867
            ]
868
            outputs.append(
1✔
869
                GeneratedOutput(
870
                    py_path,
871
                    render_python_wrappers(
872
                        specs,
873
                        py_style=py_style,
874
                    ),
875
                )
876
            )
877
        except ValueError as exc:
×
878
            raise click.ClickException(str(exc)) from exc
×
879
    return outputs
1✔
880

881

882
def _generate_f2py_outputs(
1✔
883
    loaded_entries: list[dict[str, Any]],
884
    *,
885
    helper_module: str,
886
    helper_buffer: int,
887
    kind_module: str,
888
    kind_map: dict[str, str],
889
    kind_allowlist: set[str],
890
    constants: dict[str, int],
891
    dimensions: dict[str, int],
892
    f2cmap_path: Path | None,
893
    f2py_c_types: F2pyCTypeMap,
894
    py_style: str,
895
    include_python: bool,
896
) -> None:
897
    _write_generated_outputs(
1✔
898
        _collect_f2py_outputs(
899
            loaded_entries,
900
            helper_module=helper_module,
901
            helper_buffer=helper_buffer,
902
            kind_module=kind_module,
903
            kind_map=kind_map,
904
            kind_allowlist=kind_allowlist,
905
            constants=constants,
906
            dimensions=dimensions,
907
            f2cmap_path=f2cmap_path,
908
            f2py_c_types=f2py_c_types,
909
            py_style=py_style,
910
            include_python=include_python,
911
        )
912
    )
913

914

915
def _write_generated_outputs(outputs: list[GeneratedOutput]) -> None:
1✔
916
    for output in outputs:
1✔
917
        logger.info("Writing generated file %s", output.path)
1✔
918
        output.path.parent.mkdir(parents=True, exist_ok=True)
1✔
919
        output.path.write_text(output.content, encoding="ascii")
1✔
920

921

922
def _check_generated_outputs(outputs: list[GeneratedOutput], *, show_diff: bool) -> int:
1✔
923
    failed = 0
1✔
924
    for output in outputs:
1✔
925
        if not output.path.exists():
1✔
926
            failed += 1
1✔
927
            click.echo(f"MISSING: {output.path}", err=True)
1✔
928
            continue
1✔
929
        current = output.path.read_text(encoding="ascii")
1✔
930
        if current != output.content:
1✔
931
            failed += 1
1✔
932
            click.echo(f"DIFF: {output.path}", err=True)
1✔
933
            if show_diff:
1✔
934
                diff = unified_diff(
1✔
935
                    current.splitlines(keepends=True),
936
                    output.content.splitlines(keepends=True),
937
                    fromfile=f"current {output.path}",
938
                    tofile=f"generated {output.path}",
939
                )
940
                for line in diff:
1✔
941
                    click.echo(line, nl=False)
1✔
942
            continue
1✔
943
        logger.debug("OK: %s", output.path)
1✔
944
    return failed
1✔
945

946

947
@click.group(context_settings=_CONTEXT_SETTINGS)
1✔
948
@click.version_option(__version__, "-V", "--version", prog_name="nml-tools")
1✔
949
@click.option("--verbose", "-v", count=True, help="Increase verbosity (repeatable).")
1✔
950
@click.option("--quiet", "-q", count=True, help="Decrease verbosity (repeatable).")
1✔
951
def cli(verbose: int, quiet: int) -> None:
1✔
952
    """nml-tools command line interface."""
953
    _configure_logging(verbose, quiet)
1✔
954

955

956
@cli.command("generate", context_settings=_CONTEXT_SETTINGS)
1✔
957
@click.option(
1✔
958
    "--config",
959
    "config_path",
960
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
961
    default=None,
962
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
963
)
964
def generate(config_path: Path | None) -> None:
1✔
965
    """Generate outputs from a configuration file."""
966
    config, config_path = _load_config_checked(config_path)
1✔
967
    logger.info("Loading config from %s", config_path)
1✔
968
    _write_generated_outputs(_collect_generated_outputs(config, config_path))
1✔
969

970

971
@cli.command("check", context_settings=_CONTEXT_SETTINGS)
1✔
972
@click.option(
1✔
973
    "--config",
974
    "config_path",
975
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
976
    default=None,
977
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
978
)
979
@click.option(
1✔
980
    "--diff",
981
    "show_diff",
982
    is_flag=True,
983
    help="Show unified diffs for generated files that differ.",
984
)
985
def check(config_path: Path | None, show_diff: bool) -> None:
1✔
986
    """Check that configured generated files are up to date."""
987
    config, config_path = _load_config_checked(config_path)
1✔
988
    logger.debug("Loading config from %s", config_path)
1✔
989
    failures = _check_generated_outputs(
1✔
990
        _collect_generated_outputs(config, config_path),
991
        show_diff=show_diff,
992
    )
993
    if failures:
1✔
994
        raise click.ClickException(
1✔
995
            f"generated files are out of date: {failures} file(s) differ or are missing"
996
        )
997

998

999
@cli.command("gen-fortran", context_settings=_CONTEXT_SETTINGS)
1✔
1000
@click.option(
1✔
1001
    "--config",
1002
    "config_path",
1003
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1004
    default=None,
1005
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1006
)
1007
def gen_fortran(config_path: Path | None) -> None:
1✔
1008
    """Generate Fortran module(s)."""
1009
    config, config_path = _load_config_checked(config_path)
1✔
1010
    logger.info("Loading config from %s", config_path)
1✔
1011
    base_dir = config_path.parent
1✔
1012
    logger.debug("Base directory: %s", base_dir)
1✔
1013
    helper_path, helper_module, helper_buffer, helper_header = _load_helper_settings(
1✔
1014
        config,
1015
        base_dir,
1016
    )
1017
    module_doc, _, _, py_style = _load_documentation_settings(config)
1✔
1018
    constants, constant_specs = _load_constants(config)
1✔
1019
    dimensions, dimension_specs = _load_dimensions(config, constants)
1✔
1020
    kind_module, kind_map, kind_allowlist = _load_kind_settings(config)
1✔
1021
    f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir)
1✔
1022
    if helper_path is not None:
1✔
1023
        try:
×
1024
            logger.info("Generating helper module at %s", helper_path)
×
1025
            generate_helper(
×
1026
                helper_path,
1027
                module_name=helper_module,
1028
                len_buf=helper_buffer,
1029
                constants=constant_specs + dimension_specs,
1030
                module_doc=module_doc,
1031
                helper_header=helper_header,
1032
            )
1033
        except ValueError as exc:
×
1034
            raise click.ClickException(str(exc)) from exc
×
1035

1036
    entries = _iter_namelists(config, base_dir)
1✔
1037
    logger.info("Found %d schema entries", len(entries))
1✔
1038
    loaded_entries: list[dict[str, Any]] = []
1✔
1039
    for entry in entries:
1✔
1040
        schema_path = entry["schema"]
1✔
1041
        if schema_path is None:
1✔
1042
            raise click.ClickException("namelists entry missing schema path")
×
1043
        try:
1✔
1044
            logger.info("Loading schema %s", schema_path)
1✔
1045
            schema = load_schema(schema_path)
1✔
1046
        except (FileNotFoundError, ValueError) as exc:
×
1047
            raise click.ClickException(str(exc)) from exc
×
1048
        loaded_entries.append({"entry": entry, "schema": schema})
1✔
1049
        mod_path = entry["mod_path"]
1✔
1050
        if mod_path is None:
1✔
1051
            continue
×
1052
        try:
1✔
1053
            logger.info("Generating Fortran module at %s", mod_path)
1✔
1054
            generate_fortran(
1✔
1055
                schema,
1056
                mod_path,
1057
                helper_module=helper_module,
1058
                kind_module=kind_module,
1059
                kind_map=kind_map,
1060
                kind_allowlist=kind_allowlist,
1061
                constants=constants,
1062
                dimensions=dimensions,
1063
                module_doc=module_doc,
1064
                f2py_handle_helpers=entry["f2py_path"] is not None,
1065
            )
1066
        except ValueError as exc:
×
1067
            raise click.ClickException(str(exc)) from exc
×
1068

1069
    _generate_f2py_outputs(
1✔
1070
        loaded_entries,
1071
        helper_module=helper_module,
1072
        helper_buffer=helper_buffer,
1073
        kind_module=kind_module,
1074
        kind_map=kind_map,
1075
        kind_allowlist=kind_allowlist,
1076
        constants=constants,
1077
        dimensions=dimensions,
1078
        f2cmap_path=f2cmap_path,
1079
        f2py_c_types=f2py_c_types,
1080
        py_style=py_style,
1081
        include_python=False,
1082
    )
1083

1084

1085
@cli.command("validate", context_settings=_CONTEXT_SETTINGS)
1✔
1086
@click.option(
1✔
1087
    "--config",
1088
    "config_path",
1089
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1090
    default=None,
1091
)
1092
@click.option(
1✔
1093
    "--schema",
1094
    "schema_paths",
1095
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1096
    multiple=True,
1097
)
1098
@click.option(
1✔
1099
    "--input",
1100
    "input_option",
1101
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1102
)
1103
@click.option(
1✔
1104
    "--constants",
1105
    "constant_args",
1106
    metavar="NAME=INT",
1107
    type=_CONSTANT_TYPE,
1108
    multiple=True,
1109
    help="Additional integer constants as NAME=INT (repeatable).",
1110
)
1111
@click.option(
1✔
1112
    "--dimensions",
1113
    "dimension_args",
1114
    metavar="NAME=INT",
1115
    type=_DIMENSION_TYPE,
1116
    multiple=True,
1117
    help="Runtime dimensions as NAME=INT (repeatable).",
1118
)
1119
@click.argument(
1✔
1120
    "input_path",
1121
    required=False,
1122
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1123
)
1124
def validate(
1✔
1125
    config_path: Path | None,
1126
    schema_paths: tuple[Path, ...],
1127
    input_option: Path | None,
1128
    constant_args: tuple[tuple[str, int], ...],
1129
    dimension_args: tuple[tuple[str, int], ...],
1130
    input_path: Path | None,
1131
) -> None:
1132
    """Validate a namelist file against schema definitions."""
1133
    if input_option is not None and input_path is not None:
1✔
1134
        raise click.ClickException("input path provided twice")
×
1135
    input_path = input_option or input_path
1✔
1136
    if input_path is None:
1✔
1137
        raise click.ClickException("input path is required")
×
1138
    constants = _parse_cli_constants(constant_args)
1✔
1139
    dimension_overrides = _parse_cli_dimensions(dimension_args)
1✔
1140
    dimensions: dict[str, int] = {}
1✔
1141
    schemas: list[dict[str, Any]] = []
1✔
1142

1143
    if schema_paths:
1✔
1144
        if config_path is not None:
1✔
1145
            config, config_path = _load_config_checked(config_path)
×
1146
            logger.info("Loading config from %s", config_path)
×
1147
            cfg_constants, _ = _load_constants(config)
×
NEW
1148
            dimensions, _ = _load_dimensions(config, cfg_constants)
×
1149
            constants = {**cfg_constants, **constants}
×
NEW
1150
            dimensions = {**dimensions, **dimension_overrides}
×
1151
        else:
1152
            dimensions = dimension_overrides
1✔
1153
        for schema_file in schema_paths:
1✔
1154
            try:
1✔
1155
                logger.info("Loading schema %s", schema_file)
1✔
1156
                schemas.append(load_schema(schema_file))
1✔
1157
            except (FileNotFoundError, ValueError) as exc:
×
1158
                raise click.ClickException(str(exc)) from exc
×
1159
        require_all = True
1✔
1160
    else:
1161
        config, config_path = _load_config_checked(config_path)
1✔
1162
        logger.info("Loading config from %s", config_path)
1✔
1163
        base_dir = config_path.parent
1✔
1164
        cfg_constants, _ = _load_constants(config)
1✔
1165
        dimensions, _ = _load_dimensions(config, cfg_constants)
1✔
1166
        constants = {**cfg_constants, **constants}
1✔
1167
        dimensions = {**dimensions, **dimension_overrides}
1✔
1168
        entries = _iter_namelists(config, base_dir)
1✔
1169
        logger.info("Found %d schema entries", len(entries))
1✔
1170
        for entry in entries:
1✔
1171
            schema_path = entry["schema"]
1✔
1172
            if schema_path is None:
1✔
1173
                raise click.ClickException("namelists entry missing schema path")
×
1174
            try:
1✔
1175
                logger.info("Loading schema %s", schema_path)
1✔
1176
                schemas.append(load_schema(schema_path))
1✔
1177
            except (FileNotFoundError, ValueError) as exc:
×
1178
                raise click.ClickException(str(exc)) from exc
×
1179
        require_all = False
1✔
1180

1181
    if not schemas:
1✔
1182
        raise click.ClickException("no schemas provided for validation")
×
1183
    _reject_constant_dimension_overlap(constants, dimensions)
1✔
1184

1185
    try:
1✔
1186
        logger.info("Reading namelist %s", input_path)
1✔
1187
        namelist_file = f90nml.read(input_path)
1✔
1188
    except Exception as exc:  # pragma: no cover - f90nml raises custom errors
1189
        raise click.ClickException(f"failed to read namelist: {exc}") from exc
1190

1191
    file_entries: dict[str, tuple[str, Any]] = {}
1✔
1192
    for name, values in namelist_file.items():
1✔
1193
        if not isinstance(name, str):
1✔
1194
            raise click.ClickException("namelist names must be strings")
×
1195
        key = name.lower()
1✔
1196
        if key in file_entries:
1✔
1197
            raise click.ClickException(f"namelist '{name}' appears multiple times")
×
1198
        file_entries[key] = (name, values)
1✔
1199

1200
    schema_entries: dict[str, dict[str, Any]] = {}
1✔
1201
    for schema in schemas:
1✔
1202
        namelist_name = schema.get("x-fortran-namelist")
1✔
1203
        if not isinstance(namelist_name, str) or not namelist_name.strip():
1✔
1204
            raise click.ClickException("schema must define non-empty 'x-fortran-namelist'")
×
1205
        key = namelist_name.lower()
1✔
1206
        if key in schema_entries:
1✔
1207
            raise click.ClickException(f"duplicate schema for namelist '{namelist_name}'")
×
1208
        schema_entries[key] = schema
1✔
1209

1210
    for key, (name, _) in file_entries.items():
1✔
1211
        if key not in schema_entries:
1✔
1212
            raise click.ClickException(f"input contains unknown namelist '{name}'")
×
1213

1214
    validated = 0
1✔
1215
    for key, schema in schema_entries.items():
1✔
1216
        if key not in file_entries:
1✔
1217
            if require_all:
×
1218
                raise click.ClickException(
×
1219
                    f"input is missing namelist '{schema.get('x-fortran-namelist')}'"
1220
                )
1221
            continue
×
1222
        try:
1✔
1223
            validate_namelist(
1✔
1224
                schema,
1225
                file_entries[key][1],
1226
                constants=constants,
1227
                dimensions=dimensions,
1228
            )
1229
        except ValueError as exc:
×
1230
            raise click.ClickException(str(exc)) from exc
×
1231
        validated += 1
1✔
1232
    logger.info("Validation completed (%d namelist%s).", validated, "" if validated == 1 else "s")
1✔
1233

1234

1235
@cli.command("gen-markdown", context_settings=_CONTEXT_SETTINGS)
1✔
1236
@click.option(
1✔
1237
    "--config",
1238
    "config_path",
1239
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1240
    default=None,
1241
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1242
)
1243
def gen_markdown(config_path: Path | None) -> None:
1✔
1244
    """Generate Markdown docs."""
1245
    config, config_path = _load_config_checked(config_path)
1✔
1246
    logger.info("Loading config from %s", config_path)
1✔
1247
    base_dir = config_path.parent
1✔
1248
    constants, _ = _load_constants(config)
1✔
1249
    dimensions, _ = _load_dimensions(config, constants)
1✔
1250
    _, md_doxygen_id_from_name, md_add_toc_statement, _ = _load_documentation_settings(
1✔
1251
        config
1252
    )
1253
    entries = _iter_namelists(config, base_dir)
1✔
1254
    logger.info("Found %d schema entries", len(entries))
1✔
1255
    for entry in entries:
1✔
1256
        schema_path = entry["schema"]
1✔
1257
        if schema_path is None:
1✔
1258
            raise click.ClickException("namelists entry missing schema path")
×
1259
        try:
1✔
1260
            logger.info("Loading schema %s", schema_path)
1✔
1261
            schema = load_schema(schema_path)
1✔
1262
        except (FileNotFoundError, ValueError) as exc:
×
1263
            raise click.ClickException(str(exc)) from exc
×
1264
        doc_path = entry["doc_path"]
1✔
1265
        if doc_path is None:
1✔
1266
            continue
×
1267
        try:
1✔
1268
            logger.info("Generating Markdown docs at %s", doc_path)
1✔
1269
            generate_docs(
1✔
1270
                schema,
1271
                doc_path,
1272
                constants=constants,
1273
                dimensions=dimensions,
1274
                md_doxygen_id_from_name=md_doxygen_id_from_name,
1275
                md_add_toc_statement=md_add_toc_statement,
1276
            )
1277
        except ValueError as exc:
×
1278
            raise click.ClickException(str(exc)) from exc
×
1279

1280

1281
@cli.command("gen-template", context_settings=_CONTEXT_SETTINGS)
1✔
1282
@click.option(
1✔
1283
    "--config",
1284
    "config_path",
1285
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1286
    default=None,
1287
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1288
)
1289
def gen_template(config_path: Path | None) -> None:
1✔
1290
    """Generate template namelist(s)."""
1291
    config, config_path = _load_config_checked(config_path)
1✔
1292
    logger.info("Loading config from %s", config_path)
1✔
1293
    base_dir = config_path.parent
1✔
1294
    constants, _ = _load_constants(config)
1✔
1295
    dimensions, _ = _load_dimensions(config, constants)
1✔
1296
    _, kind_map, kind_allowlist = _load_kind_settings(config)
1✔
1297
    templates = _iter_templates(config, base_dir)
1✔
1298
    if not templates:
1✔
1299
        raise click.ClickException("config must define non-empty 'templates'")
×
1300
    logger.info("Found %d template entries", len(templates))
1✔
1301
    for entry in templates:
1✔
1302
        try:
1✔
1303
            schemas = []
1✔
1304
            for schema_path in entry["schemas"]:
1✔
1305
                logger.info("Loading schema %s", schema_path)
1✔
1306
                schemas.append(load_schema(schema_path))
1✔
1307
            logger.info("Generating template at %s", entry["output"])
1✔
1308
            generate_template(
1✔
1309
                schemas,
1310
                entry["output"],
1311
                doc_mode=entry["doc_mode"],
1312
                value_mode=entry["value_mode"],
1313
                constants=constants,
1314
                dimensions=dimensions,
1315
                kind_map=kind_map,
1316
                kind_allowlist=kind_allowlist,
1317
                values=entry["values"],
1318
            )
1319
        except (FileNotFoundError, ValueError) as exc:
×
1320
            raise click.ClickException(str(exc)) from exc
×
1321

1322

1323
def main(argv: list[str] | None = None) -> int:
1✔
1324
    """Entry point for the CLI."""
1325
    try:
×
1326
        cli.main(args=argv, prog_name="nml-tools", standalone_mode=False)
×
1327
    except Exit as exc:
×
1328
        return exc.exit_code
×
1329
    except click.ClickException as exc:
×
1330
        exc.show()
×
1331
        return 1
×
1332
    return 0
×
1333

1334

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