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

MuellerSeb / nml-tools / 26478216192

26 May 2026 10:14PM UTC coverage: 83.041% (+2.0%) from 81.064%
26478216192

Pull #32

github

MuellerSeb
fix mypy
Pull Request #32: Add One-Level Derived-Type Support For Generated Namelists

642 of 729 new or added lines in 7 files covered. (88.07%)

1 existing line in 1 file now uncovered.

3697 of 4452 relevant lines covered (83.04%)

0.83 hits per line

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

77.02
/src/nml_tools/codegen_template.py
1
"""Template namelist generation."""
2

3
from __future__ import annotations
1✔
4

5
from pathlib import Path
1✔
6
from typing import Any, Iterable
1✔
7

8
from ._utils import (
1✔
9
    normalize_constant_values,
10
    normalize_runtime_dimensions,
11
    reject_constant_dimension_overlap,
12
)
13
from .codegen_fortran import (
1✔
14
    FieldTypeInfo,
15
    _array_default_value,
16
    _collect_dimension_constants,
17
    _derived_schema,
18
    _enum_values,
19
    _field_type_info,
20
    _format_scalar_default,
21
    _parse_default_dimensions,
22
    _reject_runtime_dimension_lengths,
23
)
24
from .validate import validate_schema_defaults
1✔
25

26
_MISSING = object()
1✔
27

28

29
def generate_template(
1✔
30
    schemas: Iterable[dict[str, Any]],
31
    output: str | Path,
32
    *,
33
    doc_mode: str = "plain",
34
    value_mode: str = "empty",
35
    constants: dict[str, int] | None = None,
36
    dimensions: dict[str, int] | None = None,
37
    kind_map: dict[str, str] | None = None,
38
    kind_allowlist: set[str] | None = None,
39
    values: dict[str, dict[str, Any]] | None = None,
40
) -> None:
41
    """Generate a template namelist file for *schemas* at *output*."""
42
    rendered = render_template(
1✔
43
        schemas,
44
        doc_mode=doc_mode,
45
        value_mode=value_mode,
46
        constants=constants,
47
        dimensions=dimensions,
48
        kind_map=kind_map,
49
        kind_allowlist=kind_allowlist,
50
        values=values,
51
    )
52
    output_path = Path(output)
1✔
53
    output_path.parent.mkdir(parents=True, exist_ok=True)
1✔
54
    output_path.write_text(rendered, encoding="ascii")
1✔
55

56

57
def render_template(
1✔
58
    schemas: Iterable[dict[str, Any]],
59
    *,
60
    doc_mode: str = "plain",
61
    value_mode: str = "empty",
62
    constants: dict[str, int] | None = None,
63
    dimensions: dict[str, int] | None = None,
64
    kind_map: dict[str, str] | None = None,
65
    kind_allowlist: set[str] | None = None,
66
    values: dict[str, dict[str, Any]] | None = None,
67
) -> str:
68
    """Render a template namelist file for *schemas*."""
69
    doc_mode = doc_mode.strip().lower()
1✔
70
    value_mode = value_mode.strip().lower()
1✔
71
    if doc_mode not in {"plain", "documented"}:
1✔
72
        raise ValueError("template doc_mode must be 'plain' or 'documented'")
×
73
    if value_mode not in {"empty", "filled", "minimal-empty", "minimal-filled"}:
1✔
74
        raise ValueError(
×
75
            "template value_mode must be one of: empty, filled, minimal-empty, minimal-filled"
76
        )
77

78
    return _render_template(
1✔
79
        schemas,
80
        doc_mode=doc_mode,
81
        value_mode=value_mode,
82
        constants=constants,
83
        dimensions=dimensions,
84
        kind_map=kind_map,
85
        kind_allowlist=kind_allowlist,
86
        values=values,
87
    )
88

89

90
def _render_template(
1✔
91
    schemas: Iterable[dict[str, Any]],
92
    *,
93
    doc_mode: str,
94
    value_mode: str,
95
    constants: dict[str, int] | None,
96
    dimensions: dict[str, int] | None,
97
    kind_map: dict[str, str] | None,
98
    kind_allowlist: set[str] | None,
99
    values: dict[str, dict[str, Any]] | None,
100
) -> str:
101
    lines: list[str] = []
1✔
102
    schemas_list = list(schemas)
1✔
103
    values_map = values or {}
1✔
104
    if not isinstance(values_map, dict):
1✔
105
        raise ValueError("template values must be a table of namelist tables")
×
106
    constants = normalize_constant_values(constants)
1✔
107
    dimensions = normalize_runtime_dimensions(dimensions)
1✔
108
    reject_constant_dimension_overlap(constants, dimensions)
1✔
109
    shape_constants: dict[str, int] = {
1✔
110
        **constants,
111
        **dimensions,
112
    }
113

114
    schema_by_name: dict[str, dict[str, Any]] = {}
1✔
115
    for schema in schemas_list:
1✔
116
        validate_schema_defaults(schema, constants=constants, dimensions=dimensions)
1✔
117
        namelist_name = schema.get("x-fortran-namelist")
1✔
118
        if not isinstance(namelist_name, str):
1✔
119
            raise ValueError("schema must define 'x-fortran-namelist'")
×
120
        schema_by_name[namelist_name] = schema
1✔
121

122
    for namelist_name, namelist_values in values_map.items():
1✔
123
        if not isinstance(namelist_name, str):
1✔
124
            raise ValueError("template values namelist names must be strings")
×
125
        if namelist_name not in schema_by_name:
1✔
126
            raise ValueError(f"template values namelist '{namelist_name}' not found in schemas")
×
127
        if not isinstance(namelist_values, dict):
1✔
128
            raise ValueError(f"template values for namelist '{namelist_name}' must be a table")
×
129
        properties = schema_by_name[namelist_name].get("properties")
1✔
130
        if not isinstance(properties, dict) or not properties:
1✔
131
            raise ValueError("schema must define object 'properties'")
×
132
        for key in namelist_values:
1✔
133
            if not isinstance(key, str):
1✔
134
                raise ValueError(
×
135
                    f"template values for namelist '{namelist_name}' must use string keys"
136
                )
137
            if key not in properties:
1✔
138
                raise ValueError(
×
139
                    f"template values for namelist '{namelist_name}' include unknown field '{key}'"
140
                )
141

142
    for schema in schemas_list:
1✔
143
        namelist_name = schema.get("x-fortran-namelist")
1✔
144
        if not isinstance(namelist_name, str):
1✔
145
            raise ValueError("schema must define 'x-fortran-namelist'")
×
146
        if schema.get("type") != "object":
1✔
147
            raise ValueError("schema root must be of type 'object'")
×
148
        properties = schema.get("properties")
1✔
149
        if not isinstance(properties, dict) or not properties:
1✔
150
            raise ValueError("schema must define object 'properties'")
×
151
        override_values = (
1✔
152
            values_map.get(namelist_name, {}) if value_mode in {"filled", "minimal-filled"} else {}
153
        )
154

155
        if doc_mode == "documented":
1✔
156
            title = schema.get("title")
1✔
157
            if isinstance(title, str) and title.strip():
1✔
158
                lines.append(f"! {title.strip()}")
1✔
159

160
        lines.append(f"&{namelist_name}")
1✔
161

162
        current_property: str | None = None
1✔
163
        try:
1✔
164
            for name, prop in properties.items():
1✔
165
                current_property = name
1✔
166
                if not isinstance(prop, dict):
1✔
167
                    raise ValueError(f"property '{name}' must be an object")
×
168
                _reject_runtime_dimension_lengths(prop, dimensions)
1✔
169
                type_info = _field_type_info(prop, constants)
1✔
170
                _collect_dimension_constants(type_info.dimensions, shape_constants)
1✔
171
                _validate_kind_allowlist(type_info, kind_map, kind_allowlist)
1✔
172

173
                if type_info.category == "array":
1✔
174
                    has_default = _array_default_value(prop) is not None
1✔
175
                else:
176
                    has_default = "default" in prop
1✔
177
                has_override = name in override_values
1✔
178
                if value_mode in {"minimal-empty", "minimal-filled"} and has_default:
1✔
179
                    if value_mode == "minimal-filled" and has_override:
1✔
180
                        pass
1✔
181
                    else:
182
                        continue
1✔
183

184
                if doc_mode == "documented":
1✔
185
                    title = prop.get("title")
1✔
186
                    if isinstance(title, str) and title.strip():
1✔
187
                        lines.append(f"  ! {title.strip()}")
1✔
188

189
                entries = _value_entries(
1✔
190
                    name,
191
                    prop,
192
                    type_info,
193
                    value_mode=value_mode,
194
                    override=override_values.get(name, _MISSING),
195
                    constants=shape_constants,
196
                )
197
                for entry_name, value_text in entries:
1✔
198
                    if value_text is None:
1✔
199
                        lines.append(f"  {entry_name} =")
×
200
                    else:
201
                        lines.append(f"  {entry_name} = {value_text}")
1✔
202
        except ValueError as exc:
1✔
203
            if current_property is None:
1✔
204
                raise
×
205
            msg = str(exc)
1✔
206
            if f"property '{current_property}'" in msg:
1✔
207
                raise
×
208
            raise ValueError(f"property '{current_property}': {msg}") from exc
1✔
209

210
        lines.append("/")
1✔
211
        lines.append("")
1✔
212

213
    if lines and lines[-1] == "":
1✔
214
        lines.pop()
1✔
215
    return "\n".join(lines) + "\n"
1✔
216

217

218
def _array_slice(rank: int) -> str:
1✔
219
    return "(" + ",".join(":" for _ in range(rank)) + ")"
1✔
220

221

222
def _value_entries(
1✔
223
    name: str,
224
    prop: dict[str, Any],
225
    type_info: FieldTypeInfo,
226
    *,
227
    value_mode: str,
228
    override: Any,
229
    constants: dict[str, int] | None,
230
) -> list[tuple[str, str | None]]:
231
    derived = _derived_schema(prop)
1✔
232
    if derived is not None:
1✔
233
        return _derived_value_entries(
1✔
234
            name,
235
            prop,
236
            derived,
237
            type_info,
238
            value_mode=value_mode,
239
            override=override,
240
            constants=constants,
241
        )
242
    if value_mode in {"empty", "minimal-empty"}:
1✔
243
        entry_name = name
×
244
        if type_info.category == "array":
×
245
            entry_name = f"{name}{_array_slice(len(type_info.dimensions))}"
×
246
        return [(entry_name, None)]
×
247

248
    enum_values = _enum_values(prop, type_info, constants)
1✔
249

250
    if override is not _MISSING:
1✔
251
        return _entries_from_value(name, override, type_info, prop, constants)
1✔
252

253
    example_value = _get_first_example(prop, type_info)
1✔
254
    if example_value is not None:
1✔
255
        return _entries_from_value(name, example_value, type_info, prop, constants)
×
256

257
    if type_info.category == "array":
1✔
258
        array_default = _array_default_value(prop)
1✔
259
        if array_default is not None:
1✔
260
            default_value, default_from_items = array_default
1✔
261
            if default_from_items and isinstance(default_value, list):
1✔
262
                raise ValueError("array items default must be a scalar")
×
263
            if isinstance(default_value, list):
1✔
264
                return _array_list_entries(name, default_value, type_info, prop, constants)
1✔
265
            scalar = _format_scalar_default(
1✔
266
                default_value,
267
                None,
268
                type_info.element_category,
269
            )
270
            entry_name = f"{name}{_array_slice(len(type_info.dimensions))}"
1✔
271
            return [(entry_name, scalar)]
1✔
272
    elif "default" in prop:
1✔
273
        default_value = prop["default"]
1✔
274
        scalar = _format_scalar_default(default_value, None, type_info.category)
1✔
275
        return [(name, scalar)]
1✔
276

277
    if enum_values:
1✔
278
        return _entries_from_value(name, enum_values[0], type_info, prop, constants)
1✔
279

280
    if type_info.category == "array":
1✔
281
        scalar = _format_scalar_default(
1✔
282
            _fallback_scalar_value(type_info),
283
            None,
284
            type_info.element_category,
285
        )
286
        entry_name = f"{name}{_array_slice(len(type_info.dimensions))}"
1✔
287
        return [(entry_name, scalar)]
1✔
288

289
    scalar = _format_scalar_default(
1✔
290
        _fallback_scalar_value(type_info),
291
        None,
292
        type_info.category,
293
    )
294
    return [(name, scalar)]
1✔
295

296

297
def _derived_value_entries(
1✔
298
    name: str,
299
    prop: dict[str, Any],
300
    derived: dict[str, Any],
301
    type_info: FieldTypeInfo,
302
    *,
303
    value_mode: str,
304
    override: Any,
305
    constants: dict[str, int] | None,
306
) -> list[tuple[str, str | None]]:
307
    properties = derived.get("properties")
1✔
308
    if not isinstance(properties, dict) or not properties:
1✔
NEW
309
        raise ValueError(f"derived property '{name}' must define properties")
×
310
    is_array = type_info.category == "array"
1✔
311
    entries: list[tuple[str, str | None]] = []
1✔
312
    if is_array and override is not _MISSING:
1✔
313
        if not isinstance(override, list):
1✔
NEW
314
            raise ValueError(f"derived array template value '{name}' must be an array of tables")
×
315
        for index, value in enumerate(override, start=1):
1✔
316
            if not isinstance(value, dict):
1✔
NEW
317
                raise ValueError(
×
318
                    f"derived array template value '{name}[{index}]' must be a table"
319
                )
320
            value_map = _as_str_keyed_table(
1✔
321
                value,
322
                f"derived array template value '{name}[{index}]'",
323
            )
324
            entries.extend(
1✔
325
                _derived_component_entries(
326
                    f"{name}({index})",
327
                    properties,
328
                    value_mode=value_mode,
329
                    overrides=value_map,
330
                    constants=constants,
331
                )
332
            )
333
        return entries
1✔
334
    if override is not _MISSING and not isinstance(override, dict):
1✔
NEW
335
        raise ValueError(f"derived template value '{name}' must be a table")
×
336
    override_map: dict[str, Any] = {}
1✔
337
    if override is not _MISSING:
1✔
338
        override_map = _as_str_keyed_table(override, f"derived template value '{name}'")
1✔
339
    prefix = name
1✔
340
    if is_array:
1✔
341
        prefix = f"{name}{_array_slice(len(type_info.dimensions))}"
1✔
342
    return _derived_component_entries(
1✔
343
        prefix,
344
        properties,
345
        value_mode=value_mode,
346
        overrides=override_map,
347
        constants=constants,
348
    )
349

350

351
def _as_str_keyed_table(value: object, context: str) -> dict[str, Any]:
1✔
352
    if not isinstance(value, dict):
1✔
NEW
353
        raise ValueError(f"{context} must be a table")
×
354
    table: dict[str, Any] = {}
1✔
355
    for key, entry in value.items():
1✔
356
        if not isinstance(key, str):
1✔
NEW
357
            raise ValueError(f"{context} must use string keys")
×
358
        table[key] = entry
1✔
359
    return table
1✔
360

361

362
def _derived_component_entries(
1✔
363
    prefix: str,
364
    properties: dict[str, Any],
365
    *,
366
    value_mode: str,
367
    overrides: dict[str, Any],
368
    constants: dict[str, int] | None,
369
) -> list[tuple[str, str | None]]:
370
    for key in overrides:
1✔
371
        if key not in properties:
1✔
NEW
372
            raise ValueError(f"derived template value '{prefix}%{key}' is unknown")
×
373
    entries: list[tuple[str, str | None]] = []
1✔
374
    for child_name, child in properties.items():
1✔
375
        if not isinstance(child_name, str) or not isinstance(child, dict):
1✔
NEW
376
            raise ValueError(f"derived property '{prefix}' components must be schema objects")
×
377
        child_type = _field_type_info(child, constants)
1✔
378
        entries.extend(
1✔
379
            _value_entries(
380
                f"{prefix}%{child_name}",
381
                child,
382
                child_type,
383
                value_mode=value_mode,
384
                override=overrides.get(child_name, _MISSING),
385
                constants=constants,
386
            )
387
        )
388
    return entries
1✔
389

390

391
def _array_list_entries(
1✔
392
    name: str,
393
    values: list[Any],
394
    type_info: FieldTypeInfo,
395
    prop: dict[str, Any],
396
    constants: dict[str, int] | None,
397
) -> list[tuple[str, str | None]]:
398
    rank = len(type_info.dimensions)
1✔
399
    if rank <= 1:
1✔
400
        entry_name = f"{name}{_array_slice(rank)}"
1✔
401
        return [(entry_name, _format_value_list(values, type_info))]
1✔
402

403
    dims = _parse_default_dimensions(type_info.dimensions, constants)
1✔
404
    order = _resolve_default_order(prop)
1✔
405
    slice_dim = _slice_dim_for_order(rank, order)
1✔
406
    slice_size = dims[slice_dim]
1✔
407
    fixed_dims = [dims[idx] for idx in range(rank) if idx != slice_dim]
1✔
408
    entries: list[tuple[str, str | None]] = []
1✔
409
    offset = 0
1✔
410
    for fixed_indices in _iter_fixed_indices(fixed_dims, order):
1✔
411
        if offset >= len(values):
1✔
412
            break
×
413
        chunk = values[offset : offset + slice_size]
1✔
414
        if not chunk:
1✔
415
            break
×
416
        entry_name = _slice_entry_name(
1✔
417
            name,
418
            rank,
419
            slice_dim,
420
            [index + 1 for index in fixed_indices],
421
        )
422
        entries.append((entry_name, _format_value_list(chunk, type_info)))
1✔
423
        offset += slice_size
1✔
424
    if not entries:
1✔
425
        entry_name = f"{name}{_array_slice(rank)}"
×
426
        entries.append((entry_name, _format_value_list(values, type_info)))
×
427
    return entries
1✔
428

429

430
def _slice_dim_for_order(rank: int, order: str) -> int:
1✔
431
    if order == "F":
1✔
432
        return 0
1✔
433
    return rank - 1
×
434

435

436
def _iter_fixed_indices(dims: list[int], order: str) -> Iterable[list[int]]:
1✔
437
    if not dims:
1✔
438
        yield []
×
439
        return
×
440
    indices = [0] * len(dims)
1✔
441
    while True:
1✔
442
        yield indices.copy()
1✔
443
        positions = range(len(dims)) if order == "F" else range(len(dims) - 1, -1, -1)
1✔
444
        for pos in positions:
1✔
445
            indices[pos] += 1
1✔
446
            if indices[pos] < dims[pos]:
1✔
447
                break
1✔
448
            indices[pos] = 0
1✔
449
        else:
450
            break
1✔
451

452

453
def _slice_entry_name(
1✔
454
    name: str,
455
    rank: int,
456
    slice_dim: int,
457
    fixed_indices: list[int],
458
) -> str:
459
    indices: list[str] = []
1✔
460
    fixed_iter = iter(fixed_indices)
1✔
461
    for idx in range(rank):
1✔
462
        if idx == slice_dim:
1✔
463
            indices.append(":")
1✔
464
        else:
465
            indices.append(str(next(fixed_iter)))
1✔
466
    return f"{name}({','.join(indices)})"
1✔
467

468

469
def _format_value_list(values: list[Any], type_info: FieldTypeInfo) -> str:
1✔
470
    formatted = [
1✔
471
        _format_scalar_default(value, None, type_info.element_category) for value in values
472
    ]
473
    return ", ".join(formatted)
1✔
474

475

476
def _entries_from_value(
1✔
477
    name: str,
478
    value: Any,
479
    type_info: FieldTypeInfo,
480
    prop: dict[str, Any],
481
    constants: dict[str, int] | None,
482
) -> list[tuple[str, str | None]]:
483
    if type_info.category == "array":
1✔
484
        if isinstance(value, list):
×
485
            _validate_example_list(value)
×
486
            return _array_list_entries(name, value, type_info, prop, constants)
×
487
        scalar = _format_scalar_default(value, None, type_info.element_category)
×
488
        entry_name = f"{name}{_array_slice(len(type_info.dimensions))}"
×
489
        return [(entry_name, scalar)]
×
490
    if isinstance(value, list):
1✔
491
        raise ValueError("scalar template values must not be lists")
×
492
    scalar = _format_scalar_default(value, None, type_info.category)
1✔
493
    return [(name, scalar)]
1✔
494

495

496
def _get_first_example(prop: dict[str, Any], type_info: FieldTypeInfo) -> Any | None:
1✔
497
    examples = prop.get("examples")
1✔
498
    if examples is None:
1✔
499
        return None
1✔
500
    if not isinstance(examples, list):
×
501
        raise ValueError("property examples must be a list")
×
502
    if not examples:
×
503
        return None
×
504
    first = examples[0]
×
505
    if type_info.category == "array":
×
506
        if isinstance(first, list):
×
507
            _validate_example_list(first)
×
508
        return first
×
509
    if isinstance(first, list):
×
510
        raise ValueError("scalar examples must not be lists")
×
511
    return first
×
512

513

514
def _validate_example_list(values: list[Any]) -> None:
1✔
515
    for value in values:
×
516
        if isinstance(value, list):
×
517
            raise ValueError("array examples must be flat lists")
×
518

519

520
def _fallback_scalar_value(type_info: FieldTypeInfo) -> Any:
1✔
521
    category = type_info.element_category if type_info.category == "array" else type_info.category
1✔
522
    if category == "integer":
1✔
523
        return 0
1✔
524
    if category == "real":
1✔
525
        return 0.0
1✔
526
    if category == "boolean":
×
527
        return False
×
528
    if category == "string":
×
529
        return ""
×
530
    raise ValueError(f"unsupported template category '{category}'")
×
531

532

533
def _validate_kind_allowlist(
1✔
534
    type_info: FieldTypeInfo,
535
    kind_map: dict[str, str] | None,
536
    allowlist: set[str] | None,
537
) -> None:
538
    if type_info.kind is None or allowlist is None:
1✔
539
        return
1✔
540
    kind_id = type_info.kind
1✔
541
    mapped = None
1✔
542
    if kind_map is not None and kind_id in kind_map:
1✔
543
        mapped = kind_map[kind_id]
1✔
544
        if not isinstance(mapped, str) or not mapped:
1✔
545
            raise ValueError(f"kind map target for '{kind_id}' must be a string")
×
546
        if mapped not in allowlist:
1✔
547
            raise ValueError(
×
548
                f"kind map target '{mapped}' for '{kind_id}' not present in kind module list"
549
            )
550
        return
1✔
551
    if kind_id not in allowlist:
×
552
        raise ValueError(f"kind '{kind_id}' not present in kind module list")
×
553

554

555
def _resolve_default_order(prop: dict[str, Any]) -> str:
1✔
556
    order_raw = prop.get("x-fortran-default-order", "F")
1✔
557
    if not isinstance(order_raw, str):
1✔
558
        raise ValueError("array default order must be 'F' or 'C'")
×
559
    order = order_raw.upper()
1✔
560
    if order not in {"F", "C"}:
1✔
561
        raise ValueError("array default order must be 'F' or 'C'")
×
562
    return order
1✔
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