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

MuellerSeb / nml-tools / 29495484127

16 Jul 2026 11:36AM UTC coverage: 86.011% (+1.3%) from 84.697%
29495484127

Pull #60

github

MuellerSeb
Stream repeated namelist values during evaluation

Count repetitions without materializing expanded values, then consume them lazily while applying assignments. Compile and validate required derived components up front so malformed schemas produce targeted errors instead of runtime KeyError failures.
Pull Request #60: Add A Schema-Aware Standard Fortran Namelist Parser

823 of 890 new or added lines in 4 files covered. (92.47%)

9 existing lines in 3 files now uncovered.

4759 of 5533 relevant lines covered (86.01%)

0.86 hits per line

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

82.77
/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 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
        parsed_file = parse_namelist(
1✔
1526
            input_path.read_text(encoding="utf-8"),
1527
            source=str(input_path),
1528
        )
NEW
1529
    except (OSError, UnicodeError, ValueError) as exc:
×
UNCOV
1530
        raise click.ClickException(f"failed to read namelist: {exc}") from exc
×
1531

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

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

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

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

1574

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

1621

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

1665

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

1677

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