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

MuellerSeb / nml-tools / 29525046973

16 Jul 2026 06:41PM UTC coverage: 86.028% (+1.3%) from 84.697%
29525046973

Pull #60

github

MuellerSeb
Revert "Preserve trailing namelist null values"

This reverts commit 5fd338717.
Pull Request #60: Add A Schema-Aware Standard Fortran Namelist Parser

842 of 911 new or added lines in 4 files covered. (92.43%)

9 existing lines in 3 files now uncovered.

4778 of 5554 relevant lines covered (86.03%)

0.86 hits per line

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

82.84
/src/nml_tools/cli.py
1
"""Command line interface for nml-tools."""
2

3
from __future__ import annotations
1✔
4

5
import logging
1✔
6
import sys
1✔
7
from dataclasses import dataclass
1✔
8
from difflib import unified_diff
1✔
9
from pathlib import Path
1✔
10
from typing import Any, cast
1✔
11

12
import click
1✔
13
from click.exceptions import Exit
1✔
14
from packaging.specifiers import InvalidSpecifier, SpecifierSet
1✔
15
from packaging.version import InvalidVersion, Version
1✔
16

17
from ._namelist_eval import evaluate_group
1✔
18
from ._namelist_parser import NamelistSyntaxError, ParsedGroup, parse_namelist
1✔
19
from ._utils import constant_dimension_overlap, validate_user_fortran_identifier
1✔
20
from ._version import __version__
1✔
21
from .codegen_f2py import (
1✔
22
    F2pyCTypeMap,
23
    build_f2py_namelist_spec,
24
    collect_f2py_kind_usage,
25
    merge_f2py_kind_usage,
26
    render_f2cmap,
27
    render_f2py_wrappers,
28
    render_python_wrappers,
29
)
30
from .codegen_fortran import (
1✔
31
    ConstantSpec,
32
    collect_local_derived_types,
33
    generate_fortran,
34
    generate_helper,
35
    render_fortran,
36
    render_helper,
37
)
38
from .codegen_markdown import generate_docs, render_docs
1✔
39
from .codegen_template import generate_template, render_template
1✔
40
from .schema import SchemaResolver, load_schema
1✔
41

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

47
logger = logging.getLogger(__name__)
1✔
48

49

50
_CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
1✔
51
_DEFAULT_CONFIG = Path("nml-config.toml")
1✔
52
_PYPROJECT_CONFIG = Path("pyproject.toml")
1✔
53

54
_NamedIntegerTypeBase = cast(Any, click.ParamType)
1✔
55

56

57
class NamedIntegerType(_NamedIntegerTypeBase):  # type: ignore[valid-type, misc]
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✔
78
            self.fail("must use non-empty names", param, ctx)
×
79
        try:
1✔
80
            validate_user_fortran_identifier(name, label=f"{self._label} '{name}'")
1✔
81
        except ValueError as exc:
1✔
82
            self.fail(str(exc), param, ctx)
1✔
83

84
        value_text = raw_value.strip()
1✔
85
        if not value_text:
1✔
86
            self.fail(f"{self._label} '{name}' must define a value", param, ctx)
×
87
        value_digits = value_text[1:] if value_text[:1] in {"+", "-"} else value_text
1✔
88
        if not value_digits.isdigit():
1✔
89
            self.fail(f"{self._label} '{name}' value must be an integer", param, ctx)
1✔
90

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

96

97
_CONSTANT_TYPE = NamedIntegerType(label="constant")
1✔
98
_DIMENSION_TYPE = NamedIntegerType(label="dimension", positive=True)
1✔
99

100

101
@dataclass(frozen=True)
1✔
102
class GeneratedOutput:
1✔
103
    """Generated file content for write/check commands."""
104

105
    path: Path
1✔
106
    content: str
1✔
107

108

109
@dataclass(frozen=True)
1✔
110
class LoadedNamelist:
1✔
111
    """Configured namelist entry with its resolved schema."""
112

113
    entry: dict[str, Any]
1✔
114
    schema: dict[str, Any]
1✔
115
    name: str
1✔
116
    key: str
1✔
117

118

119
@dataclass(frozen=True)
1✔
120
class FileProfile:
1✔
121
    """Project-specific logical namelist file profile."""
122

123
    name: str
1✔
124
    key: str
1✔
125
    default_file: str
1✔
126
    namelists: list[str]
1✔
127
    required: list[str]
1✔
128
    title: str | None = None
1✔
129
    description: str | None = None
1✔
130

131

132
def _configure_logging(verbose: int, quiet: int) -> None:
1✔
133
    base_level = logging.INFO
1✔
134
    level = base_level - (10 * verbose) + (10 * quiet)
1✔
135
    level = max(logging.DEBUG, min(logging.CRITICAL, level))
1✔
136
    logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
1✔
137

138

139
def _load_toml(path: Path) -> dict[str, Any]:
1✔
140
    with path.open("rb") as handle:
1✔
141
        data = tomllib.load(handle)
1✔
142
    if not isinstance(data, dict):
1✔
143
        raise click.ClickException("config must be a table")
×
144
    return data
1✔
145

146

147
def _load_toml_checked(path: Path) -> dict[str, Any]:
1✔
148
    try:
1✔
149
        return _load_toml(path)
1✔
150
    except FileNotFoundError as exc:
1✔
151
        raise click.ClickException(str(exc)) from exc
1✔
152
    except Exception as exc:  # pragma: no cover - tomllib may raise ValueError
153
        raise click.ClickException(f"failed to read config: {exc}") from exc
154

155

156
def _load_config_checked(path: Path | None) -> tuple[dict[str, Any], Path]:
1✔
157
    config_path = _resolve_config_path(path)
1✔
158
    raw_config = _load_toml_checked(config_path)
1✔
159
    if config_path.name == _PYPROJECT_CONFIG.name:
1✔
160
        config = _extract_pyproject_config(raw_config)
1✔
161
    else:
162
        config = raw_config
1✔
163
    _check_required_version(config)
1✔
164
    return config, config_path
1✔
165

166

167
def _resolve_config_path(path: Path | None) -> Path:
1✔
168
    if path is not None:
1✔
169
        return path
1✔
170
    if _DEFAULT_CONFIG.is_file():
1✔
171
        return _DEFAULT_CONFIG
1✔
172
    if _PYPROJECT_CONFIG.is_file():
1✔
173
        raw_config = _load_toml_checked(_PYPROJECT_CONFIG)
1✔
174
        if _has_pyproject_config(raw_config):
1✔
175
            return _PYPROJECT_CONFIG
1✔
176
    raise click.ClickException(
1✔
177
        "no config found; create nml-config.toml or add [tool.nml-tools] to pyproject.toml"
178
    )
179

180

181
def _has_pyproject_config(config: dict[str, Any]) -> bool:
1✔
182
    tool_raw = config.get("tool")
1✔
183
    return isinstance(tool_raw, dict) and isinstance(tool_raw.get("nml-tools"), dict)
1✔
184

185

186
def _extract_pyproject_config(config: dict[str, Any]) -> dict[str, Any]:
1✔
187
    tool_raw = config.get("tool")
1✔
188
    if not isinstance(tool_raw, dict):
1✔
189
        raise click.ClickException("pyproject.toml must define [tool.nml-tools]")
1✔
190
    nml_tools_raw = tool_raw.get("nml-tools")
1✔
191
    if not isinstance(nml_tools_raw, dict):
1✔
192
        raise click.ClickException("pyproject.toml must define [tool.nml-tools]")
1✔
193
    return nml_tools_raw
1✔
194

195

196
def _check_required_version(config: dict[str, Any]) -> None:
1✔
197
    required_raw = config.get("required-version")
1✔
198
    minimum_raw = config.get("minimum-version")
1✔
199
    if required_raw is None and minimum_raw is None:
1✔
200
        return
1✔
201

202
    if required_raw is not None and minimum_raw is not None:
1✔
203
        raise click.ClickException(
1✔
204
            "config must not set both 'required-version' and 'minimum-version'; "
205
            "use 'required-version'"
206
        )
207

208
    if required_raw is not None:
1✔
209
        if not isinstance(required_raw, str) or not required_raw.strip():
1✔
210
            raise click.ClickException("config 'required-version' must be a non-empty string")
1✔
211
        requirement = required_raw.strip()
1✔
212
        try:
1✔
213
            specifier = SpecifierSet(requirement)
1✔
214
        except InvalidSpecifier as exc:
1✔
215
            raise click.ClickException(
1✔
216
                f"config 'required-version' is not a valid version specifier: {required_raw}"
217
            ) from exc
218
    else:
219
        if not isinstance(minimum_raw, str) or not minimum_raw.strip():
1✔
220
            raise click.ClickException("config 'minimum-version' must be a non-empty string")
1✔
221
        try:
1✔
222
            minimum = Version(minimum_raw.strip())
1✔
223
        except InvalidVersion as exc:
1✔
224
            raise click.ClickException(
1✔
225
                f"config 'minimum-version' is not a valid version: {minimum_raw}"
226
            ) from exc
227
        requirement = f">={minimum}"
1✔
228
        specifier = SpecifierSet(requirement)
1✔
229

230
    try:
1✔
231
        current = Version(__version__)
1✔
232
    except InvalidVersion as exc:  # pragma: no cover - package version should be valid
233
        raise click.ClickException(f"nml-tools version is not valid: {__version__}") from exc
234
    if not specifier.contains(current, prereleases=True):
1✔
235
        raise click.ClickException(
1✔
236
            f"config requires nml-tools {requirement}, current version is {current}"
237
        )
238

239

240
def _resolve_optional_path(
1✔
241
    value: Any,
242
    *,
243
    base_dir: Path,
244
    key: str,
245
) -> Path | None:
246
    if value is None:
1✔
247
        return None
1✔
248
    if not isinstance(value, str):
1✔
249
        raise click.ClickException(f"config '{key}' must be a string")
×
250
    return base_dir / value
1✔
251

252

253
def _load_helper_settings(
1✔
254
    config: dict[str, Any],
255
    base_dir: Path,
256
) -> tuple[Path | None, str, int, str | None]:
257
    default_buffer = 1024
1✔
258
    helper_raw = config.get("helper")
1✔
259
    if helper_raw is None:
1✔
260
        helper_path = _resolve_optional_path(
1✔
261
            config.get("helper_path"),
262
            base_dir=base_dir,
263
            key="helper_path",
264
        )
265
        helper_module_raw = config.get("helper_module", "nml_helper")
1✔
266
        if not isinstance(helper_module_raw, str):
1✔
267
            raise click.ClickException("config 'helper_module' must be a string")
×
268
        helper_module = helper_module_raw.strip()
1✔
269
        if not helper_module:
1✔
270
            raise click.ClickException("config 'helper_module' must be a non-empty string")
×
271
        return helper_path, helper_module, default_buffer, None
1✔
272

273
    if not isinstance(helper_raw, dict):
1✔
274
        raise click.ClickException("config 'helper' must be a table")
×
275
    if "helper_path" in config or "helper_module" in config:
1✔
276
        logger.warning("config uses [helper]; ignoring legacy helper_path/helper_module")
×
277
    helper_path = _resolve_optional_path(
1✔
278
        helper_raw.get("path"),
279
        base_dir=base_dir,
280
        key="helper.path",
281
    )
282
    helper_module_raw = helper_raw.get("module", "nml_helper")
1✔
283
    if not isinstance(helper_module_raw, str):
1✔
284
        raise click.ClickException("config 'helper.module' must be a string")
×
285
    helper_module = helper_module_raw.strip()
1✔
286
    if not helper_module:
1✔
287
        raise click.ClickException("config 'helper.module' must be a non-empty string")
×
288
    header_raw = helper_raw.get("header")
1✔
289
    if header_raw is None:
1✔
290
        header = None
1✔
291
    else:
292
        if not isinstance(header_raw, str):
×
293
            raise click.ClickException("config 'helper.header' must be a string")
×
294
        header = header_raw.rstrip()
×
295
        if not header:
×
296
            header = None
×
297
    buffer_raw = helper_raw.get("buffer", default_buffer)
1✔
298
    if isinstance(buffer_raw, bool) or not isinstance(buffer_raw, int):
1✔
299
        raise click.ClickException("config 'helper.buffer' must be an integer")
×
300
    if buffer_raw <= 0:
1✔
301
        raise click.ClickException("config 'helper.buffer' must be positive")
×
302
    return helper_path, helper_module, buffer_raw, header
1✔
303

304

305
def _load_kind_settings(config: dict[str, Any]) -> tuple[str, dict[str, str], set[str]]:
1✔
306
    kinds_raw = config.get("kinds")
1✔
307
    if not isinstance(kinds_raw, dict):
1✔
308
        raise click.ClickException("config must define a [kinds] table")
×
309
    module_raw = kinds_raw.get("module")
1✔
310
    if not isinstance(module_raw, str) or not module_raw.strip():
1✔
311
        raise click.ClickException("config 'kinds.module' must be a non-empty string")
×
312
    module = module_raw.strip()
1✔
313

314
    map_raw = kinds_raw.get("map", {})
1✔
315
    if map_raw is None:
1✔
316
        map_raw = {}
×
317
    if not isinstance(map_raw, dict):
1✔
318
        raise click.ClickException("config 'kinds.map' must be a table")
×
319
    kind_map: dict[str, str] = {}
1✔
320
    for alias, target in map_raw.items():
1✔
321
        if not isinstance(alias, str) or not isinstance(target, str):
1✔
322
            raise click.ClickException("config 'kinds.map' keys and values must be strings")
×
323
        kind_map[alias] = target
1✔
324

325
    real_raw = kinds_raw.get("real", [])
1✔
326
    integer_raw = kinds_raw.get("integer", [])
1✔
327
    if not isinstance(real_raw, list) or not all(isinstance(item, str) for item in real_raw):
1✔
328
        raise click.ClickException("config 'kinds.real' must be a list of strings")
×
329
    if not isinstance(integer_raw, list) or not all(isinstance(item, str) for item in integer_raw):
1✔
330
        raise click.ClickException("config 'kinds.integer' must be a list of strings")
×
331
    allowlist = set(real_raw) | set(integer_raw)
1✔
332

333
    return module, kind_map, allowlist
1✔
334

335

336
def _load_f2py_settings(
1✔
337
    config: dict[str, Any],
338
    base_dir: Path,
339
) -> tuple[Path | None, F2pyCTypeMap]:
340
    f2py_raw = config.get("f2py")
1✔
341
    if f2py_raw is None:
1✔
342
        return None, F2pyCTypeMap(real={}, integer={})
1✔
343
    if not isinstance(f2py_raw, dict):
1✔
344
        raise click.ClickException("config 'f2py' must be a table")
×
345
    if "python_package" in f2py_raw:
1✔
346
        raise click.ClickException(
×
347
            "config 'f2py.python_package' is no longer supported; "
348
            "place the f2py extension next to the generated Python wrapper"
349
        )
350

351
    f2cmap_path = _resolve_optional_path(
1✔
352
        f2py_raw.get("f2cmap_path"),
353
        base_dir=base_dir,
354
        key="f2py.f2cmap_path",
355
    )
356

357
    c_types_raw = f2py_raw.get("c_types", {})
1✔
358
    if c_types_raw is None:
1✔
359
        c_types_raw = {}
×
360
    if not isinstance(c_types_raw, dict):
1✔
361
        raise click.ClickException("config 'f2py.c_types' must be a table")
×
362

363
    real = _load_f2py_ctype_table(c_types_raw, "real")
1✔
364
    integer = _load_f2py_ctype_table(c_types_raw, "integer")
1✔
365
    return f2cmap_path, F2pyCTypeMap(real=real, integer=integer)
1✔
366

367

368
def _load_f2py_ctype_table(c_types: dict[str, Any], key: str) -> dict[str, str]:
1✔
369
    raw = c_types.get(key, {})
1✔
370
    if raw is None:
1✔
371
        raw = {}
×
372
    if not isinstance(raw, dict):
1✔
373
        raise click.ClickException(f"config 'f2py.c_types.{key}' must be a table")
×
374
    values: dict[str, str] = {}
1✔
375
    for kind, c_type in raw.items():
1✔
376
        if not isinstance(kind, str) or not kind.strip():
1✔
377
            raise click.ClickException(
×
378
                f"config 'f2py.c_types.{key}' keys must be non-empty strings"
379
            )
380
        if not isinstance(c_type, str) or not c_type.strip():
1✔
381
            raise click.ClickException(
×
382
                f"config 'f2py.c_types.{key}.{kind}' must be a non-empty string"
383
            )
384
        values[kind.strip()] = c_type.strip()
1✔
385
    return values
1✔
386

387

388
def _format_constant_literal(value: int) -> tuple[str, str]:
1✔
389
    if isinstance(value, bool):
1✔
390
        raise click.ClickException("config constants must not be boolean")
×
391
    if isinstance(value, int):
1✔
392
        return "integer", str(value)
1✔
393
    raise click.ClickException("config constants must be integers")
×
394

395

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

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

444

445
def _load_dimensions(
1✔
446
    config: dict[str, Any],
447
    constants: dict[str, int],
448
) -> tuple[dict[str, int], list[ConstantSpec]]:
449
    dimensions_raw = config.get("dimensions", {})
1✔
450
    if dimensions_raw is None:
1✔
451
        dimensions_raw = {}
×
452
    if not isinstance(dimensions_raw, dict):
1✔
453
        raise click.ClickException("config 'dimensions' must be a table")
×
454

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

511

512
def _load_bool_field(section: dict[str, Any], key: str, *, label: str) -> bool:
1✔
513
    value = section.get(key, False)
×
514
    if isinstance(value, bool):
×
515
        return value
×
516
    raise click.ClickException(f"config '{label}' must be a boolean")
×
517

518

519
def _load_documentation_settings(
1✔
520
    config: dict[str, Any],
521
) -> tuple[str | None, bool, bool, str]:
522
    doc_raw = config.get("documentation")
1✔
523
    if doc_raw is None:
1✔
524
        return None, False, False, "numpy"
1✔
525
    if not isinstance(doc_raw, dict):
×
526
        raise click.ClickException("config 'documentation' must be a table")
×
527
    module_doc = None
×
528
    if "module" in doc_raw:
×
529
        module_raw = doc_raw.get("module")
×
530
        if not isinstance(module_raw, str):
×
531
            raise click.ClickException("config 'documentation.module' must be a string")
×
532
        module_doc = module_raw.strip() or None
×
533
    md_doxygen_id_from_name = _load_bool_field(
×
534
        doc_raw,
535
        "md_doxygen_id_from_name",
536
        label="documentation.md_doxygen_id_from_name",
537
    )
538
    md_add_toc_statement = _load_bool_field(
×
539
        doc_raw,
540
        "md_add_toc_statement",
541
        label="documentation.md_add_toc_statement",
542
    )
543
    py_style_raw = doc_raw.get("py-style", "numpy")
×
544
    if not isinstance(py_style_raw, str):
×
545
        raise click.ClickException("config 'documentation.py-style' must be a string")
×
546
    py_style = py_style_raw.strip().lower()
×
547
    if py_style not in {"numpy", "doxygen"}:
×
548
        raise click.ClickException(
×
549
            "config 'documentation.py-style' must be 'numpy' or 'doxygen'"
550
        )
551
    return module_doc, md_doxygen_id_from_name, md_add_toc_statement, py_style
×
552

553

554
def _parse_cli_constants(values: tuple[tuple[str, int], ...]) -> dict[str, int]:
1✔
555
    constants: dict[str, int] = {}
1✔
556
    for name, value in values:
1✔
557
        if name in constants:
1✔
558
            raise click.ClickException(f"constant '{name}' duplicates another constant name")
1✔
559
        constants[name] = value
1✔
560
    return constants
1✔
561

562

563
def _parse_cli_dimensions(values: tuple[tuple[str, int], ...]) -> dict[str, int]:
1✔
564
    dimensions: dict[str, int] = {}
1✔
565
    for name, value in values:
1✔
566
        if name in dimensions:
1✔
567
            raise click.ClickException(f"dimension '{name}' duplicates another dimension name")
1✔
568
        if value <= 0:
1✔
569
            raise click.ClickException(f"dimension '{name}' value must be positive")
×
570
        dimensions[name] = value
1✔
571
    return dimensions
1✔
572

573

574
def _reject_constant_dimension_overlap(
1✔
575
    constants: dict[str, int],
576
    dimensions: dict[str, int],
577
) -> None:
578
    duplicate_names = constant_dimension_overlap(constants, dimensions)
1✔
579
    if duplicate_names:
1✔
580
        raise click.ClickException(
×
581
            "constants and dimensions must not share names: " + ", ".join(duplicate_names)
582
        )
583

584

585
def _iter_namelists(config: dict[str, Any], base_dir: Path) -> list[dict[str, Any]]:
1✔
586
    raw_entries = config.get("namelists")
1✔
587
    if raw_entries is None and "nml-files" in config:
1✔
588
        raise click.ClickException("config uses deprecated 'nml-files'; rename to 'namelists'")
×
589
    if not isinstance(raw_entries, list) or not raw_entries:
1✔
590
        raise click.ClickException("config must define non-empty 'namelists'")
×
591

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

646

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

687

688
def _namelist_registry_by_key(
1✔
689
    registry: list[LoadedNamelist],
690
) -> dict[str, LoadedNamelist]:
691
    return {loaded.key: loaded for loaded in registry}
1✔
692

693

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

717

718
def _resolve_required_profile_members(
1✔
719
    raw_names: Any,
720
    *,
721
    registry: dict[str, LoadedNamelist],
722
    profile_members: set[str],
723
    label: str,
724
) -> list[str]:
725
    if raw_names is None:
1✔
726
        return []
1✔
727
    if not isinstance(raw_names, list):
1✔
728
        raise click.ClickException(f"{label} 'required' must be a list")
1✔
729
    resolved: list[str] = []
1✔
730
    seen: set[str] = set()
1✔
731
    for raw_name in raw_names:
1✔
732
        if not isinstance(raw_name, str) or not raw_name.strip():
1✔
733
            raise click.ClickException(f"{label} 'required' entries must be strings")
1✔
734
        key = raw_name.lower()
1✔
735
        if key in seen:
1✔
736
            raise click.ClickException(
1✔
737
                f"{label} required namelist '{raw_name}' duplicates another name"
738
            )
739
        seen.add(key)
1✔
740
        if key not in registry:
1✔
741
            raise click.ClickException(
1✔
742
                f"{label} required namelist '{raw_name}' references unknown namelist"
743
            )
744
        if key not in profile_members:
1✔
745
            raise click.ClickException(
1✔
746
                f"{label} required namelist '{raw_name}' is not listed in 'namelists'"
747
            )
748
        resolved.append(key)
1✔
749
    return resolved
1✔
750

751

752
def _resolve_template_schema_members(
1✔
753
    raw_paths: Any,
754
    *,
755
    base_dir: Path,
756
    registry: dict[str, LoadedNamelist],
757
) -> list[LoadedNamelist]:
758
    """Resolve legacy template schema paths to configured namelist entries."""
759
    if not isinstance(raw_paths, list) or not raw_paths:
1✔
760
        raise click.ClickException("templates 'schemas' must be a non-empty list")
×
761
    by_schema_path: dict[Path, LoadedNamelist] = {}
1✔
762
    for loaded in registry.values():
1✔
763
        schema_path = loaded.entry["schema"]
1✔
764
        if isinstance(schema_path, Path):
1✔
765
            by_schema_path[schema_path.resolve()] = loaded
1✔
766

767
    resolved: list[LoadedNamelist] = []
1✔
768
    seen: set[Path] = set()
1✔
769
    for raw_path in raw_paths:
1✔
770
        if not isinstance(raw_path, str) or not raw_path.strip():
1✔
771
            raise click.ClickException("templates 'schemas' entries must be strings")
×
772
        schema_path = Path(raw_path)
1✔
773
        if not schema_path.is_absolute():
1✔
774
            schema_path = base_dir / schema_path
1✔
775
        canonical_path = schema_path.resolve()
1✔
776
        if canonical_path in seen:
1✔
777
            raise click.ClickException(
1✔
778
                f"templates schema '{raw_path}' duplicates another schema"
779
            )
780
        seen.add(canonical_path)
1✔
781
        matched = by_schema_path.get(canonical_path)
1✔
782
        if matched is None:
1✔
783
            raise click.ClickException(
1✔
784
                f"templates schema '{raw_path}' does not match a configured namelist schema"
785
            )
786
        resolved.append(matched)
1✔
787
    return resolved
1✔
788

789

790
def _optional_string(
1✔
791
    entry: dict[str, Any],
792
    key: str,
793
    *,
794
    label: str,
795
) -> str | None:
796
    value = entry.get(key)
1✔
797
    if value is None:
1✔
798
        return None
1✔
799
    if not isinstance(value, str):
1✔
800
        raise click.ClickException(f"{label} '{key}' must be a string")
1✔
801
    return value
1✔
802

803

804
def _iter_file_profiles(
1✔
805
    config: dict[str, Any],
806
    registry: dict[str, LoadedNamelist],
807
) -> dict[str, FileProfile]:
808
    raw_entries = config.get("file_profiles")
1✔
809
    if raw_entries is None:
1✔
810
        return {}
1✔
811
    if not isinstance(raw_entries, list) or not raw_entries:
1✔
812
        raise click.ClickException("config 'file_profiles' must be a non-empty list")
×
813

814
    profiles: dict[str, FileProfile] = {}
1✔
815
    for entry in raw_entries:
1✔
816
        if not isinstance(entry, dict):
1✔
817
            raise click.ClickException("each file_profiles entry must be a table")
×
818
        name = entry.get("name")
1✔
819
        if not isinstance(name, str) or not name.strip():
1✔
820
            raise click.ClickException("file_profiles entry must define string 'name'")
1✔
821
        key = name.lower()
1✔
822
        if key in profiles:
1✔
823
            raise click.ClickException(f"file profile '{name}' duplicates another profile")
1✔
824
        default_file = entry.get("default_file")
1✔
825
        if not isinstance(default_file, str) or not default_file.strip():
1✔
826
            raise click.ClickException("file_profiles entry must define string 'default_file'")
1✔
827
        members = _resolve_namelist_members(
1✔
828
            entry.get("namelists"),
829
            registry=registry,
830
            label=f"file profile '{name}'",
831
        )
832
        member_keys = [member.key for member in members]
1✔
833
        required = _resolve_required_profile_members(
1✔
834
            entry.get("required"),
835
            registry=registry,
836
            profile_members=set(member_keys),
837
            label=f"file profile '{name}'",
838
        )
839
        profiles[key] = FileProfile(
1✔
840
            name=name,
841
            key=key,
842
            default_file=default_file,
843
            namelists=member_keys,
844
            required=required,
845
            title=_optional_string(entry, "title", label=f"file profile '{name}'"),
846
            description=_optional_string(
847
                entry,
848
                "description",
849
                label=f"file profile '{name}'",
850
            ),
851
        )
852
    return profiles
1✔
853

854

855
def _iter_templates(
1✔
856
    config: dict[str, Any],
857
    base_dir: Path,
858
    registry: dict[str, LoadedNamelist],
859
    profiles: dict[str, FileProfile],
860
) -> list[dict[str, Any]]:
861
    raw_entries = config.get("templates")
1✔
862
    if raw_entries is None:
1✔
863
        return []
1✔
864
    if not isinstance(raw_entries, list) or not raw_entries:
1✔
865
        raise click.ClickException("config 'templates' must be a non-empty list")
×
866

867
    entries: list[dict[str, Any]] = []
1✔
868
    for entry in raw_entries:
1✔
869
        if not isinstance(entry, dict):
1✔
870
            raise click.ClickException("each templates entry must be a table")
×
871
        if "output" in entry:
1✔
872
            raise click.ClickException("templates use 'path', not deprecated 'output'")
1✔
873
        path_raw = entry.get("path")
1✔
874
        if not isinstance(path_raw, str):
1✔
875
            raise click.ClickException("templates entry must define string 'path'")
×
876
        output_path = base_dir / path_raw
1✔
877

878
        profile_raw = entry.get("profile")
1✔
879
        has_profile = profile_raw is not None
1✔
880
        has_namelists = "namelists" in entry
1✔
881
        has_schemas = "schemas" in entry
1✔
882
        if has_schemas and (has_profile or has_namelists):
1✔
883
            raise click.ClickException(
1✔
884
                "templates entry with 'schemas' must not define 'profile' or 'namelists'"
885
            )
886
        if not has_schemas and has_profile == has_namelists:
1✔
887
            raise click.ClickException(
×
888
                "templates entry must define exactly one of 'profile' or 'namelists'"
889
            )
890
        title = _optional_string(entry, "title", label="templates entry")
1✔
891
        description = _optional_string(entry, "description", label="templates entry")
1✔
892
        if has_profile:
1✔
893
            if not isinstance(profile_raw, str) or not profile_raw.strip():
1✔
894
                raise click.ClickException("templates 'profile' must be a string")
×
895
            profile = profiles.get(profile_raw.lower())
1✔
896
            if profile is None:
1✔
897
                raise click.ClickException(
×
898
                    f"templates entry references unknown profile '{profile_raw}'"
899
                )
900
            loaded_namelists = [registry[key] for key in profile.namelists]
1✔
901
            if title is None:
1✔
902
                title = profile.title
1✔
903
            if description is None:
1✔
904
                description = profile.description
×
905
        elif has_schemas:
1✔
906
            loaded_namelists = _resolve_template_schema_members(
1✔
907
                entry.get("schemas"),
908
                base_dir=base_dir,
909
                registry=registry,
910
            )
911
        else:
912
            loaded_namelists = _resolve_namelist_members(
1✔
913
                entry.get("namelists"),
914
                registry=registry,
915
                label="templates entry",
916
            )
917

918
        doc_mode = entry.get("doc_mode", "plain")
1✔
919
        if not isinstance(doc_mode, str):
1✔
920
            raise click.ClickException("templates 'doc_mode' must be a string")
×
921
        value_mode = entry.get("value_mode", "empty")
1✔
922
        if not isinstance(value_mode, str):
1✔
923
            raise click.ClickException("templates 'value_mode' must be a string")
×
924
        values_raw = entry.get("values", {})
1✔
925
        if values_raw is None:
1✔
926
            values_raw = {}
×
927
        if not isinstance(values_raw, dict):
1✔
928
            raise click.ClickException("templates 'values' must be a table")
×
929

930
        entries.append(
1✔
931
            {
932
                "path": output_path,
933
                "schemas": [loaded.schema for loaded in loaded_namelists],
934
                "doc_mode": doc_mode,
935
                "value_mode": value_mode,
936
                "title": title,
937
                "description": description,
938
                "values": values_raw,
939
            }
940
        )
941
    return entries
1✔
942

943

944
def _collect_generated_outputs(
1✔
945
    config: dict[str, Any],
946
    config_path: Path,
947
) -> list[GeneratedOutput]:
948
    base_dir = config_path.parent
1✔
949
    logger.debug("Base directory: %s", base_dir)
1✔
950
    helper_path, helper_module, helper_buffer, helper_header = _load_helper_settings(
1✔
951
        config,
952
        base_dir,
953
    )
954
    (
1✔
955
        module_doc,
956
        md_doxygen_id_from_name,
957
        md_add_toc_statement,
958
        py_style,
959
    ) = _load_documentation_settings(config)
960
    constants, constant_specs = _load_constants(config)
1✔
961
    dimensions, dimension_specs = _load_dimensions(config, constants)
1✔
962
    kind_module, kind_map, kind_allowlist = _load_kind_settings(config)
1✔
963
    f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir)
1✔
964
    resolver = SchemaResolver()
1✔
965
    outputs: list[GeneratedOutput] = []
1✔
966

967
    loaded_namelists = _load_namelist_registry(config, base_dir, resolver)
1✔
968
    loaded_by_key = _namelist_registry_by_key(loaded_namelists)
1✔
969
    profiles = _iter_file_profiles(config, loaded_by_key)
1✔
970
    logger.debug("Found %d schema entries", len(loaded_namelists))
1✔
971
    loaded_entries: list[dict[str, Any]] = [
1✔
972
        {"entry": loaded.entry, "schema": loaded.schema} for loaded in loaded_namelists
973
    ]
974
    for loaded in loaded_namelists:
1✔
975
        namelist_entry = loaded.entry
1✔
976
        schema = loaded.schema
1✔
977

978
        mod_path = namelist_entry["mod_path"]
1✔
979
        if mod_path is not None:
1✔
980
            try:
1✔
981
                logger.debug("Rendering Fortran module at %s", mod_path)
1✔
982
                outputs.append(
1✔
983
                    GeneratedOutput(
984
                        mod_path,
985
                        render_fortran(
986
                            schema,
987
                            file_name=mod_path.name,
988
                            helper_module=helper_module,
989
                            kind_module=kind_module,
990
                            kind_map=kind_map,
991
                            kind_allowlist=kind_allowlist,
992
                            constants=constants,
993
                            dimensions=dimensions,
994
                            module_doc=module_doc,
995
                            f2py_handle_helpers=namelist_entry["f2py_path"] is not None,
996
                        ),
997
                    )
998
                )
999
            except ValueError as exc:
×
1000
                raise click.ClickException(str(exc)) from exc
×
1001

1002
        doc_path = namelist_entry["doc_path"]
1✔
1003
        if doc_path is not None:
1✔
1004
            try:
1✔
1005
                logger.debug("Rendering Markdown docs at %s", doc_path)
1✔
1006
                outputs.append(
1✔
1007
                    GeneratedOutput(
1008
                        doc_path,
1009
                        render_docs(
1010
                            schema,
1011
                            constants=constants,
1012
                            dimensions=dimensions,
1013
                            md_doxygen_id_from_name=md_doxygen_id_from_name,
1014
                            md_add_toc_statement=md_add_toc_statement,
1015
                        ),
1016
                    )
1017
                )
1018
            except ValueError as exc:
×
1019
                raise click.ClickException(str(exc)) from exc
×
1020

1021
    try:
1✔
1022
        local_derived_types = collect_local_derived_types(
1✔
1023
            [loaded.schema for loaded in loaded_namelists],
1024
            constants=constants,
1025
        )
1026
        if local_derived_types and helper_path is None:
1✔
1027
            raise ValueError("locally generated derived types require a configured helper output")
×
1028
        if helper_path is not None:
1✔
1029
            logger.debug("Rendering helper module at %s", helper_path)
1✔
1030
            outputs.append(
1✔
1031
                GeneratedOutput(
1032
                    helper_path,
1033
                    render_helper(
1034
                        file_name=helper_path.name,
1035
                        module_name=helper_module,
1036
                        len_buf=helper_buffer,
1037
                        constants=constant_specs + dimension_specs,
1038
                        local_derived_types=local_derived_types,
1039
                        kind_module=kind_module,
1040
                        kind_map=kind_map,
1041
                        kind_allowlist=kind_allowlist,
1042
                        module_doc=module_doc,
1043
                        helper_header=helper_header,
1044
                    ),
1045
                )
1046
            )
1047
    except ValueError as exc:
×
1048
        raise click.ClickException(str(exc)) from exc
×
1049

1050
    outputs.extend(
1✔
1051
        _collect_f2py_outputs(
1052
            loaded_entries,
1053
            helper_module=helper_module,
1054
            helper_buffer=helper_buffer,
1055
            kind_module=kind_module,
1056
            kind_map=kind_map,
1057
            kind_allowlist=kind_allowlist,
1058
            constants=constants,
1059
            dimensions=dimensions,
1060
            f2cmap_path=f2cmap_path,
1061
            f2py_c_types=f2py_c_types,
1062
            py_style=py_style,
1063
            include_python=True,
1064
        )
1065
    )
1066

1067
    template_entries = _iter_templates(config, base_dir, loaded_by_key, profiles)
1✔
1068
    if template_entries:
1✔
1069
        logger.debug("Found %d template entries", len(template_entries))
1✔
1070
    for template_entry in template_entries:
1✔
1071
        try:
1✔
1072
            logger.debug("Rendering template at %s", template_entry["path"])
1✔
1073
            outputs.append(
1✔
1074
                GeneratedOutput(
1075
                    template_entry["path"],
1076
                    render_template(
1077
                        template_entry["schemas"],
1078
                        doc_mode=template_entry["doc_mode"],
1079
                        value_mode=template_entry["value_mode"],
1080
                        title=template_entry["title"],
1081
                        description=template_entry["description"],
1082
                        constants=constants,
1083
                        dimensions=dimensions,
1084
                        kind_map=kind_map,
1085
                        kind_allowlist=kind_allowlist,
1086
                        values=template_entry["values"],
1087
                    ),
1088
                )
1089
            )
1090
        except ValueError as exc:
×
1091
            raise click.ClickException(str(exc)) from exc
×
1092

1093
    return outputs
1✔
1094

1095

1096
def _collect_f2py_outputs(
1✔
1097
    loaded_entries: list[dict[str, Any]],
1098
    *,
1099
    helper_module: str,
1100
    helper_buffer: int,
1101
    kind_module: str,
1102
    kind_map: dict[str, str],
1103
    kind_allowlist: set[str],
1104
    constants: dict[str, int],
1105
    dimensions: dict[str, int],
1106
    f2cmap_path: Path | None,
1107
    f2py_c_types: F2pyCTypeMap,
1108
    py_style: str,
1109
    include_python: bool,
1110
) -> list[GeneratedOutput]:
1111
    outputs: list[GeneratedOutput] = []
1✔
1112
    f2py_groups: dict[Path, list[dict[str, Any]]] = {}
1✔
1113
    py_groups: dict[Path, list[tuple[dict[str, Any], Path]]] = {}
1✔
1114
    for loaded in loaded_entries:
1✔
1115
        entry = loaded["entry"]
1✔
1116
        schema = loaded["schema"]
1✔
1117
        f2py_path = entry["f2py_path"]
1✔
1118
        if f2py_path is None:
1✔
1119
            continue
1✔
1120
        f2py_groups.setdefault(f2py_path, []).append(schema)
1✔
1121
        py_path = entry["py_path"]
1✔
1122
        if include_python and py_path is not None:
1✔
1123
            py_groups.setdefault(py_path, []).append((schema, f2py_path))
1✔
1124

1125
    for f2py_path, schemas in f2py_groups.items():
1✔
1126
        try:
1✔
1127
            logger.debug("Rendering f2py wrappers at %s", f2py_path)
1✔
1128
            outputs.append(
1✔
1129
                GeneratedOutput(
1130
                    f2py_path,
1131
                    render_f2py_wrappers(
1132
                        schemas,
1133
                        file_name=f2py_path.name,
1134
                        helper_module=helper_module,
1135
                        kind_module=kind_module,
1136
                        kind_map=kind_map,
1137
                        kind_allowlist=kind_allowlist,
1138
                        constants=constants,
1139
                        dimensions=dimensions,
1140
                        errmsg_len=helper_buffer,
1141
                    ),
1142
                )
1143
            )
1144
        except ValueError as exc:
×
1145
            raise click.ClickException(str(exc)) from exc
×
1146

1147
    if f2cmap_path is not None:
1✔
1148
        try:
1✔
1149
            usage = merge_f2py_kind_usage(
1✔
1150
                collect_f2py_kind_usage(schemas, constants=constants, dimensions=dimensions)
1151
                for schemas in f2py_groups.values()
1152
            )
1153
            logger.debug("Rendering f2py kind map at %s", f2cmap_path)
1✔
1154
            outputs.append(
1✔
1155
                GeneratedOutput(
1156
                    f2cmap_path,
1157
                    render_f2cmap(usage, f2py_c_types),
1158
                )
1159
            )
1160
        except ValueError as exc:
×
1161
            raise click.ClickException(str(exc)) from exc
×
1162

1163
    if not include_python:
1✔
1164
        return outputs
1✔
1165
    for py_path, entries in py_groups.items():
1✔
1166
        try:
1✔
1167
            logger.debug("Rendering Python f2py wrappers at %s", py_path)
1✔
1168
            specs = [
1✔
1169
                (
1170
                    build_f2py_namelist_spec(
1171
                        schema,
1172
                        helper_module=helper_module,
1173
                        kind_module=kind_module,
1174
                        kind_map=kind_map,
1175
                        kind_allowlist=kind_allowlist,
1176
                        constants=constants,
1177
                        dimensions=dimensions,
1178
                        errmsg_len=helper_buffer,
1179
                    ),
1180
                    f2py_path.stem,
1181
                )
1182
                for schema, f2py_path in entries
1183
            ]
1184
            outputs.append(
1✔
1185
                GeneratedOutput(
1186
                    py_path,
1187
                    render_python_wrappers(
1188
                        specs,
1189
                        py_style=py_style,
1190
                    ),
1191
                )
1192
            )
1193
        except ValueError as exc:
×
1194
            raise click.ClickException(str(exc)) from exc
×
1195
    return outputs
1✔
1196

1197

1198
def _generate_f2py_outputs(
1✔
1199
    loaded_entries: list[dict[str, Any]],
1200
    *,
1201
    helper_module: str,
1202
    helper_buffer: int,
1203
    kind_module: str,
1204
    kind_map: dict[str, str],
1205
    kind_allowlist: set[str],
1206
    constants: dict[str, int],
1207
    dimensions: dict[str, int],
1208
    f2cmap_path: Path | None,
1209
    f2py_c_types: F2pyCTypeMap,
1210
    py_style: str,
1211
    include_python: bool,
1212
) -> None:
1213
    _write_generated_outputs(
1✔
1214
        _collect_f2py_outputs(
1215
            loaded_entries,
1216
            helper_module=helper_module,
1217
            helper_buffer=helper_buffer,
1218
            kind_module=kind_module,
1219
            kind_map=kind_map,
1220
            kind_allowlist=kind_allowlist,
1221
            constants=constants,
1222
            dimensions=dimensions,
1223
            f2cmap_path=f2cmap_path,
1224
            f2py_c_types=f2py_c_types,
1225
            py_style=py_style,
1226
            include_python=include_python,
1227
        )
1228
    )
1229

1230

1231
def _write_generated_outputs(outputs: list[GeneratedOutput]) -> None:
1✔
1232
    for output in outputs:
1✔
1233
        logger.info("Writing generated file %s", output.path)
1✔
1234
        output.path.parent.mkdir(parents=True, exist_ok=True)
1✔
1235
        output.path.write_text(output.content, encoding="ascii")
1✔
1236

1237

1238
def _check_generated_outputs(outputs: list[GeneratedOutput], *, show_diff: bool) -> int:
1✔
1239
    failed = 0
1✔
1240
    for output in outputs:
1✔
1241
        if not output.path.exists():
1✔
1242
            failed += 1
1✔
1243
            click.echo(f"MISSING: {output.path}", err=True)
1✔
1244
            continue
1✔
1245
        current = output.path.read_text(encoding="ascii")
1✔
1246
        if current != output.content:
1✔
1247
            failed += 1
1✔
1248
            click.echo(f"DIFF: {output.path}", err=True)
1✔
1249
            if show_diff:
1✔
1250
                diff = unified_diff(
1✔
1251
                    current.splitlines(keepends=True),
1252
                    output.content.splitlines(keepends=True),
1253
                    fromfile=f"current {output.path}",
1254
                    tofile=f"generated {output.path}",
1255
                )
1256
                for line in diff:
1✔
1257
                    click.echo(line, nl=False)
1✔
1258
            continue
1✔
1259
        logger.debug("OK: %s", output.path)
1✔
1260
    return failed
1✔
1261

1262

1263
@click.group(context_settings=_CONTEXT_SETTINGS)
1✔
1264
@click.version_option(__version__, "-V", "--version", prog_name="nml-tools")
1✔
1265
@click.option("--verbose", "-v", count=True, help="Increase verbosity (repeatable).")
1✔
1266
@click.option("--quiet", "-q", count=True, help="Decrease verbosity (repeatable).")
1✔
1267
def cli(verbose: int, quiet: int) -> None:
1✔
1268
    """nml-tools command line interface."""
1269
    _configure_logging(verbose, quiet)
1✔
1270

1271

1272
@cli.command("generate", context_settings=_CONTEXT_SETTINGS)
1✔
1273
@click.option(
1✔
1274
    "--config",
1275
    "config_path",
1276
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1277
    default=None,
1278
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1279
)
1280
def generate(config_path: Path | None) -> None:
1✔
1281
    """Generate outputs from a configuration file."""
1282
    config, config_path = _load_config_checked(config_path)
1✔
1283
    logger.info("Loading config from %s", config_path)
1✔
1284
    _write_generated_outputs(_collect_generated_outputs(config, config_path))
1✔
1285

1286

1287
@cli.command("check", context_settings=_CONTEXT_SETTINGS)
1✔
1288
@click.option(
1✔
1289
    "--config",
1290
    "config_path",
1291
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1292
    default=None,
1293
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1294
)
1295
@click.option(
1✔
1296
    "--diff",
1297
    "show_diff",
1298
    is_flag=True,
1299
    help="Show unified diffs for generated files that differ.",
1300
)
1301
def check(config_path: Path | None, show_diff: bool) -> None:
1✔
1302
    """Check that configured generated files are up to date."""
1303
    config, config_path = _load_config_checked(config_path)
1✔
1304
    logger.debug("Loading config from %s", config_path)
1✔
1305
    failures = _check_generated_outputs(
1✔
1306
        _collect_generated_outputs(config, config_path),
1307
        show_diff=show_diff,
1308
    )
1309
    if failures:
1✔
1310
        raise click.ClickException(
1✔
1311
            f"generated files are out of date: {failures} file(s) differ or are missing"
1312
        )
1313

1314

1315
@cli.command("gen-fortran", context_settings=_CONTEXT_SETTINGS)
1✔
1316
@click.option(
1✔
1317
    "--config",
1318
    "config_path",
1319
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1320
    default=None,
1321
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1322
)
1323
def gen_fortran(config_path: Path | None) -> None:
1✔
1324
    """Generate Fortran module(s)."""
1325
    config, config_path = _load_config_checked(config_path)
1✔
1326
    logger.info("Loading config from %s", config_path)
1✔
1327
    base_dir = config_path.parent
1✔
1328
    logger.debug("Base directory: %s", base_dir)
1✔
1329
    helper_path, helper_module, helper_buffer, helper_header = _load_helper_settings(
1✔
1330
        config,
1331
        base_dir,
1332
    )
1333
    module_doc, _, _, py_style = _load_documentation_settings(config)
1✔
1334
    constants, constant_specs = _load_constants(config)
1✔
1335
    dimensions, dimension_specs = _load_dimensions(config, constants)
1✔
1336
    kind_module, kind_map, kind_allowlist = _load_kind_settings(config)
1✔
1337
    f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir)
1✔
1338
    resolver = SchemaResolver()
1✔
1339
    entries = _iter_namelists(config, base_dir)
1✔
1340
    logger.info("Found %d schema entries", len(entries))
1✔
1341
    loaded_entries: list[dict[str, Any]] = []
1✔
1342
    for entry in entries:
1✔
1343
        schema_path = entry["schema"]
1✔
1344
        if schema_path is None:
1✔
1345
            raise click.ClickException("namelists entry missing schema path")
×
1346
        try:
1✔
1347
            logger.info("Loading schema %s", schema_path)
1✔
1348
            schema = load_schema(schema_path, resolver=resolver)
1✔
1349
        except (FileNotFoundError, ValueError) as exc:
×
1350
            raise click.ClickException(str(exc)) from exc
×
1351
        loaded_entries.append({"entry": entry, "schema": schema})
1✔
1352
        mod_path = entry["mod_path"]
1✔
1353
        if mod_path is None:
1✔
1354
            continue
×
1355
        try:
1✔
1356
            logger.info("Generating Fortran module at %s", mod_path)
1✔
1357
            generate_fortran(
1✔
1358
                schema,
1359
                mod_path,
1360
                helper_module=helper_module,
1361
                kind_module=kind_module,
1362
                kind_map=kind_map,
1363
                kind_allowlist=kind_allowlist,
1364
                constants=constants,
1365
                dimensions=dimensions,
1366
                module_doc=module_doc,
1367
                f2py_handle_helpers=entry["f2py_path"] is not None,
1368
            )
1369
        except ValueError as exc:
×
1370
            raise click.ClickException(str(exc)) from exc
×
1371

1372
    try:
1✔
1373
        local_derived_types = collect_local_derived_types(
1✔
1374
            [loaded["schema"] for loaded in loaded_entries],
1375
            constants=constants,
1376
        )
1377
        if local_derived_types and helper_path is None:
1✔
1378
            raise ValueError("locally generated derived types require a configured helper output")
×
1379
        if helper_path is not None:
1✔
1380
            logger.info("Generating helper module at %s", helper_path)
×
1381
            generate_helper(
×
1382
                helper_path,
1383
                module_name=helper_module,
1384
                len_buf=helper_buffer,
1385
                constants=constant_specs + dimension_specs,
1386
                local_derived_types=local_derived_types,
1387
                kind_module=kind_module,
1388
                kind_map=kind_map,
1389
                kind_allowlist=kind_allowlist,
1390
                module_doc=module_doc,
1391
                helper_header=helper_header,
1392
            )
1393
    except ValueError as exc:
×
1394
        raise click.ClickException(str(exc)) from exc
×
1395

1396
    _generate_f2py_outputs(
1✔
1397
        loaded_entries,
1398
        helper_module=helper_module,
1399
        helper_buffer=helper_buffer,
1400
        kind_module=kind_module,
1401
        kind_map=kind_map,
1402
        kind_allowlist=kind_allowlist,
1403
        constants=constants,
1404
        dimensions=dimensions,
1405
        f2cmap_path=f2cmap_path,
1406
        f2py_c_types=f2py_c_types,
1407
        py_style=py_style,
1408
        include_python=False,
1409
    )
1410

1411

1412
@cli.command("validate", context_settings=_CONTEXT_SETTINGS)
1✔
1413
@click.option(
1✔
1414
    "--config",
1415
    "config_path",
1416
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1417
    default=None,
1418
)
1419
@click.option(
1✔
1420
    "--schema",
1421
    "schema_paths",
1422
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1423
    multiple=True,
1424
)
1425
@click.option(
1✔
1426
    "--input",
1427
    "input_option",
1428
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1429
)
1430
@click.option(
1✔
1431
    "--profile",
1432
    "profile_name",
1433
    help="Validate against one configured file profile.",
1434
)
1435
@click.option(
1✔
1436
    "--constants",
1437
    "constant_args",
1438
    metavar="NAME=INT",
1439
    type=_CONSTANT_TYPE,
1440
    multiple=True,
1441
    help="Additional integer constants as NAME=INT (repeatable).",
1442
)
1443
@click.option(
1✔
1444
    "--dimensions",
1445
    "dimension_args",
1446
    metavar="NAME=INT",
1447
    type=_DIMENSION_TYPE,
1448
    multiple=True,
1449
    help="Runtime dimensions as NAME=INT (repeatable).",
1450
)
1451
@click.argument(
1✔
1452
    "input_path",
1453
    required=False,
1454
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1455
)
1456
def validate(
1✔
1457
    config_path: Path | None,
1458
    schema_paths: tuple[Path, ...],
1459
    input_option: Path | None,
1460
    profile_name: str | None,
1461
    constant_args: tuple[tuple[str, int], ...],
1462
    dimension_args: tuple[tuple[str, int], ...],
1463
    input_path: Path | None,
1464
) -> None:
1465
    """Validate a namelist file against schema definitions."""
1466
    if input_option is not None and input_path is not None:
1✔
1467
        raise click.ClickException("input path provided twice")
×
1468
    input_path = input_option or input_path
1✔
1469
    if input_path is None:
1✔
1470
        raise click.ClickException("input path is required")
×
1471
    constants = _parse_cli_constants(constant_args)
1✔
1472
    dimension_overrides = _parse_cli_dimensions(dimension_args)
1✔
1473
    dimensions: dict[str, int] = {}
1✔
1474
    schemas: list[dict[str, Any]] = []
1✔
1475
    required_namelist_keys: set[str] | None
1476
    resolver = SchemaResolver()
1✔
1477

1478
    if schema_paths:
1✔
1479
        if profile_name is not None:
1✔
1480
            raise click.ClickException("--profile can only be used with config-based validation")
1✔
1481
        if config_path is not None:
1✔
1482
            config, config_path = _load_config_checked(config_path)
×
1483
            logger.info("Loading config from %s", config_path)
×
1484
            cfg_constants, _ = _load_constants(config)
×
1485
            dimensions, _ = _load_dimensions(config, cfg_constants)
×
1486
            constants = {**cfg_constants, **constants}
×
1487
            dimensions = {**dimensions, **dimension_overrides}
×
1488
        else:
1489
            dimensions = dimension_overrides
1✔
1490
        for schema_file in schema_paths:
1✔
1491
            try:
1✔
1492
                logger.info("Loading schema %s", schema_file)
1✔
1493
                schemas.append(load_schema(schema_file, resolver=resolver))
1✔
1494
            except (FileNotFoundError, ValueError) as exc:
×
1495
                raise click.ClickException(str(exc)) from exc
×
1496
        required_namelist_keys = None
1✔
1497
    else:
1498
        config, config_path = _load_config_checked(config_path)
1✔
1499
        logger.info("Loading config from %s", config_path)
1✔
1500
        base_dir = config_path.parent
1✔
1501
        cfg_constants, _ = _load_constants(config)
1✔
1502
        dimensions, _ = _load_dimensions(config, cfg_constants)
1✔
1503
        constants = {**cfg_constants, **constants}
1✔
1504
        dimensions = {**dimensions, **dimension_overrides}
1✔
1505
        loaded_namelists = _load_namelist_registry(config, base_dir, resolver)
1✔
1506
        loaded_by_key = _namelist_registry_by_key(loaded_namelists)
1✔
1507
        if profile_name is not None:
1✔
1508
            profiles = _iter_file_profiles(config, loaded_by_key)
1✔
1509
            profile = profiles.get(profile_name.lower())
1✔
1510
            if profile is None:
1✔
1511
                raise click.ClickException(f"unknown file profile '{profile_name}'")
×
1512
            loaded_namelists = [loaded_by_key[key] for key in profile.namelists]
1✔
1513
            required_namelist_keys = set(profile.required)
1✔
1514
        else:
1515
            required_namelist_keys = set()
1✔
1516
        logger.info("Found %d schema entries", len(loaded_namelists))
1✔
1517
        schemas = [loaded.schema for loaded in loaded_namelists]
1✔
1518

1519
    if not schemas:
1✔
1520
        raise click.ClickException("no schemas provided for validation")
×
1521
    _reject_constant_dimension_overlap(constants, dimensions)
1✔
1522

1523
    try:
1✔
1524
        logger.info("Reading namelist %s", input_path)
1✔
1525
        namelist_text = input_path.read_text(encoding="utf-8")
1✔
NEW
1526
    except (OSError, UnicodeError) as exc:
×
UNCOV
1527
        raise click.ClickException(f"failed to read namelist: {exc}") from exc
×
1528
    try:
1✔
1529
        parsed_file = parse_namelist(namelist_text, source=str(input_path))
1✔
1530
    except NamelistSyntaxError as exc:
1✔
1531
        raise click.ClickException(f"failed to parse namelist: {exc}") from exc
1✔
1532

1533
    file_entries: dict[str, tuple[str, ParsedGroup]] = {}
1✔
1534
    for group in parsed_file.groups:
1✔
1535
        key = group.name.lower()
1✔
1536
        if key in file_entries:
1✔
NEW
1537
            raise click.ClickException(f"namelist '{group.name}' appears multiple times")
×
1538
        file_entries[key] = (group.name, group)
1✔
1539

1540
    schema_entries: dict[str, dict[str, Any]] = {}
1✔
1541
    for schema in schemas:
1✔
1542
        namelist_name = schema.get("x-fortran-namelist")
1✔
1543
        if not isinstance(namelist_name, str) or not namelist_name.strip():
1✔
1544
            raise click.ClickException("schema must define non-empty 'x-fortran-namelist'")
×
1545
        key = namelist_name.lower()
1✔
1546
        if key in schema_entries:
1✔
1547
            raise click.ClickException(f"duplicate schema for namelist '{namelist_name}'")
×
1548
        schema_entries[key] = schema
1✔
1549

1550
    for key, (name, _) in file_entries.items():
1✔
1551
        if key not in schema_entries:
1✔
1552
            raise click.ClickException(f"input contains unknown namelist '{name}'")
1✔
1553

1554
    validated = 0
1✔
1555
    for key, schema in schema_entries.items():
1✔
1556
        if key not in file_entries:
1✔
1557
            if required_namelist_keys is None or key in required_namelist_keys:
1✔
1558
                raise click.ClickException(
1✔
1559
                    f"input is missing namelist '{schema.get('x-fortran-namelist')}'"
1560
                )
1561
            continue
1✔
1562
        try:
1✔
1563
            evaluate_group(
1✔
1564
                file_entries[key][1],
1565
                schema,
1566
                source=str(input_path),
1567
                constants=constants,
1568
                dimensions=dimensions,
1569
            )
1570
        except ValueError as exc:
1✔
1571
            raise click.ClickException(str(exc)) from exc
1✔
1572
        validated += 1
1✔
1573
    logger.info("Validation completed (%d namelist%s).", validated, "" if validated == 1 else "s")
1✔
1574

1575

1576
@cli.command("gen-markdown", context_settings=_CONTEXT_SETTINGS)
1✔
1577
@click.option(
1✔
1578
    "--config",
1579
    "config_path",
1580
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1581
    default=None,
1582
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1583
)
1584
def gen_markdown(config_path: Path | None) -> None:
1✔
1585
    """Generate Markdown docs."""
1586
    config, config_path = _load_config_checked(config_path)
1✔
1587
    logger.info("Loading config from %s", config_path)
1✔
1588
    base_dir = config_path.parent
1✔
1589
    constants, _ = _load_constants(config)
1✔
1590
    dimensions, _ = _load_dimensions(config, constants)
1✔
1591
    _, md_doxygen_id_from_name, md_add_toc_statement, _ = _load_documentation_settings(
1✔
1592
        config
1593
    )
1594
    entries = _iter_namelists(config, base_dir)
1✔
1595
    resolver = SchemaResolver()
1✔
1596
    logger.info("Found %d schema entries", len(entries))
1✔
1597
    for entry in entries:
1✔
1598
        schema_path = entry["schema"]
1✔
1599
        if schema_path is None:
1✔
1600
            raise click.ClickException("namelists entry missing schema path")
×
1601
        try:
1✔
1602
            logger.info("Loading schema %s", schema_path)
1✔
1603
            schema = load_schema(schema_path, resolver=resolver)
1✔
1604
        except (FileNotFoundError, ValueError) as exc:
×
1605
            raise click.ClickException(str(exc)) from exc
×
1606
        doc_path = entry["doc_path"]
1✔
1607
        if doc_path is None:
1✔
1608
            continue
×
1609
        try:
1✔
1610
            logger.info("Generating Markdown docs at %s", doc_path)
1✔
1611
            generate_docs(
1✔
1612
                schema,
1613
                doc_path,
1614
                constants=constants,
1615
                dimensions=dimensions,
1616
                md_doxygen_id_from_name=md_doxygen_id_from_name,
1617
                md_add_toc_statement=md_add_toc_statement,
1618
            )
1619
        except ValueError as exc:
×
1620
            raise click.ClickException(str(exc)) from exc
×
1621

1622

1623
@cli.command("gen-template", context_settings=_CONTEXT_SETTINGS)
1✔
1624
@click.option(
1✔
1625
    "--config",
1626
    "config_path",
1627
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
1628
    default=None,
1629
    show_default="nml-config.toml, then pyproject.toml [tool.nml-tools]",
1630
)
1631
def gen_template(config_path: Path | None) -> None:
1✔
1632
    """Generate template namelist(s)."""
1633
    config, config_path = _load_config_checked(config_path)
1✔
1634
    logger.info("Loading config from %s", config_path)
1✔
1635
    base_dir = config_path.parent
1✔
1636
    constants, _ = _load_constants(config)
1✔
1637
    dimensions, _ = _load_dimensions(config, constants)
1✔
1638
    _, kind_map, kind_allowlist = _load_kind_settings(config)
1✔
1639
    resolver = SchemaResolver()
1✔
1640
    loaded_namelists = _load_namelist_registry(config, base_dir, resolver)
1✔
1641
    loaded_by_key = _namelist_registry_by_key(loaded_namelists)
1✔
1642
    profiles = _iter_file_profiles(config, loaded_by_key)
1✔
1643
    templates = _iter_templates(config, base_dir, loaded_by_key, profiles)
1✔
1644
    if not templates:
1✔
1645
        raise click.ClickException("config must define non-empty 'templates'")
×
1646
    logger.info("Found %d template entries", len(templates))
1✔
1647
    for entry in templates:
1✔
1648
        try:
1✔
1649
            logger.info("Generating template at %s", entry["path"])
1✔
1650
            generate_template(
1✔
1651
                entry["schemas"],
1652
                entry["path"],
1653
                doc_mode=entry["doc_mode"],
1654
                value_mode=entry["value_mode"],
1655
                title=entry["title"],
1656
                description=entry["description"],
1657
                constants=constants,
1658
                dimensions=dimensions,
1659
                kind_map=kind_map,
1660
                kind_allowlist=kind_allowlist,
1661
                values=entry["values"],
1662
            )
1663
        except ValueError as exc:
×
1664
            raise click.ClickException(str(exc)) from exc
×
1665

1666

1667
def main(argv: list[str] | None = None) -> int:
1✔
1668
    """Entry point for the CLI."""
1669
    try:
×
1670
        cli.main(args=argv, prog_name="nml-tools", standalone_mode=False)
×
1671
    except Exit as exc:
×
1672
        return exc.exit_code
×
1673
    except click.ClickException as exc:
×
1674
        exc.show()
×
1675
        return 1
×
1676
    return 0
×
1677

1678

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