mkdocs-owl-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,612 @@
1
+ """
2
+ Shared rendering building blocks used by both the AsyncAPI and OpenAPI page renderers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import html as _html
8
+ import re
9
+ from typing import Any
10
+
11
+ import markdown as _md
12
+ import yaml
13
+
14
+ _CELL_MD = _md.Markdown(
15
+ extensions=["fenced_code", "tables", "admonition", "attr_list"],
16
+ output_format="html",
17
+ )
18
+
19
+
20
+ def _md_to_html(text: str, *, inline: bool = False) -> str:
21
+ """
22
+ Convert a Markdown fragment to HTML for direct embedding.
23
+
24
+ Pass `inline=True` to strip a single wrapping `<p>...</p>` so the
25
+ result sits cleanly in a single-line cell (used for name/type columns).
26
+ Block content (descriptions with paragraphs, lists, code) keeps its
27
+ natural HTML structure.
28
+ """
29
+ if not text or not text.strip():
30
+ return ""
31
+ _CELL_MD.reset()
32
+ html = _CELL_MD.convert(_normalize_lists(text)).strip()
33
+ if inline and html.startswith("<p>") and html.endswith("</p>"):
34
+ html = html[3:-4]
35
+ return html
36
+
37
+
38
+ def _pill(label: str, *, kind: str, title: str | None = None) -> str:
39
+ """
40
+ Render a short categorical badge as a `<span class="techdocs-owl-api-pill ...">`.
41
+ """
42
+ classes = f"techdocs-owl-api-pill techdocs-owl-api-pill--{kind}"
43
+ title_attr = f' title="{_html.escape(title)}"' if title else ""
44
+ return (
45
+ f'<span class="{classes}"{title_attr}>'
46
+ f'{_html.escape(label)}'
47
+ f'</span>'
48
+ )
49
+
50
+
51
+ def _unescape_pointer(token: str) -> str:
52
+ """
53
+ Decode a JSON Pointer reference token (RFC 6901): `~1` -> `/`, `~0` -> `~`.
54
+ """
55
+ return token.replace("~1", "/").replace("~0", "~")
56
+
57
+
58
+ def _resolve_ref(spec: dict[str, Any], ref: str) -> Any:
59
+ """
60
+ Walk a JSON-Pointer-style `$ref` against the loaded spec.
61
+
62
+ Used for inlining referenced objects (e.g. embedding a security scheme body where it is referenced).
63
+ """
64
+ if not ref.startswith("#/"):
65
+ return None
66
+ node: Any = spec
67
+ for part in ref[2:].split("/"):
68
+ part = _unescape_pointer(part)
69
+ if not isinstance(node, dict) or part not in node:
70
+ return None
71
+ node = node[part]
72
+ return node
73
+
74
+
75
+ _LIST_ITEM_RE = re.compile(r"^[ \t]*(?:[-*+]|\d+[.)])\s+")
76
+
77
+
78
+ def _normalize_lists(md: str) -> str:
79
+ """
80
+ Insert a blank line before a bullet/numbered list that directly follows a non-blank line.
81
+
82
+ p.s. Python-Markdown is strict about list recognition: a `- item`.
83
+ """
84
+ if not md:
85
+ return md
86
+ out: list[str] = []
87
+ in_fence = False
88
+ prev_blank = True
89
+ for line in md.split("\n"):
90
+ if _FENCE_RE.match(line):
91
+ in_fence = not in_fence
92
+ out.append(line)
93
+ prev_blank = False
94
+ continue
95
+ if not in_fence and _LIST_ITEM_RE.match(line) and not prev_blank:
96
+ if not (out and _LIST_ITEM_RE.match(out[-1])):
97
+ out.append("")
98
+ out.append(line)
99
+ prev_blank = (line.strip() == "")
100
+ return "\n".join(out)
101
+
102
+
103
+ _FENCE_RE = re.compile(r"^[ \t]*(```+|~~~+)")
104
+ _HEADING_RE = re.compile(r"^#+\s+")
105
+
106
+
107
+ def _slug(name: str) -> str:
108
+ """
109
+ Match python-markdown's default toc slugifier closely enough for in-page anchor links to resolve.
110
+ """
111
+ return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
112
+
113
+
114
+ def _anchor(section: str, name: str) -> str:
115
+ """
116
+ Stable anchor id for an item rendered under a section heading.
117
+ """
118
+ return f"{_slug(section)}-{_slug(name)}"
119
+
120
+
121
+ def _heading(level: int, name: str, *, anchor: str | None = None) -> str:
122
+ """
123
+ Emit an ATX heading, optionally with an explicit attr_list id.
124
+
125
+ Explicit ids decouple anchors from python-markdown's auto-slug rules,
126
+ which is important here because two sections may legitimately share an
127
+ item name (e.g. a schema and a message both called `User`).
128
+ """
129
+ line = f"{'#' * level} {name}"
130
+ if anchor:
131
+ line += f" {{#{anchor}}}"
132
+ return line
133
+
134
+
135
+ def _ref_link(ref: str) -> str:
136
+ """
137
+ Resolve a JSON Pointer-style `$ref` to a Markdown link.
138
+
139
+ The convention is that `parts[-2]` of the ref path is the section name
140
+ and `parts[-1]` is the item name. Examples:
141
+
142
+ '#/components/schemas/Foo' -> [Foo](#schemas-foo)
143
+ '#/components/messages/Light' -> [Light](#messages-light)
144
+ '#/channels/lightingMeasured' -> [lightingMeasured](#channels-lightingmeasured)
145
+ '#/channels/X/messages/Y' -> [Y](#messages-y)
146
+ """
147
+ parts = [_unescape_pointer(p) for p in ref.lstrip("#/").split("/")]
148
+ if not parts:
149
+ return "`<broken-ref>`"
150
+ if len(parts) < 2:
151
+ return f"`{parts[-1]}`"
152
+ name = parts[-1]
153
+ section = parts[-2]
154
+ return f"[`{name}`](#{_anchor(section, name)})"
155
+
156
+
157
+ def _demote_headings(md: str, levels: int = 2) -> str:
158
+ """
159
+ Add `levels` `#`s to ATX heading lines outside fenced code blocks.
160
+
161
+ Spec descriptions are free-form Markdown and sometimes contain `##` headings that would collide with section headings.
162
+ Shifting them down keeps the heading hierarchy clean.
163
+ """
164
+ if not md:
165
+ return md
166
+ prefix = "#" * levels
167
+ out: list[str] = []
168
+ in_fence = False
169
+ for line in md.split("\n"):
170
+ if _FENCE_RE.match(line):
171
+ in_fence = not in_fence
172
+ out.append(line)
173
+ continue
174
+ if not in_fence and _HEADING_RE.match(line):
175
+ line = prefix + line
176
+ out.append(line)
177
+ return "\n".join(out)
178
+
179
+
180
+ def _format_type(prop: dict[str, Any]) -> str:
181
+ """
182
+ Render a property's type as a short, readable expression.
183
+ """
184
+ if "$ref" in prop:
185
+ return _ref_link(prop["$ref"])
186
+
187
+ all_of = prop.get("allOf")
188
+ if isinstance(all_of, list) and len(all_of) == 1:
189
+ return f"array of {_format_type(all_of[0])}"
190
+
191
+ t = prop.get("type")
192
+ if isinstance(t, list):
193
+ t = " | ".join(str(x) for x in t)
194
+ fmt = prop.get("format")
195
+
196
+ if t == "object" and "additionalProperties" in prop:
197
+ return f"map of string → {_format_type(prop['additionalProperties'])}"
198
+ if t == "array":
199
+ return f"array of {_format_type(prop.get('items') or {})}"
200
+ if t and fmt:
201
+ return f"{t} ({fmt})"
202
+ if t:
203
+ return t
204
+ return "object"
205
+
206
+
207
+ def _flags(prop: dict[str, Any]) -> list[str]:
208
+ """
209
+ Visible callouts for property-level annotations, rendered as pills.
210
+ """
211
+ flags: list[str] = []
212
+ if prop.get("x-internal-only") is True:
213
+ flags.append(_pill("internal", kind="internal"))
214
+ if prop.get("deprecated") is True:
215
+ flags.append(_pill("deprecated", kind="deprecated"))
216
+ return flags
217
+
218
+
219
+ def _schema_depth(opts: dict[str, Any]) -> int:
220
+ """
221
+ How many levels deep inline object properties are flattened into the dot-path properties table.
222
+ Configurable via the `schema_depth` frontmatter key.
223
+ """
224
+ raw = opts.get("schema_depth", 3)
225
+ try:
226
+ return max(1, int(raw))
227
+ except (TypeError, ValueError):
228
+ return 3
229
+
230
+
231
+ def _render_tags(tags: Any) -> str:
232
+ if not isinstance(tags, list) or not tags:
233
+ return ""
234
+ pills: list[str] = []
235
+ for tag in tags:
236
+ if isinstance(tag, dict):
237
+ nm = str(tag.get("name") or "tag")
238
+ td = (tag.get("description") or "").strip() or None
239
+ pills.append(_pill(nm, kind="tag", title=td))
240
+ else:
241
+ pills.append(_pill(str(tag), kind="tag"))
242
+ return "**Tags:** " + " ".join(pills)
243
+
244
+
245
+ def _render_bindings(bindings: Any, *, hide_bindings: bool) -> str:
246
+ if hide_bindings or not isinstance(bindings, dict) or not bindings:
247
+ return ""
248
+ parts: list[str] = []
249
+ for protocol, body in bindings.items():
250
+ parts.append(f'!!! note "{protocol} bindings"')
251
+ rendered = yaml.safe_dump(
252
+ body, sort_keys=False, default_flow_style=False
253
+ ).rstrip()
254
+ parts.append(" ```yaml")
255
+ for line in rendered.split("\n"):
256
+ parts.append(" " + line)
257
+ parts.append(" ```")
258
+ parts.append("")
259
+ return "\n".join(parts)
260
+
261
+
262
+ def _render_examples(examples: Any) -> str:
263
+ """
264
+ Render `examples` as fenced code blocks.
265
+ """
266
+ if not examples:
267
+ return ""
268
+ if not isinstance(examples, list):
269
+ examples = [examples]
270
+ parts: list[str] = ["**Examples**", ""]
271
+ for ex in examples:
272
+ if isinstance(ex, dict) and "payload" in ex:
273
+ label = ex.get("name") or ex.get("summary")
274
+ payload = ex.get("payload")
275
+ else:
276
+ label = None
277
+ payload = ex
278
+ if label:
279
+ parts.append(f"_{label}_:")
280
+ parts.append("")
281
+ rendered = yaml.safe_dump(
282
+ payload, sort_keys=False, default_flow_style=False
283
+ ).rstrip()
284
+ parts.append("```yaml")
285
+ parts.append(rendered)
286
+ parts.append("```")
287
+ parts.append("")
288
+ return "\n".join(parts)
289
+
290
+
291
+ def _render_property_row(
292
+ name: str,
293
+ prop: dict[str, Any],
294
+ *,
295
+ required: bool,
296
+ type_override: str | None = None,
297
+ ) -> str:
298
+ type_str = type_override or _format_type(prop)
299
+ flag_bits = _flags(prop)
300
+ if required:
301
+ flag_bits.insert(0, _pill("required", kind="required"))
302
+
303
+ name_md = f"`{name}`"
304
+ if flag_bits:
305
+ name_md += "<br>" + " ".join(flag_bits)
306
+
307
+ desc_block = _build_description_block(prop)
308
+
309
+ name_html = _md_to_html(name_md, inline=True)
310
+ type_html = _md_to_html(type_str, inline=True)
311
+ desc_html = _md_to_html(desc_block) or "&mdash;"
312
+
313
+ return (
314
+ "<tr>\n"
315
+ f"<td>{name_html}</td>\n"
316
+ f"<td>{type_html}</td>\n"
317
+ f"<td>{desc_html}</td>\n"
318
+ "</tr>"
319
+ )
320
+
321
+
322
+ def _build_description_block(prop: dict[str, Any]) -> str:
323
+ parts: list[str] = []
324
+
325
+ desc = (prop.get("description") or "").strip()
326
+ if desc:
327
+ parts.append(_demote_headings(desc, levels=4))
328
+ parts.append("")
329
+
330
+ rules: list[str] = []
331
+
332
+ enum = prop.get("enum")
333
+ if enum:
334
+ rules.append(
335
+ "- Allowed values: " + ", ".join(f"`{v}`" for v in enum)
336
+ )
337
+
338
+ rule_keys: list[tuple[str, str]] = [
339
+ ("Default", "default"),
340
+ ("Min length", "minLength"),
341
+ ("Max length", "maxLength"),
342
+ ("Pattern", "pattern"),
343
+ ("Minimum", "minimum"),
344
+ ("Maximum", "maximum"),
345
+ ("Exclusive minimum", "exclusiveMinimum"),
346
+ ("Exclusive maximum", "exclusiveMaximum"),
347
+ ("Multiple of", "multipleOf"),
348
+ ("Min items", "minItems"),
349
+ ("Max items", "maxItems"),
350
+ ("Unique items", "uniqueItems"),
351
+ ("Min properties", "minProperties"),
352
+ ("Max properties", "maxProperties"),
353
+ ]
354
+ for label, key in rule_keys:
355
+ if key in prop and prop[key] is not None:
356
+ rules.append(f"- {label}: `{prop[key]}`")
357
+
358
+ example = prop.get("example")
359
+ if isinstance(example, (str, int, float, bool)):
360
+ rules.append(f"- Example: `{example}`")
361
+
362
+ if rules:
363
+ if desc:
364
+ parts.append("**Constraints**")
365
+ parts.append("")
366
+ parts.extend(rules)
367
+
368
+ return "\n".join(parts).strip()
369
+
370
+
371
+ def _flatten_properties(
372
+ properties: dict[str, Any],
373
+ required: set[str],
374
+ *,
375
+ hide_internal: bool,
376
+ max_depth: int,
377
+ _prefix: str = "",
378
+ _depth: int = 1,
379
+ ) -> list[tuple[str, dict[str, Any], bool, str | None]]:
380
+ rows: list[tuple[str, dict[str, Any], bool, str | None]] = []
381
+ for pname, pschema in properties.items():
382
+ if not isinstance(pschema, dict):
383
+ continue
384
+ if hide_internal and pschema.get("x-internal-only") is True:
385
+ continue
386
+
387
+ path = f"{_prefix}{pname}"
388
+ req = pname in required
389
+ child_props = pschema.get("properties")
390
+ is_inline_object = (
391
+ "$ref" not in pschema
392
+ and isinstance(child_props, dict) and child_props
393
+ )
394
+ items = pschema.get("items") if pschema.get("type") == "array" else None
395
+ item_props = items.get("properties") if isinstance(items, dict) else None
396
+ is_array_of_objects = (
397
+ isinstance(items, dict) and "$ref" not in items
398
+ and isinstance(item_props, dict) and item_props
399
+ )
400
+
401
+ if is_inline_object and _depth < max_depth:
402
+ rows.append((path, pschema, req, None))
403
+ rows.extend(_flatten_properties(
404
+ child_props, set(pschema.get("required") or []),
405
+ hide_internal=hide_internal, max_depth=max_depth,
406
+ _prefix=f"{path}.", _depth=_depth + 1,
407
+ ))
408
+ elif is_array_of_objects and _depth < max_depth:
409
+ rows.append((f"{path}[]", pschema, req, "array of objects"))
410
+ rows.extend(_flatten_properties(
411
+ item_props, set(items.get("required") or []),
412
+ hide_internal=hide_internal, max_depth=max_depth,
413
+ _prefix=f"{path}[].", _depth=_depth + 1,
414
+ ))
415
+ else:
416
+ rows.append((path, pschema, req, None))
417
+ return rows
418
+
419
+
420
+ def _render_properties_table(
421
+ properties: dict[str, Any],
422
+ required: set[str],
423
+ *,
424
+ hide_internal: bool = False,
425
+ max_depth: int = 3,
426
+ ) -> str:
427
+ rows = _flatten_properties(
428
+ properties, required, hide_internal=hide_internal, max_depth=max_depth,
429
+ )
430
+ if not rows:
431
+ return ""
432
+ parts: list[str] = [
433
+ '<table>',
434
+ '<thead>',
435
+ '<tr><th>Name</th><th>Type</th><th>Description</th></tr>',
436
+ '</thead>',
437
+ '<tbody>',
438
+ ]
439
+ for path, pschema, req, type_override in rows:
440
+ parts.append(_render_property_row(
441
+ path, pschema, required=req, type_override=type_override,
442
+ ))
443
+ parts.append('</tbody>')
444
+ parts.append('</table>')
445
+ parts.append("")
446
+ return "\n".join(parts)
447
+
448
+
449
+ def _render_schema(
450
+ schema: dict[str, Any],
451
+ *,
452
+ hide_internal: bool,
453
+ max_depth: int = 3,
454
+ ) -> str:
455
+ parts: list[str] = []
456
+
457
+ desc = (schema.get("description") or "").strip()
458
+ if desc:
459
+ parts.append(_demote_headings(desc))
460
+ parts.append("")
461
+
462
+ if "$ref" in schema:
463
+ parts.append(f"_Type:_ {_ref_link(schema['$ref'])}")
464
+ return "\n".join(parts).strip()
465
+
466
+ t = schema.get("type")
467
+ enum = schema.get("enum")
468
+
469
+ base_props: dict[str, Any] = dict(schema.get("properties") or {})
470
+ base_required: set[str] = set(schema.get("required") or [])
471
+ compose_lines: list[str] = []
472
+
473
+ all_of = schema.get("allOf")
474
+ if isinstance(all_of, list):
475
+ includes: list[str] = []
476
+ for mem in all_of:
477
+ if not isinstance(mem, dict):
478
+ continue
479
+ if "$ref" in mem:
480
+ includes.append(_ref_link(mem["$ref"]))
481
+ else:
482
+ base_props.update(mem.get("properties") or {})
483
+ base_required.update(mem.get("required") or [])
484
+ if includes:
485
+ compose_lines.append("**All of:** " + " | ".join(includes))
486
+
487
+ for kw, label in (("oneOf", "One of"), ("anyOf", "Any of")):
488
+ members = schema.get(kw)
489
+ if isinstance(members, list) and members:
490
+ rendered: list[str] = []
491
+ for mem in members:
492
+ if isinstance(mem, dict) and "$ref" in mem:
493
+ rendered.append(_ref_link(mem["$ref"]))
494
+ elif isinstance(mem, dict):
495
+ rendered.append(f"`{_format_type(mem)}`")
496
+ else:
497
+ rendered.append(f"`{mem}`")
498
+ compose_lines.append(f"**{label}:** " + " | ".join(rendered))
499
+
500
+ if enum and not base_props:
501
+ if t:
502
+ parts.append(f"_Type:_ `{t}`")
503
+ parts.append("")
504
+ parts.append("**Allowed values:**")
505
+ parts.append("")
506
+ for v in enum:
507
+ parts.append(f"- `{v}`")
508
+ return "\n".join(parts).strip()
509
+
510
+ if t:
511
+ parts.append(f"_Type:_ `{t}`")
512
+ parts.append("")
513
+
514
+ for line in compose_lines:
515
+ parts.append(line)
516
+ parts.append("")
517
+
518
+ if base_props:
519
+ parts.append("_Properties:_")
520
+ parts.append("")
521
+ parts.append(_render_properties_table(
522
+ base_props, base_required,
523
+ hide_internal=hide_internal, max_depth=max_depth,
524
+ ))
525
+
526
+ return "\n".join(parts).strip()
527
+
528
+
529
+ def _render_security_inline(spec: dict[str, Any], entry: Any) -> str:
530
+ if not isinstance(entry, dict):
531
+ return ""
532
+
533
+ scopes: Any = None
534
+ if "$ref" in entry:
535
+ ref = entry["$ref"]
536
+ scheme_name = ref.rsplit("/", 1)[-1]
537
+ scheme = _resolve_ref(spec, ref)
538
+ else:
539
+ items = list(entry.items())
540
+ if not items:
541
+ return ""
542
+ scheme_name, scopes = items[0]
543
+ components = (spec.get("components") or {}).get("securitySchemes") or {}
544
+ scheme = components.get(scheme_name) if isinstance(components, dict) else None
545
+
546
+ if not isinstance(scheme, dict):
547
+ return f"- **Security:** `{scheme_name}`"
548
+
549
+ body_lines: list[str] = []
550
+ t = scheme.get("type")
551
+ if t:
552
+ body_lines.append(f"**Type:** {_pill(str(t), kind='scheme')}")
553
+ body_lines.append("")
554
+ for label, key in (
555
+ ("Name", "name"),
556
+ ("In", "in"),
557
+ ("Scheme", "scheme"),
558
+ ("Bearer format", "bearerFormat"),
559
+ ("OpenID Connect URL", "openIdConnectUrl"),
560
+ ):
561
+ v = scheme.get(key)
562
+ if v:
563
+ body_lines.append(f"**{label}:** `{v}`")
564
+ sdesc = (scheme.get("description") or "").strip()
565
+ if sdesc:
566
+ if body_lines and body_lines[-1] != "":
567
+ body_lines.append("")
568
+ body_lines.append(_demote_headings(sdesc, levels=2))
569
+
570
+ if isinstance(scopes, list) and scopes:
571
+ if body_lines and body_lines[-1] != "":
572
+ body_lines.append("")
573
+ body_lines.append("**Scopes:** " + ", ".join(f"`{sc}`" for sc in scopes))
574
+
575
+ indented = "\n".join((" " + l) if l else "" for l in body_lines)
576
+
577
+ return (
578
+ f'!!! note ":material-security: Security: {scheme_name}"\n'
579
+ f"{indented}"
580
+ )
581
+
582
+
583
+ def _render_downloads_table(
584
+ spec_url: str,
585
+ attachments: list[dict[str, Any]],
586
+ *,
587
+ hide_download: bool,
588
+ ) -> str:
589
+ rows: list[str] = []
590
+ if spec_url and not hide_download:
591
+ rows.append(f":material-file-document: [Specification Source]({spec_url})")
592
+ for att in attachments:
593
+ if att.get("url"):
594
+ rows.append(f":material-file-document: [{att['title']}]({att['url']})")
595
+ else:
596
+ rows.append(f":material-file-document: {att['title']} _(unavailable: {att.get('error')})_")
597
+
598
+ if not rows:
599
+ return ""
600
+
601
+ out = ["| Downloads |", "|---|"]
602
+ out.extend(f"| {r} |" for r in rows)
603
+ out.append("")
604
+ return "\n".join(out)
605
+
606
+
607
+ def _error_page(title: str, detail: str) -> str:
608
+ return (
609
+ "# AsyncAPI page failed to render\n\n"
610
+ f'!!! danger "{title}"\n'
611
+ f" {detail}\n"
612
+ )