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

MuellerSeb / nml-tools / 26189498331

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

Pull #27

github

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

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

3 existing lines in 2 files now uncovered.

2591 of 3277 relevant lines covered (79.07%)

0.79 hits per line

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

71.91
/src/nml_tools/codegen_markdown.py
1
"""Markdown documentation generation."""
2

3
from __future__ import annotations
1✔
4

5
import re
1✔
6
import string
1✔
7
from pathlib import Path
1✔
8
from typing import Any
1✔
9

10
from .codegen_fortran import (
1✔
11
    FieldTypeInfo,
12
    _array_default_value,
13
    _bounds_spec,
14
    _collect_dimension_constants,
15
    _enum_category,
16
    _enum_values,
17
    _field_type_info,
18
    _format_scalar_default,
19
    _normalize_constant_values,
20
    _parse_default_dimensions,
21
    _prepare_array_default,
22
    _reject_runtime_dimension_lengths,
23
    _validate_runtime_dimensions,
24
)
25
from .codegen_template import render_template
1✔
26

27
_DEFAULT_MISSING = object()
1✔
28

29

30
def generate_docs(
1✔
31
    schema: dict[str, Any],
32
    output: str | Path,
33
    *,
34
    constants: dict[str, int] | None = None,
35
    dimensions: dict[str, int] | None = None,
36
    md_doxygen_id_from_name: bool = False,
37
    md_add_toc_statement: bool = False,
38
) -> None:
39
    """Generate Markdown docs for *schema* at *output*."""
40
    rendered = render_docs(
1✔
41
        schema,
42
        constants=constants,
43
        dimensions=dimensions,
44
        md_doxygen_id_from_name=md_doxygen_id_from_name,
45
        md_add_toc_statement=md_add_toc_statement,
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_docs(
1✔
53
    schema: dict[str, Any],
54
    *,
55
    constants: dict[str, int] | None = None,
56
    dimensions: dict[str, int] | None = None,
57
    md_doxygen_id_from_name: bool = False,
58
    md_add_toc_statement: bool = False,
59
) -> str:
60
    """Render Markdown docs for *schema*."""
61
    namelist_name = schema.get("x-fortran-namelist")
1✔
62
    if not isinstance(namelist_name, str):
1✔
63
        raise ValueError("schema must define 'x-fortran-namelist'")
×
64

65
    if schema.get("type") != "object":
1✔
66
        raise ValueError("schema root must be of type 'object'")
×
67

68
    properties = schema.get("properties")
1✔
69
    if not isinstance(properties, dict) or not properties:
1✔
70
        raise ValueError("schema must define object 'properties'")
×
71

72
    title = schema.get("title", namelist_name)
1✔
73
    if not isinstance(title, str):
1✔
74
        raise ValueError("schema title must be a string")
×
75
    description = schema.get("description")
1✔
76
    if description is not None and not isinstance(description, str):
1✔
77
        raise ValueError("schema description must be a string")
×
78

79
    required_raw = schema.get("required", [])
1✔
80
    if required_raw is None:
1✔
81
        required_raw = []
×
82
    if not isinstance(required_raw, list):
1✔
83
        raise ValueError("schema 'required' must be a list")
×
84
    required_set = _validate_required(required_raw)
1✔
85
    constants = _normalize_constant_values(constants)
1✔
86
    dimensions = _validate_runtime_dimensions(dimensions)
1✔
87
    overlap = sorted(set(constants) & set(dimensions))
1✔
88
    if overlap:
1✔
NEW
89
        raise ValueError(
×
90
            "constants and dimensions must not share names: " + ", ".join(overlap)
91
        )
92
    shape_constants: dict[str, int] = {
1✔
93
        **constants,
94
        **dimensions,
95
    }
96

97
    title_line = f"# {title}"
1✔
98
    if md_doxygen_id_from_name:
1✔
99
        title_line = f"{title_line} {{#{namelist_name}}}"
1✔
100
    lines = [title_line, ""]
1✔
101
    if md_add_toc_statement:
1✔
102
        lines.append("[TOC]")
1✔
103
        lines.append("")
1✔
104
    if description:
1✔
105
        lines.append(description)
×
106
        lines.append("")
×
107
    lines.append(f"**Namelist**: `{namelist_name}`")
1✔
108
    lines.append("")
1✔
109
    lines.append("## Fields")
1✔
110
    lines.append("")
1✔
111

112
    header = ["Name", "Type", "Required", "Info"]
1✔
113
    lines.append(f"| {' | '.join(header)} |")
1✔
114
    lines.append(f"| {' | '.join('---' for _ in header)} |")
1✔
115

116
    current_property: str | None = None
1✔
117
    try:
1✔
118
        for name, prop in properties.items():
1✔
119
            current_property = name
1✔
120
            if not isinstance(prop, dict):
1✔
121
                raise ValueError(f"property '{name}' must be an object")
×
122
            _reject_runtime_dimension_lengths(prop, dimensions)
1✔
123
            type_info = _field_type_info(prop, constants)
1✔
124
            _collect_dimension_constants(type_info.dimensions, shape_constants)
1✔
125
            type_label = _format_table_type(type_info)
1✔
126
            info_label = _format_info(prop)
1✔
127
            required_label = "yes" if name in required_set else "no"
1✔
128
            row = [
1✔
129
                f"[{name}](#{_github_section_id(name)})",
130
                type_label,
131
                required_label,
132
                info_label,
133
            ]
134
            lines.append(f"| {' | '.join(_escape_table_cell(cell) for cell in row)} |")
1✔
135
    except ValueError as exc:
1✔
136
        if current_property is None:
1✔
137
            raise
×
138
        msg = str(exc)
1✔
139
        if f"property '{current_property}'" in msg:
1✔
140
            raise
×
141
        raise ValueError(f"property '{current_property}': {msg}") from exc
1✔
142

143
    lines.append("")
1✔
144

145
    lines.append("## Field details")
1✔
146
    lines.append("")
1✔
147

148
    current_property = None
1✔
149
    try:
1✔
150
        for name, prop in properties.items():
1✔
151
            current_property = name
1✔
152
            if not isinstance(prop, dict):
1✔
153
                raise ValueError(f"property '{name}' must be an object")
×
154
            _reject_runtime_dimension_lengths(prop, dimensions)
1✔
155
            type_info = _field_type_info(prop, constants)
1✔
156
            required_label = "yes" if name in required_set else "no"
1✔
157
            default_label = _get_default_value(prop, type_info, shape_constants)
1✔
158
            enum_label = _get_enum_values(prop, type_info, shape_constants)
1✔
159
            example_values = _get_example_values(prop, type_info)
1✔
160
            flex_tail_dims = _get_flex_tail_dims(prop, type_info)
1✔
161
            title = _get_title(prop)
1✔
162
            description_text = _get_description(prop)
1✔
163

164
            # if title:
165
            #     lines.append(f"### `{name}` - {title}")
166
            # else:
167
            #     lines.append(f"### `{name}`")
168
            lines.append(f"### {name}")
1✔
169
            lines.append("")
1✔
170
            if title:
1✔
171
                lines.append(f"{title} `{name}`")
1✔
172
            else:
173
                lines.append(f"`{name}`")
1✔
174
            lines.append("")
1✔
175
            if description_text:
1✔
176
                lines.append(description_text)
×
177
                lines.append("")
×
178

179
            lines.append("Summary:")
1✔
180
            lines.append(f"- Type: `{_format_specific_type(type_info)}`")
1✔
181
            if type_info.category == "array" and flex_tail_dims > 0:
1✔
182
                lines.append(f"- Flexible tail dims: {flex_tail_dims}")
×
183
            lines.append(f"- Required: {required_label}")
1✔
184
            if default_label is not None:
1✔
185
                if isinstance(default_label, tuple):
1✔
186
                    base, note = default_label
1✔
187
                    if note:
1✔
188
                        lines.append(f"- Default: `{base}` {note}")
×
189
                    else:
190
                        lines.append(f"- Default: `{base}`")
1✔
191
                else:
192
                    lines.append(f"- Default: `{default_label}`")
1✔
193
            for bounds_label in _get_bounds_labels(prop, type_info):
1✔
194
                lines.append(bounds_label)
1✔
195
            if enum_label is not None:
1✔
196
                lines.append(f"- Allowed values: {enum_label}")
1✔
197
            if example_values is not None:
1✔
198
                examples_text = ", ".join(f"`{value}`" for value in example_values)
×
199
                lines.append(f"- Examples: {examples_text}")
×
200
            lines.append("")
1✔
201
    except ValueError as exc:
×
202
        if current_property is None:
×
203
            raise
×
204
        msg = str(exc)
×
205
        if f"property '{current_property}'" in msg:
×
206
            raise
×
207
        raise ValueError(f"property '{current_property}': {msg}") from exc
×
208

209
    lines.append("## Example")
1✔
210
    lines.append("")
1✔
211
    lines.append("```fortran")
1✔
212
    filled_template = render_template(
1✔
213
        [schema],
214
        doc_mode="plain",
215
        value_mode="filled",
216
        constants=shape_constants,
217
        kind_map=None,
218
        kind_allowlist=None,
219
    )
220
    lines.extend(filled_template.rstrip("\n").splitlines())
1✔
221
    lines.append("```")
1✔
222
    lines.append("")
1✔
223

224
    return "\n".join(lines) + "\n"
1✔
225

226

227
def _validate_required(values: list[Any]) -> set[str]:
1✔
228
    required: set[str] = set()
1✔
229
    for value in values:
1✔
230
        if not isinstance(value, str):
×
231
            raise ValueError("schema 'required' entries must be strings")
×
232
        required.add(value)
×
233
    return required
1✔
234

235

236
def _format_table_type(type_info: FieldTypeInfo) -> str:
1✔
237
    if type_info.category == "array":
1✔
238
        element = _format_scalar_type_name(type_info.element_category)
1✔
239
        return f"{element} array"
1✔
240
    return _format_scalar_type_name(type_info.category)
1✔
241

242

243
def _format_scalar_type_name(category: str | None) -> str:
1✔
244
    if category == "boolean":
1✔
245
        return "logical"
×
246
    if category == "string":
1✔
247
        return "string"
1✔
248
    if category == "integer":
1✔
249
        return "integer"
1✔
250
    if category == "real":
1✔
251
        return "real"
1✔
252
    raise ValueError(f"unsupported type category '{category}'")
×
253

254

255
def _format_specific_type(type_info: FieldTypeInfo) -> str:
1✔
256
    if type_info.category != "array":
1✔
257
        return type_info.type_spec
1✔
258
    dimensions = ", ".join(type_info.dimensions)
1✔
259
    return f"{type_info.type_spec}, dimension({dimensions})"
1✔
260

261

262
def _get_default_value(
1✔
263
    prop: dict[str, Any],
264
    type_info: FieldTypeInfo,
265
    constants: dict[str, int] | None,
266
) -> str | tuple[str, str | None] | None:
267
    if type_info.category == "array":
1✔
268
        array_default = _array_default_value(prop)
1✔
269
        if array_default is None:
1✔
270
            return None
1✔
271
        default_value, default_from_items = array_default
1✔
272
        return _format_array_default_display(
1✔
273
            prop,
274
            type_info,
275
            constants,
276
            default_value=default_value,
277
            from_items=default_from_items,
278
        )
279
    if "default" not in prop:
1✔
280
        return None
1✔
281
    return _format_default_plain(prop["default"], type_info, prop, constants)
1✔
282

283

284
def _format_info(prop: dict[str, Any]) -> str:
1✔
285
    title = _get_title(prop)
1✔
286
    return title or "n/a"
1✔
287

288

289
def _get_title(prop: dict[str, Any]) -> str | None:
1✔
290
    title = prop.get("title")
1✔
291
    if title is None:
1✔
292
        return None
1✔
293
    if not isinstance(title, str):
1✔
294
        raise ValueError("property title must be a string")
×
295
    title = title.strip()
1✔
296
    return title or None
1✔
297

298

299
def _get_description(prop: dict[str, Any]) -> str | None:
1✔
300
    description = prop.get("description")
1✔
301
    if description is None:
1✔
302
        return None
1✔
303
    if not isinstance(description, str):
×
304
        raise ValueError("property description must be a string")
×
305
    description = description.strip()
×
306
    return description or None
×
307

308

309
def _get_enum_values(
1✔
310
    prop: dict[str, Any],
311
    type_info: FieldTypeInfo,
312
    constants: dict[str, int] | None,
313
) -> str | None:
314
    enum_values = _enum_values(prop, type_info, constants)
1✔
315
    if enum_values is None:
1✔
316
        return None
1✔
317
    category = _enum_category(type_info)
1✔
318
    values = [_format_scalar_default(value, None, category) for value in enum_values]
1✔
319
    return ", ".join(f"`{value}`" for value in values)
1✔
320

321

322
def _get_bounds_labels(
1✔
323
    prop: dict[str, Any],
324
    type_info: FieldTypeInfo,
325
) -> list[str]:
326
    bounds = _bounds_spec(prop, type_info)
1✔
327
    if bounds is None:
1✔
328
        return []
1✔
329
    category = bounds["category"]
1✔
330
    labels: list[str] = []
1✔
331
    min_value = bounds["min_value"]
1✔
332
    max_value = bounds["max_value"]
1✔
333
    if min_value is not None:
1✔
334
        op = ">" if bounds["min_exclusive"] else ">="
1✔
335
        literal = _format_scalar_default(min_value, None, category)
1✔
336
        labels.append(f"- Minimum: `{op} {literal}`")
1✔
337
    if max_value is not None:
1✔
338
        op = "<" if bounds["max_exclusive"] else "<="
1✔
339
        literal = _format_scalar_default(max_value, None, category)
1✔
340
        labels.append(f"- Maximum: `{op} {literal}`")
1✔
341
    return labels
1✔
342

343

344
def _get_flex_tail_dims(
1✔
345
    prop: dict[str, Any],
346
    type_info: FieldTypeInfo,
347
) -> int:
348
    flex_raw = prop.get("x-fortran-flex-tail-dims")
1✔
349
    if flex_raw is None:
1✔
350
        flex_value = 0
1✔
351
    else:
352
        if isinstance(flex_raw, bool) or not isinstance(flex_raw, int):
×
353
            raise ValueError("property flex tail dims must be an integer")
×
354
        flex_value = flex_raw
×
355
    if flex_value < 0:
1✔
356
        raise ValueError("property flex tail dims must be >= 0")
×
357
    if flex_value == 0:
1✔
358
        return 0
1✔
359
    if type_info.category != "array":
×
360
        raise ValueError("flex tail dims only apply to arrays")
×
361
    if flex_value > len(type_info.dimensions):
×
362
        raise ValueError("flex tail dims must not exceed array rank")
×
363
    return flex_value
×
364

365

366
def _escape_table_cell(value: str) -> str:
1✔
367
    escaped = value.replace("|", "\\|").replace("\n", " ").strip()
1✔
368
    return escaped or "n/a"
1✔
369

370

371
def _get_example_values(
1✔
372
    prop: dict[str, Any],
373
    type_info: FieldTypeInfo,
374
) -> list[str] | None:
375
    examples = prop.get("examples")
1✔
376
    if examples is None:
1✔
377
        return None
1✔
378
    if not isinstance(examples, list):
×
379
        raise ValueError("property examples must be a list")
×
380
    if not examples:
×
381
        return None
×
382
    return [_format_example_value(value, type_info) for value in examples]
×
383

384

385
def _format_example_value(value: Any, type_info: FieldTypeInfo) -> str:
1✔
386
    if type_info.category == "array":
×
387
        if isinstance(value, list):
×
388
            formatted = [
×
389
                _format_scalar_default(item, None, type_info.element_category) for item in value
390
            ]
391
            return f"[{', '.join(formatted)}]"
×
392
        return _format_scalar_default(value, None, type_info.element_category)
×
393
    if isinstance(value, list):
×
394
        raise ValueError("scalar examples must not be lists")
×
395
    return _format_scalar_default(value, None, type_info.category)
×
396

397

398
def _format_default_plain(
1✔
399
    value: Any,
400
    type_info: FieldTypeInfo,
401
    prop: dict[str, Any],
402
    constants: dict[str, int] | None,
403
) -> str:
404
    if type_info.category == "array":
1✔
405
        if not isinstance(value, list):
×
406
            raise ValueError("array default must be a list")
×
407
        parsed_dims = _parse_default_dimensions(type_info.dimensions, constants)
×
408
        array_default = _prepare_array_default(value, parsed_dims, prop)
×
409
        elements = [
×
410
            _format_scalar_default(element, None, type_info.element_category)
411
            for element in array_default.source_values
412
        ]
413
        if (
×
414
            len(type_info.dimensions) == 1
415
            and array_default.order_values is None
416
            and array_default.pad_values is None
417
        ):
418
            return f"[{', '.join(elements)}]"
×
419

420
        shape_literal = ", ".join(type_info.dimensions)
×
421
        arguments = [f"[{', '.join(elements)}]", f"shape=[{shape_literal}]"]
×
422

423
        if array_default.order_values is not None:
×
424
            order_literal = ", ".join(str(index) for index in array_default.order_values)
×
425
            arguments.append(f"order=[{order_literal}]")
×
426

427
        if array_default.pad_values is not None:
×
428
            pad_elements = [
×
429
                _format_scalar_default(element, None, type_info.element_category)
430
                for element in array_default.pad_values
431
            ]
432
            arguments.append(f"pad=[{', '.join(pad_elements)}]")
×
433

434
        return f"reshape({', '.join(arguments)})"
×
435

436
    return _format_scalar_default(value, None, type_info.category)
1✔
437

438

439
def _format_array_default_display(
1✔
440
    prop: dict[str, Any],
441
    type_info: FieldTypeInfo,
442
    constants: dict[str, int] | None,
443
    *,
444
    default_value: Any = _DEFAULT_MISSING,
445
    from_items: bool = False,
446
) -> tuple[str, str | None]:
447
    if default_value is _DEFAULT_MISSING:
1✔
448
        default_value = prop["default"]
×
449
    if not from_items and not isinstance(default_value, list):
1✔
450
        raise ValueError("array default must be a list")
×
451
    default_is_list = isinstance(default_value, list)
1✔
452
    default_list = default_value if default_is_list else [default_value]
1✔
453

454
    parsed_dims = _parse_default_dimensions(type_info.dimensions, constants)
1✔
455
    default_prop = prop
1✔
456
    if from_items:
1✔
457
        default_prop = dict(prop)
1✔
458
        default_prop["x-fortran-default-repeat"] = True
1✔
459
    _prepare_array_default(default_list, parsed_dims, default_prop)
1✔
460

461
    if default_is_list:
1✔
462
        elements = [
×
463
            _format_scalar_default(value, None, type_info.element_category)
464
            for value in default_list
465
        ]
466
        base = f"[{', '.join(elements)}]"
×
467
    else:
468
        base = _format_scalar_default(default_value, None, type_info.element_category)
1✔
469

470
    repeat_raw = prop.get("x-fortran-default-repeat", False)
1✔
471
    if not isinstance(repeat_raw, bool):
1✔
472
        raise ValueError("array default repeat must be a boolean")
×
473
    notes: list[str] = []
1✔
474
    if repeat_raw:
1✔
475
        notes.append("repeated")
×
476

477
    order_raw = prop.get("x-fortran-default-order", "F")
1✔
478
    if not isinstance(order_raw, str):
1✔
479
        raise ValueError("array default order must be 'F' or 'C'")
×
480
    order = order_raw.upper()
1✔
481
    if order not in {"F", "C"}:
1✔
482
        raise ValueError("array default order must be 'F' or 'C'")
×
483
    if order == "C":
1✔
484
        notes.append("order: C")
×
485

486
    pad_raw = prop.get("x-fortran-default-pad")
1✔
487
    if pad_raw is not None:
1✔
488
        pad_list = pad_raw if isinstance(pad_raw, list) else [pad_raw]
×
489
        pad_elements = [
×
490
            _format_scalar_default(value, None, type_info.element_category) for value in pad_list
491
        ]
492
        if isinstance(pad_raw, list):
×
493
            pad_text = f"[{', '.join(pad_elements)}]"
×
494
        else:
495
            pad_text = pad_elements[0]
×
496
        notes.append(f"pad: `{pad_text}`")
×
497

498
    if notes:
1✔
499
        return base, f"({', '.join(notes)})"
×
500
    return base, None
1✔
501

502

503
def _github_section_id(title: str) -> str:
1✔
504
    # lowercase
505
    s = title.strip().lower()
1✔
506
    # remove punctuation characters
507
    punctuation = string.punctuation.replace("_", "")
1✔
508
    s = re.sub(f"[{re.escape(punctuation)}]", "", s)
1✔
509
    # replace any whitespace run with '-'
510
    s = re.sub(r"\s+", "-", s)
1✔
511
    return s
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