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

MuellerSeb / nml-tools / 26189498331

20 May 2026 08:55PM UTC coverage: 79.066% (+4.8%) from 74.315%
26189498331

Pull #27

github

MuellerSeb
Tidy runtime dimension codegen checks
Pull Request #27: Add Runtime Array Dimensions

438 of 490 new or added lines in 7 files covered. (89.39%)

3 existing lines in 2 files now uncovered.

2591 of 3277 relevant lines covered (79.07%)

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 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 ._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
_NamedIntegerTypeBase = cast(Any, click.ParamType)
1✔
52

53

54
class NamedIntegerType(_NamedIntegerTypeBase):  # type: ignore[valid-type, misc]
1✔
55
    """Parse NAME=INT values for CLI options."""
56

57
    name = "NAME=INT"
1✔
58

59
    def __init__(self, *, label: str, positive: bool = False) -> None:
1✔
60
        self._label = label
1✔
61
        self._positive = positive
1✔
62

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

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

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

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

90

91
_CONSTANT_TYPE = NamedIntegerType(label="constant")
1✔
92
_DIMENSION_TYPE = NamedIntegerType(label="dimension", positive=True)
1✔
93

94

95
@dataclass(frozen=True)
1✔
96
class GeneratedOutput:
1✔
97
    """Generated file content for write/check commands."""
98

99
    path: Path
1✔
100
    content: str
1✔
101

102

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

109

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

117

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

126

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

137

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

151

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

156

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

166

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

188

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

201

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

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

253

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

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

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

282
    return module, kind_map, allowlist
1✔
283

284

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

300
    f2cmap_path = _resolve_optional_path(
1✔
301
        f2py_raw.get("f2cmap_path"),
302
        base_dir=base_dir,
303
        key="f2py.f2cmap_path",
304
    )
305

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

312
    real = _load_f2py_ctype_table(c_types_raw, "real")
1✔
313
    integer = _load_f2py_ctype_table(c_types_raw, "integer")
1✔
314
    return f2cmap_path, F2pyCTypeMap(real=real, integer=integer)
1✔
315

316

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

336

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

344

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

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

393

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

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

451

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

458

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

493

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

502

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

513

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

526

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

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

583

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

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

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

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

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

630

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

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

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

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

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

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

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

774
    return outputs
1✔
775

776

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

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

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

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

878

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

911

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

918

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

943

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

952

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

967

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

995

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

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

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

1081

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

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

1178
    if not schemas:
1✔
1179
        raise click.ClickException("no schemas provided for validation")
×
1180
    _reject_constant_dimension_overlap(constants, dimensions)
1✔
1181

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

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

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

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

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

1231

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

1277

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

1319

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

1331

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