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

MuellerSeb / nml-tools / 26169996067

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

Pull #27

github

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

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

4 existing lines in 2 files now uncovered.

2663 of 3356 relevant lines covered (79.35%)

0.79 hits per line

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

69.73
/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 .codegen_fortran import (
1✔
9
    FieldTypeInfo,
10
    _array_default_value,
11
    _collect_dimension_constants,
12
    _enum_values,
13
    _field_type_info,
14
    _format_scalar_default,
15
    _normalize_constant_values,
16
    _parse_default_dimensions,
17
    _reject_runtime_dimension_lengths,
18
    _validate_runtime_dimensions,
19
)
20

21
_MISSING = object()
1✔
22

23

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

51

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

73
    return _render_template(
1✔
74
        schemas,
75
        doc_mode=doc_mode,
76
        value_mode=value_mode,
77
        constants=constants,
78
        dimensions=dimensions,
79
        kind_map=kind_map,
80
        kind_allowlist=kind_allowlist,
81
        values=values,
82
    )
83

84

85
def _render_template(
1✔
86
    schemas: Iterable[dict[str, Any]],
87
    *,
88
    doc_mode: str,
89
    value_mode: str,
90
    constants: dict[str, int] | None,
91
    dimensions: dict[str, int] | None,
92
    kind_map: dict[str, str] | None,
93
    kind_allowlist: set[str] | None,
94
    values: dict[str, dict[str, Any]] | None,
95
) -> str:
96
    lines: list[str] = []
1✔
97
    schemas_list = list(schemas)
1✔
98
    values_map = values or {}
1✔
99
    if not isinstance(values_map, dict):
1✔
100
        raise ValueError("template values must be a table of namelist tables")
×
101
    constants = _normalize_constant_values(constants)
1✔
102
    dimensions = _validate_runtime_dimensions(dimensions)
1✔
103
    overlap = sorted(set(constants) & set(dimensions))
1✔
104
    if overlap:
1✔
NEW
105
        raise ValueError(
×
106
            "constants and dimensions must not share names: " + ", ".join(overlap)
107
        )
108
    shape_constants: dict[str, int] = {
1✔
109
        **constants,
110
        **dimensions,
111
    }
112

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

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

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

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

158
        lines.append(f"&{namelist_name}")
1✔
159

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

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

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

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

208
        lines.append("/")
1✔
209
        lines.append("")
1✔
210

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

215

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

219

220
def _value_entries(
1✔
221
    name: str,
222
    prop: dict[str, Any],
223
    type_info: FieldTypeInfo,
224
    *,
225
    value_mode: str,
226
    override: Any,
227
    constants: dict[str, int] | None,
228
) -> list[tuple[str, str | None]]:
229
    if value_mode in {"empty", "minimal-empty"}:
1✔
230
        entry_name = name
×
231
        if type_info.category == "array":
×
232
            entry_name = f"{name}{_array_slice(len(type_info.dimensions))}"
×
233
        return [(entry_name, None)]
×
234

235
    enum_values = _enum_values(prop, type_info, constants)
1✔
236

237
    if override is not _MISSING:
1✔
238
        return _entries_from_value(name, override, type_info, prop, constants)
1✔
239

240
    example_value = _get_first_example(prop, type_info)
1✔
241
    if example_value is not None:
1✔
242
        return _entries_from_value(name, example_value, type_info, prop, constants)
×
243

244
    if type_info.category == "array":
1✔
245
        array_default = _array_default_value(prop)
1✔
246
        if array_default is not None:
1✔
247
            default_value, default_from_items = array_default
1✔
248
            if default_from_items and isinstance(default_value, list):
1✔
249
                raise ValueError("array items default must be a scalar")
×
250
            if isinstance(default_value, list):
1✔
251
                return _array_list_entries(name, default_value, type_info, prop, constants)
1✔
252
            scalar = _format_scalar_default(
1✔
253
                default_value,
254
                None,
255
                type_info.element_category,
256
            )
257
            entry_name = f"{name}{_array_slice(len(type_info.dimensions))}"
1✔
258
            return [(entry_name, scalar)]
1✔
259
    elif "default" in prop:
1✔
260
        default_value = prop["default"]
1✔
261
        scalar = _format_scalar_default(default_value, None, type_info.category)
1✔
262
        return [(name, scalar)]
1✔
263

264
    if enum_values:
1✔
265
        return _entries_from_value(name, enum_values[0], type_info, prop, constants)
1✔
266

267
    if type_info.category == "array":
1✔
268
        scalar = _format_scalar_default(
1✔
269
            _fallback_scalar_value(type_info),
270
            None,
271
            type_info.element_category,
272
        )
273
        entry_name = f"{name}{_array_slice(len(type_info.dimensions))}"
1✔
274
        return [(entry_name, scalar)]
1✔
275

276
    scalar = _format_scalar_default(
1✔
277
        _fallback_scalar_value(type_info),
278
        None,
279
        type_info.category,
280
    )
281
    return [(name, scalar)]
1✔
282

283

284
def _array_list_entries(
1✔
285
    name: str,
286
    values: list[Any],
287
    type_info: FieldTypeInfo,
288
    prop: dict[str, Any],
289
    constants: dict[str, int] | None,
290
) -> list[tuple[str, str | None]]:
291
    rank = len(type_info.dimensions)
1✔
292
    if rank <= 1:
1✔
293
        entry_name = f"{name}{_array_slice(rank)}"
×
294
        return [(entry_name, _format_value_list(values, type_info))]
×
295

296
    dims = _parse_default_dimensions(type_info.dimensions, constants)
1✔
297
    order = _resolve_default_order(prop)
1✔
298
    slice_dim = _slice_dim_for_order(rank, order)
1✔
299
    slice_size = dims[slice_dim]
1✔
300
    fixed_dims = [dims[idx] for idx in range(rank) if idx != slice_dim]
1✔
301
    entries: list[tuple[str, str | None]] = []
1✔
302
    offset = 0
1✔
303
    for fixed_indices in _iter_fixed_indices(fixed_dims, order):
1✔
304
        if offset >= len(values):
1✔
305
            break
×
306
        chunk = values[offset : offset + slice_size]
1✔
307
        if not chunk:
1✔
308
            break
×
309
        entry_name = _slice_entry_name(
1✔
310
            name,
311
            rank,
312
            slice_dim,
313
            [index + 1 for index in fixed_indices],
314
        )
315
        entries.append((entry_name, _format_value_list(chunk, type_info)))
1✔
316
        offset += slice_size
1✔
317
    if not entries:
1✔
318
        entry_name = f"{name}{_array_slice(rank)}"
×
319
        entries.append((entry_name, _format_value_list(values, type_info)))
×
320
    return entries
1✔
321

322

323
def _slice_dim_for_order(rank: int, order: str) -> int:
1✔
324
    if order == "F":
1✔
325
        return 0
1✔
326
    return rank - 1
×
327

328

329
def _iter_fixed_indices(dims: list[int], order: str) -> Iterable[list[int]]:
1✔
330
    if not dims:
1✔
331
        yield []
×
332
        return
×
333
    indices = [0] * len(dims)
1✔
334
    while True:
1✔
335
        yield indices.copy()
1✔
336
        positions = range(len(dims)) if order == "F" else range(len(dims) - 1, -1, -1)
1✔
337
        for pos in positions:
1✔
338
            indices[pos] += 1
1✔
339
            if indices[pos] < dims[pos]:
1✔
340
                break
1✔
341
            indices[pos] = 0
1✔
342
        else:
343
            break
1✔
344

345

346
def _slice_entry_name(
1✔
347
    name: str,
348
    rank: int,
349
    slice_dim: int,
350
    fixed_indices: list[int],
351
) -> str:
352
    indices: list[str] = []
1✔
353
    fixed_iter = iter(fixed_indices)
1✔
354
    for idx in range(rank):
1✔
355
        if idx == slice_dim:
1✔
356
            indices.append(":")
1✔
357
        else:
358
            indices.append(str(next(fixed_iter)))
1✔
359
    return f"{name}({','.join(indices)})"
1✔
360

361

362
def _format_value_list(values: list[Any], type_info: FieldTypeInfo) -> str:
1✔
363
    formatted = [
1✔
364
        _format_scalar_default(value, None, type_info.element_category) for value in values
365
    ]
366
    return ", ".join(formatted)
1✔
367

368

369
def _entries_from_value(
1✔
370
    name: str,
371
    value: Any,
372
    type_info: FieldTypeInfo,
373
    prop: dict[str, Any],
374
    constants: dict[str, int] | None,
375
) -> list[tuple[str, str | None]]:
376
    if type_info.category == "array":
1✔
377
        if isinstance(value, list):
×
378
            _validate_example_list(value)
×
379
            return _array_list_entries(name, value, type_info, prop, constants)
×
380
        scalar = _format_scalar_default(value, None, type_info.element_category)
×
381
        entry_name = f"{name}{_array_slice(len(type_info.dimensions))}"
×
382
        return [(entry_name, scalar)]
×
383
    if isinstance(value, list):
1✔
384
        raise ValueError("scalar template values must not be lists")
×
385
    scalar = _format_scalar_default(value, None, type_info.category)
1✔
386
    return [(name, scalar)]
1✔
387

388

389
def _get_first_example(prop: dict[str, Any], type_info: FieldTypeInfo) -> Any | None:
1✔
390
    examples = prop.get("examples")
1✔
391
    if examples is None:
1✔
392
        return None
1✔
393
    if not isinstance(examples, list):
×
394
        raise ValueError("property examples must be a list")
×
395
    if not examples:
×
396
        return None
×
397
    first = examples[0]
×
398
    if type_info.category == "array":
×
399
        if isinstance(first, list):
×
400
            _validate_example_list(first)
×
401
        return first
×
402
    if isinstance(first, list):
×
403
        raise ValueError("scalar examples must not be lists")
×
404
    return first
×
405

406

407
def _validate_example_list(values: list[Any]) -> None:
1✔
408
    for value in values:
×
409
        if isinstance(value, list):
×
410
            raise ValueError("array examples must be flat lists")
×
411

412

413
def _fallback_scalar_value(type_info: FieldTypeInfo) -> Any:
1✔
414
    category = type_info.element_category if type_info.category == "array" else type_info.category
1✔
415
    if category == "integer":
1✔
416
        return 0
1✔
417
    if category == "real":
1✔
418
        return 0.0
1✔
419
    if category == "boolean":
×
420
        return False
×
421
    if category == "string":
×
422
        return ""
×
423
    raise ValueError(f"unsupported template category '{category}'")
×
424

425

426
def _validate_kind_allowlist(
1✔
427
    type_info: FieldTypeInfo,
428
    kind_map: dict[str, str] | None,
429
    allowlist: set[str] | None,
430
) -> None:
431
    if type_info.kind is None or allowlist is None:
1✔
432
        return
1✔
433
    kind_id = type_info.kind
×
434
    mapped = None
×
435
    if kind_map is not None and kind_id in kind_map:
×
436
        mapped = kind_map[kind_id]
×
437
        if not isinstance(mapped, str) or not mapped:
×
438
            raise ValueError(f"kind map target for '{kind_id}' must be a string")
×
439
        if mapped not in allowlist:
×
440
            raise ValueError(
×
441
                f"kind map target '{mapped}' for '{kind_id}' not present in kind module list"
442
            )
443
        return
×
444
    if kind_id not in allowlist:
×
445
        raise ValueError(f"kind '{kind_id}' not present in kind module list")
×
446

447

448
def _resolve_default_order(prop: dict[str, Any]) -> str:
1✔
449
    order_raw = prop.get("x-fortran-default-order", "F")
1✔
450
    if not isinstance(order_raw, str):
1✔
451
        raise ValueError("array default order must be 'F' or 'C'")
×
452
    order = order_raw.upper()
1✔
453
    if order not in {"F", "C"}:
1✔
454
        raise ValueError("array default order must be 'F' or 'C'")
×
455
    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