sphinx-config-schema 0.0.1__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,658 @@
1
+ """Render a JSON schema as a copyable TOML, YAML, or JSON example in Sphinx."""
2
+
3
+ # cspell:ignore astext docname docnames doctree ensurenl refid relfn stripnl templatename
4
+
5
+ from __future__ import annotations
6
+
7
+ import html
8
+ import importlib.metadata
9
+ import itertools
10
+ import json
11
+ import re
12
+ from collections.abc import Sequence # ruff: ignore[typing-only-standard-library-import] # autodoc resolves the annotations
13
+ from dataclasses import dataclass, field, replace
14
+ from pathlib import Path
15
+ from types import MappingProxyType
16
+ from typing import TYPE_CHECKING, Any, cast
17
+
18
+ from docutils import nodes
19
+ from docutils.parsers.rst import directives
20
+ from pygments.lexers import get_lexer_by_name
21
+ from pygments.token import STANDARD_TYPES
22
+ from sphinx.directives.code import container_wrapper
23
+ from sphinx.util.docutils import SphinxDirective
24
+ from typing_extensions import override
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Iterator
28
+
29
+ from pygments.token import _TokenType
30
+ from sphinx.application import Sphinx
31
+ from sphinx.environment import BuildEnvironment
32
+
33
+ CSS_CLASS = "config-schema-key"
34
+ STATIC_DIR = Path(__file__).parent / "_static"
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Setting:
39
+ """One key-value pair in the generated example."""
40
+
41
+ table: tuple[str, ...]
42
+ name: str
43
+ value: Any
44
+ description: str
45
+ anchor_suffix: str = field(default="", repr=False)
46
+ """Distinguishes `.anchor` from that of the same key elsewhere on the page."""
47
+ examples: tuple[Any, ...] = field(default=(), repr=False)
48
+ """Example values from the schema, listed in the tooltip unless `.value` shows them."""
49
+ allowed: tuple[Any, ...] = field(default=(), repr=False)
50
+ """The values that the schema allows for this setting through ``enum``."""
51
+
52
+ @property
53
+ def path(self) -> tuple[str, ...]:
54
+ return (*self.table, self.name)
55
+
56
+ @property
57
+ def anchor(self) -> str:
58
+ return nodes.make_id(".".join(self.path) + self.anchor_suffix)
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Table:
63
+ """A nested table in the generated example, which holds settings of its own."""
64
+
65
+ table: tuple[str, ...]
66
+ name: str
67
+ description: str
68
+ anchor_suffix: str = field(default="", repr=False)
69
+
70
+ @property
71
+ def path(self) -> tuple[str, ...]:
72
+ return (*self.table, self.name)
73
+
74
+ @property
75
+ def anchor(self) -> str:
76
+ return nodes.make_id(".".join(self.path) + self.anchor_suffix)
77
+
78
+
79
+ Entry = Setting | Table
80
+ """A key in the generated example."""
81
+
82
+
83
+ class ConfigSchemaDirective(SphinxDirective):
84
+ """Render a copyable TOML, YAML, or JSON example from a JSON schema.
85
+
86
+ Each key in the example is an anchor that sphinx-tippy turns into a tooltip with the
87
+ description of that setting and the values that the example does not show.
88
+ """
89
+
90
+ has_content = False
91
+ required_arguments = 1
92
+ option_spec = MappingProxyType({
93
+ "caption": directives.unchanged_required,
94
+ "format": lambda arg: directives.choice(arg, FORMATS),
95
+ "strict": directives.flag,
96
+ "table": directives.unchanged_required,
97
+ })
98
+
99
+ @override
100
+ def run(self) -> list[nodes.Node]:
101
+ rel_path, abs_path = self.env.relfn2path(self.arguments[0])
102
+ self.env.note_dependency(rel_path)
103
+ schema = json.loads(Path(abs_path).read_text())
104
+ table = (
105
+ tuple(self.options.get("table", "").split("."))
106
+ if "table" in self.options
107
+ else ()
108
+ )
109
+ fmt = self.options.get("format", "toml")
110
+ strict = "strict" in self.options
111
+ try:
112
+ settings = list(iter_settings(schema, table=table, strict=strict))
113
+ except ValueError as exc:
114
+ raise self.error(str(exc)) from exc
115
+ tips: dict[str, str] = _get_tips(self.env).setdefault(self.env.docname, {})
116
+ settings = [_make_unique(s, fmt, tips) for s in _drop_empty_tables(settings)]
117
+ tips.update({
118
+ f"#{s.anchor}": tip for s in settings if (tip := _entry_tooltip(s))
119
+ })
120
+ example: nodes.Node = create_literal_block(settings, fmt, rawsource=self.name)
121
+ self.set_source_info(example)
122
+ caption = self.options.get("caption")
123
+ if caption is not None:
124
+ example = container_wrapper(self, example, caption)
125
+ return [example]
126
+
127
+
128
+ def _drop_empty_tables(entries: list[Entry]) -> list[Entry]:
129
+ """Remove the tables that do not appear in the example, as no setting is in them."""
130
+ used = {
131
+ s.table[:i]
132
+ for s in entries
133
+ if isinstance(s, Setting)
134
+ for i in range(len(s.table) + 1)
135
+ }
136
+ return [e for e in entries if isinstance(e, Setting) or e.path in used]
137
+
138
+
139
+ def _make_unique(setting: Entry, fmt: str, tips: dict[str, str]) -> Entry:
140
+ """Suffix the anchor of a setting if an earlier example on the page already uses it.
141
+
142
+ This happens when the same schema is shown in several formats, for instance in tabs.
143
+
144
+ >>> tips = {"#port": "", "#port-yaml": ""}
145
+ >>> _make_unique(Setting((), "port", 80, ""), "yaml", tips).anchor
146
+ 'port-yaml-2'
147
+ """
148
+ suffixes = itertools.chain(
149
+ ("", f".{fmt}"), (f".{fmt}.{i}" for i in itertools.count(2))
150
+ )
151
+ candidates = (replace(setting, anchor_suffix=s) for s in suffixes)
152
+ return next(c for c in candidates if f"#{c.anchor}" not in tips)
153
+
154
+
155
+ def iter_settings(
156
+ schema: dict[str, Any], table: tuple[str, ...] = (), *, strict: bool = False
157
+ ) -> Iterator[Entry]:
158
+ """Collect the settings with a default or example value from a JSON schema.
159
+
160
+ Settings without a usable value are skipped, or reported with a `ValueError` after
161
+ all other settings have been collected if ``strict`` is set. Nested tables are
162
+ yielded as a `Table`, so that their key can carry a description as well. So is the
163
+ last part of ``table``, with the description of the schema itself.
164
+
165
+ >>> schema = {
166
+ ... "description": "Demo settings.",
167
+ ... "properties": {
168
+ ... "name": {"description": "Project name.", "examples": ["demo"]},
169
+ ... "optional": {"type": "string"},
170
+ ... },
171
+ ... }
172
+ >>> for entry in iter_settings(schema, table=("tool", "demo")):
173
+ ... print(entry)
174
+ Table(table=('tool',), name='demo', description='Demo settings.')
175
+ Setting(table=('tool', 'demo'), name='name', value='demo', description='Project name.')
176
+ >>> list(iter_settings(schema, strict=True))
177
+ Traceback (most recent call last):
178
+ ...
179
+ ValueError: Settings without a default or example: optional
180
+ """
181
+ missing: list[str] = []
182
+ if table:
183
+ yield Table(table[:-1], table[-1], schema.get("description", ""))
184
+ for sub_table, properties in _iter_tables(
185
+ table, schema.get("properties", {}), schema
186
+ ):
187
+ for name, field_schema in properties.items():
188
+ resolved = _resolve_ref(field_schema, schema)
189
+ if _is_table(resolved):
190
+ description = field_schema.get("description") or resolved.get(
191
+ "description", ""
192
+ )
193
+ yield Table(sub_table, name, description)
194
+ continue
195
+ value = _example_value(field_schema)
196
+ if value is None:
197
+ missing.append(".".join((*sub_table, name)))
198
+ continue
199
+ yield Setting(
200
+ sub_table,
201
+ name,
202
+ value,
203
+ description=field_schema.get("description", ""),
204
+ examples=tuple(field_schema.get("examples", [])),
205
+ allowed=tuple(field_schema.get("enum") or resolved.get("enum", [])),
206
+ )
207
+ if strict and missing:
208
+ msg = f"Settings without a default or example: {', '.join(missing)}"
209
+ raise ValueError(msg)
210
+
211
+
212
+ def _example_value(field_schema: dict[str, Any]) -> Any:
213
+ """Select the value shown for a setting: its default, or else its first example.
214
+
215
+ An empty collection is a placeholder rather than a meaningful default, so it gives
216
+ way to an example when the setting declares one.
217
+
218
+ >>> _example_value({"default": [], "examples": [["pc15"]]})
219
+ ['pc15']
220
+ >>> _example_value({"default": [], "examples": [None]})
221
+ []
222
+ """
223
+ default = field_schema.get("default")
224
+ if default is not None and not (isinstance(default, list | dict) and not default):
225
+ return default
226
+ examples = [e for e in field_schema.get("examples", []) if e is not None]
227
+ if examples:
228
+ return examples[0]
229
+ return default
230
+
231
+
232
+ def _iter_tables(
233
+ table: tuple[str, ...], properties: dict[str, Any], schema: dict[str, Any]
234
+ ) -> Iterator[tuple[tuple[str, ...], dict[str, Any]]]:
235
+ yield table, properties
236
+ for name, field_schema in properties.items():
237
+ resolved = _resolve_ref(field_schema, schema)
238
+ if _is_table(resolved):
239
+ yield from _iter_tables((*table, name), resolved["properties"], schema)
240
+
241
+
242
+ def _is_table(field_schema: dict[str, Any]) -> bool:
243
+ return (
244
+ field_schema.get("type", "object") == "object" and "properties" in field_schema
245
+ )
246
+
247
+
248
+ def _resolve_ref(
249
+ field_schema: dict[str, Any], schema: dict[str, Any]
250
+ ) -> dict[str, Any]:
251
+ """Follow a local ``$ref``, such as the ``#/$defs/...`` references of pydantic.
252
+
253
+ >>> schema = {"$defs": {"Paths": {"properties": {}}}}
254
+ >>> _resolve_ref({"$ref": "#/$defs/Paths"}, schema)
255
+ {'properties': {}}
256
+ """
257
+ reference = field_schema.get("$ref")
258
+ if reference is None or not reference.startswith("#/"):
259
+ return field_schema
260
+ resolved: Any = schema
261
+ for part in reference.removeprefix("#/").split("/"):
262
+ resolved = resolved[part]
263
+ return resolved
264
+
265
+
266
+ FORMATS = ("toml", "yaml", "json")
267
+ """Formats in which the example can be rendered."""
268
+
269
+ Fragment = tuple[str, "Entry | None"]
270
+ """A piece of the example text, with its `Entry` if the piece is a key."""
271
+
272
+
273
+ def render(settings: Sequence[Entry], fmt: str = "toml") -> str:
274
+ """Render the example as plain text.
275
+
276
+ >>> settings = [Setting(("tool", "demo"), "transport", "auto", "")]
277
+ >>> print(render(settings))
278
+ [tool.demo]
279
+ transport = "auto"
280
+ >>> print(render(settings, "yaml"))
281
+ tool:
282
+ demo:
283
+ transport: auto
284
+ """
285
+ return "".join(text for text, _ in iter_fragments(settings, fmt))
286
+
287
+
288
+ def iter_fragments(settings: Sequence[Entry], fmt: str) -> Iterator[Fragment]:
289
+ """Split the example text into its keys and the text in between."""
290
+ leaves = [s for s in settings if isinstance(s, Setting)]
291
+ tables = {s.path: s for s in settings if isinstance(s, Table)}
292
+ if fmt == "toml":
293
+ fragments = list(_iter_toml(leaves, tables))
294
+ elif fmt == "yaml":
295
+ fragments = list(_iter_yaml(_build_tree(leaves), tables, (), indent=""))
296
+ elif fmt == "json":
297
+ fragments = list(_iter_json(_build_tree(leaves), tables, (), indent=""))
298
+ else:
299
+ msg = f"Unknown format {fmt!r}, choose from {', '.join(FORMATS)}"
300
+ raise ValueError(msg)
301
+ if fragments:
302
+ text, setting = fragments.pop()
303
+ fragments.append((text.rstrip("\n"), setting))
304
+ yield from fragments
305
+
306
+
307
+ _Tree = dict[str, "Setting | _Tree"]
308
+
309
+
310
+ def _build_tree(settings: list[Setting]) -> _Tree:
311
+ """Nest the settings by their table, keeping the order in which they appear."""
312
+ tree: _Tree = {}
313
+ for setting in settings:
314
+ node = tree
315
+ for part in setting.table:
316
+ node = cast("_Tree", node.setdefault(part, {}))
317
+ node[setting.name] = setting
318
+ return tree
319
+
320
+
321
+ def _iter_toml(
322
+ settings: list[Setting], tables: dict[tuple[str, ...], Table]
323
+ ) -> Iterator[Fragment]:
324
+ table: tuple[str, ...] = ()
325
+ first = True
326
+ for setting in settings:
327
+ if setting.table != table:
328
+ if not first:
329
+ yield "\n", None
330
+ *parents, name = map(_to_toml_key, setting.table)
331
+ yield "[" + "".join(f"{p}." for p in parents), None
332
+ yield name, tables.get(setting.table)
333
+ yield "]\n", None
334
+ table = setting.table
335
+ first = False
336
+ yield _to_toml_key(setting.name), setting
337
+ yield f" = {to_toml_value(setting.value)}\n", None
338
+
339
+
340
+ def _iter_yaml(
341
+ tree: _Tree,
342
+ tables: dict[tuple[str, ...], Table],
343
+ path: tuple[str, ...],
344
+ indent: str,
345
+ ) -> Iterator[Fragment]:
346
+ for key, child in tree.items():
347
+ yield indent, None
348
+ if isinstance(child, Setting):
349
+ yield _to_yaml_key(key), child
350
+ yield f": {to_yaml_value(child.value)}\n", None
351
+ else:
352
+ yield _to_yaml_key(key), tables.get((*path, key))
353
+ yield ":\n", None
354
+ yield from _iter_yaml(child, tables, (*path, key), indent + " ")
355
+
356
+
357
+ def _iter_json(
358
+ tree: _Tree,
359
+ tables: dict[tuple[str, ...], Table],
360
+ path: tuple[str, ...],
361
+ indent: str,
362
+ ) -> Iterator[Fragment]:
363
+ if not tree:
364
+ yield "{}", None
365
+ return
366
+ yield "{\n", None
367
+ for i, (key, child) in enumerate(tree.items()):
368
+ yield indent + " ", None
369
+ if isinstance(child, Setting):
370
+ yield _to_json(key), child
371
+ yield f": {_to_json(child.value)}", None
372
+ else:
373
+ yield _to_json(key), tables.get((*path, key))
374
+ yield ": ", None
375
+ yield from _iter_json(child, tables, (*path, key), indent + " ")
376
+ yield ",\n" if i < len(tree) - 1 else "\n", None
377
+ yield indent + "}", None
378
+
379
+
380
+ def to_toml_value(value: Any) -> str:
381
+ """Render a JSON value as an inline TOML value.
382
+
383
+ >>> to_toml_value({"name": "demo", "paths": ["a", "b"], "strict": True})
384
+ '{ name = "demo", paths = ["a", "b"], strict = true }'
385
+ """
386
+ if isinstance(value, bool):
387
+ return "true" if value else "false"
388
+ if isinstance(value, int | float):
389
+ return repr(value)
390
+ if isinstance(value, str):
391
+ return _to_json(value)
392
+ if isinstance(value, list):
393
+ return "[" + ", ".join(to_toml_value(v) for v in value) + "]"
394
+ if isinstance(value, dict):
395
+ if not value:
396
+ return "{}"
397
+ items = ", ".join(
398
+ f"{_to_toml_key(k)} = {to_toml_value(v)}" for k, v in value.items()
399
+ )
400
+ return f"{{ {items} }}"
401
+ msg = f"Cannot render {value!r} as a TOML value"
402
+ raise TypeError(msg)
403
+
404
+
405
+ def to_yaml_value(value: Any) -> str:
406
+ """Render a JSON value as a YAML flow value.
407
+
408
+ >>> to_yaml_value({"name": "demo", "paths": ["a", "b"], "strict": True, "v": "1.0"})
409
+ '{name: demo, paths: [a, b], strict: true, v: "1.0"}'
410
+ """
411
+ if value is None:
412
+ return "null"
413
+ if isinstance(value, bool):
414
+ return "true" if value else "false"
415
+ if isinstance(value, int | float):
416
+ return repr(value)
417
+ if isinstance(value, str):
418
+ return _to_yaml_key(value)
419
+ if isinstance(value, list):
420
+ return "[" + ", ".join(to_yaml_value(v) for v in value) + "]"
421
+ if isinstance(value, dict):
422
+ items = ", ".join(
423
+ f"{_to_yaml_key(k)}: {to_yaml_value(v)}" for k, v in value.items()
424
+ )
425
+ return f"{{{items}}}"
426
+ msg = f"Cannot render {value!r} as a YAML value"
427
+ raise TypeError(msg)
428
+
429
+
430
+ def _to_json(value: Any) -> str:
431
+ try:
432
+ return json.dumps(value, ensure_ascii=False)
433
+ except TypeError as exc:
434
+ msg = f"Cannot render {value!r} as a JSON value"
435
+ raise TypeError(msg) from exc
436
+
437
+
438
+ _BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
439
+
440
+
441
+ def _to_toml_key(key: str) -> str:
442
+ """Quote a key if it is not a valid bare TOML key.
443
+
444
+ >>> _to_toml_key("web-root"), _to_toml_key("web.example.edu")
445
+ ('web-root', '"web.example.edu"')
446
+ """
447
+ if _BARE_KEY.match(key):
448
+ return key
449
+ return _to_json(key)
450
+
451
+
452
+ _PLAIN_YAML = re.compile(r"^[A-Za-z_][A-Za-z0-9_.\-/]*$")
453
+ _YAML_KEYWORDS = frozenset({
454
+ "true",
455
+ "false",
456
+ "yes",
457
+ "no",
458
+ "on",
459
+ "off",
460
+ "null",
461
+ "y",
462
+ "n",
463
+ })
464
+
465
+
466
+ def _to_yaml_key(text: str) -> str:
467
+ """Quote a string unless YAML reads it unquoted as the same string.
468
+
469
+ >>> _to_yaml_key("web.example.edu"), _to_yaml_key("no"), _to_yaml_key("a b")
470
+ ('web.example.edu', '"no"', '"a b"')
471
+ """
472
+ if _PLAIN_YAML.match(text) and text.lower() not in _YAML_KEYWORDS:
473
+ return text
474
+ return _to_json(text)
475
+
476
+
477
+ def create_literal_block(
478
+ settings: Sequence[Entry], fmt: str, rawsource: str
479
+ ) -> nodes.Element:
480
+ """Build the example as a code block in which each key is an anchor.
481
+
482
+ The anchor is what sphinx-tippy attaches the tooltip to; it refers to itself so that
483
+ the key doubles as a permalink to the setting.
484
+
485
+ The example is highlighted as a whole, so that the lexer sees the full context, and
486
+ the tokens of each key are then wrapped in its anchor.
487
+
488
+ Sphinx highlights a literal block again when its ``rawsource`` equals its text,
489
+ which would drop the inline nodes built here. A ``rawsource`` that differs from the
490
+ text, such as the directive name, marks the block as a parsed literal instead. The
491
+ surrounding containers give the block the class names that themes and
492
+ sphinx-copybutton expect from a regular code block.
493
+ """
494
+ fragments = list(iter_fragments(settings, fmt))
495
+ tokens = _highlight("".join(text for text, _ in fragments), fmt)
496
+ block = nodes.literal_block(rawsource, "")
497
+ for text, setting in fragments:
498
+ inlines = _take(tokens, len(text))
499
+ if setting is None:
500
+ block += inlines
501
+ continue
502
+ block += nodes.reference(
503
+ "",
504
+ "",
505
+ *inlines,
506
+ classes=[CSS_CLASS],
507
+ ids=[setting.anchor],
508
+ refid=setting.anchor,
509
+ )
510
+ highlight = nodes.container("", block, classes=["highlight"])
511
+ return nodes.container("", highlight, classes=[f"highlight-{fmt}", "notranslate"])
512
+
513
+
514
+ def _take(tokens: list[nodes.inline], length: int) -> list[nodes.inline]:
515
+ """Remove the first ``length`` characters from the tokens, splitting one if needed.
516
+
517
+ >>> tokens = _highlight("port = 80", "toml")
518
+ >>> [node.astext() for node in _take(tokens, 6)], tokens[0].astext()
519
+ (['port', ' ', '='], ' ')
520
+ """
521
+ taken: list[nodes.inline] = []
522
+ while length > 0:
523
+ token = tokens.pop(0)
524
+ text = token.astext()
525
+ if len(text) > length:
526
+ classes = token["classes"]
527
+ tokens.insert(0, nodes.inline("", text[length:], classes=classes))
528
+ token = nodes.inline("", text[:length], classes=classes)
529
+ text = text[:length]
530
+ taken.append(token)
531
+ length -= len(text)
532
+ return taken
533
+
534
+
535
+ def _highlight(source: str, fmt: str) -> list[nodes.inline]:
536
+ r"""Tokenize source code into inline nodes that carry the CSS classes of Pygments.
537
+
538
+ >>> [(node.astext(), node["classes"]) for node in _highlight("port = 80", "toml")]
539
+ [('port', ['n']), (' ', ['w']), ('=', ['o']), (' ', ['w']), ('80', ['mi'])]
540
+ """
541
+ lexer = get_lexer_by_name(fmt, stripnl=False, ensurenl=False)
542
+ return [
543
+ nodes.inline(
544
+ "", text, classes=[css_class] if (css_class := _css_class(t)) else []
545
+ )
546
+ for _, t, text in lexer.get_tokens_unprocessed(source)
547
+ if text
548
+ ]
549
+
550
+
551
+ def _css_class(token_type: _TokenType | None) -> str:
552
+ """Look up the CSS class of a token type as the Pygments HTML formatter does.
553
+
554
+ >>> from pygments import token
555
+ >>> _css_class(token.Name), _css_class(token.String.Double)
556
+ ('n', 's2')
557
+ """
558
+ while token_type is not None and token_type not in STANDARD_TYPES:
559
+ token_type = token_type.parent
560
+ return STANDARD_TYPES.get(token_type, "")
561
+
562
+
563
+ _LITERAL_PATTERN = re.compile(r"``(?P<text>[^`]+)``")
564
+
565
+
566
+ def to_tooltip(description: str) -> str:
567
+ """Convert a schema description to the HTML shown in its tooltip.
568
+
569
+ Only inline literals are supported, as that is the reStructuredText markup that
570
+ descriptions generated from Python docstrings typically use.
571
+
572
+ >>> to_tooltip("Arguments for ``pytest``, such as ``-n auto``.")
573
+ '<p>Arguments for <code>pytest</code>, such as <code>-n auto</code>.</p>'
574
+ """
575
+ escaped = html.escape(description)
576
+ body = _LITERAL_PATTERN.sub(r"<code>\g<text></code>", escaped)
577
+ return f"<p>{body}</p>"
578
+
579
+
580
+ def _entry_tooltip(entry: Entry) -> str:
581
+ """Create the tooltip of a key, with the values that the example does not show.
582
+
583
+ The tooltip is empty if there is nothing to show, in which case the key gets none.
584
+
585
+ >>> _entry_tooltip(Setting((), "mode", "a", "Mode.", allowed=("a", "b")))
586
+ '<p>Mode.</p><p>Allowed values: <code>&quot;a&quot;</code>, <code>&quot;b&quot;</code></p>'
587
+ >>> _entry_tooltip(Setting((), "port", 80, "", examples=(80, 8080)))
588
+ '<p>Examples: <code>8080</code></p>'
589
+ >>> _entry_tooltip(Table((), "format", ""))
590
+ ''
591
+ """
592
+ tooltip = to_tooltip(entry.description) if entry.description else ""
593
+ if isinstance(entry, Table):
594
+ return tooltip
595
+ if entry.allowed:
596
+ tooltip += f"<p>Allowed values: {_code_list(entry.allowed)}</p>"
597
+ examples = [e for e in entry.examples if e is not None and e != entry.value]
598
+ if examples:
599
+ tooltip += f"<p>Examples: {_code_list(examples)}</p>"
600
+ return tooltip
601
+
602
+
603
+ def _code_list(values: Sequence[Any]) -> str:
604
+ return ", ".join(_code(v) for v in values)
605
+
606
+
607
+ def _code(value: Any) -> str:
608
+ return f"<code>{html.escape(json.dumps(value))}</code>"
609
+
610
+
611
+ def _get_tips(env: BuildEnvironment) -> dict[str, dict[str, str]]:
612
+ """Get the tooltips per document, which are stored in the build environment."""
613
+ if not hasattr(env, "config_schema_tips"):
614
+ env.config_schema_tips = {} # ty: ignore[invalid-assignment]
615
+ return env.config_schema_tips # ty: ignore[unresolved-attribute]
616
+
617
+
618
+ def _purge_tips(_app: Sphinx, env: BuildEnvironment, docname: str) -> None:
619
+ _get_tips(env).pop(docname, None)
620
+
621
+
622
+ def _merge_tips(
623
+ _app: Sphinx, env: BuildEnvironment, docnames: set[str], other: BuildEnvironment
624
+ ) -> None:
625
+ other_tips = _get_tips(other)
626
+ _get_tips(env).update({d: other_tips[d] for d in docnames if d in other_tips})
627
+
628
+
629
+ def _register_tips(
630
+ app: Sphinx,
631
+ pagename: str,
632
+ _templatename: str,
633
+ _context: dict[str, Any],
634
+ _doctree: nodes.document | None,
635
+ ) -> None:
636
+ """Feed the tooltips to sphinx-tippy before it collects the tips of a page."""
637
+ tips = _get_tips(app.env).get(pagename)
638
+ if tips:
639
+ app.env.tippy_config.custom_tips.update(tips) # ty: ignore[unresolved-attribute]
640
+
641
+
642
+ def setup(app: Sphinx) -> dict[str, Any]:
643
+ app.setup_extension("sphinx_tippy")
644
+ app.add_directive("config-schema", ConfigSchemaDirective)
645
+ app.connect("builder-inited", _add_static_path)
646
+ app.connect("env-purge-doc", _purge_tips)
647
+ app.connect("env-merge-info", _merge_tips)
648
+ app.connect("html-page-context", _register_tips, priority=400)
649
+ return {
650
+ "parallel_read_safe": True,
651
+ "parallel_write_safe": True,
652
+ "version": importlib.metadata.version("sphinx-config-schema"),
653
+ }
654
+
655
+
656
+ def _add_static_path(app: Sphinx) -> None:
657
+ app.config.html_static_path.append(str(STATIC_DIR))
658
+ app.add_css_file("config-schema.css")
@@ -0,0 +1,12 @@
1
+ .tippy-box {
2
+ background-color: var(--pst-color-surface, #333);
3
+ border: 1px solid var(--pst-color-border, transparent);
4
+ color: var(--pst-color-text-base, #fff);
5
+ }
6
+
7
+ .tippy-arrow {
8
+ color: var(--pst-color-surface, #333);
9
+ }
10
+ .tippy-content > :last-child {
11
+ margin-bottom: 0;
12
+ }
File without changes
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.0.1'
22
+ __version_tuple__ = version_tuple = (0, 0, 1)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: sphinx-config-schema
3
+ Version: 0.0.1
4
+ Summary: Render a JSON schema as a copyable TOML, YAML, or JSON example with tooltips in Sphinx
5
+ Author: Common Partial Wave Analysis
6
+ License-Expression: MIT
7
+ Project-URL: Changelog, https://github.com/ComPWA/sphinx-config-schema/releases
8
+ Project-URL: Documentation, https://compwa.github.io/sphinx-config-schema
9
+ Project-URL: Issues, https://github.com/ComPWA/sphinx-config-schema/issues
10
+ Project-URL: Source, https://github.com/ComPWA/sphinx-config-schema
11
+ Keywords: json-schema,sphinx,toml,yaml
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: Sphinx :: Extension
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
20
+ Classifier: Topic :: Documentation :: Sphinx
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: docutils
26
+ Requires-Dist: pygments
27
+ Requires-Dist: sphinx
28
+ Requires-Dist: sphinx-tippy
29
+ Requires-Dist: typing-extensions
30
+ Dynamic: license-file
31
+
32
+ # sphinx-config-schema
33
+
34
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
35
+ [![PyPI package](https://badge.fury.io/py/sphinx-config-schema.svg)](https://pypi.org/project/sphinx-config-schema)
36
+ [![Supported Python versions](https://img.shields.io/pypi/pyversions/sphinx-config-schema)](https://pypi.org/project/sphinx-config-schema)
37
+ [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/ComPWA/sphinx-config-schema/main.svg)](https://results.pre-commit.ci/latest/github/ComPWA/sphinx-config-schema/main)
38
+ [![CI status](https://github.com/ComPWA/sphinx-config-schema/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ComPWA/sphinx-config-schema/actions/workflows/ci.yml?query=branch%3Amain)
39
+ [![Spelling checked](https://img.shields.io/badge/cspell-checked-brightgreen.svg)](https://github.com/streetsidesoftware/cspell/tree/main/packages/cspell)
40
+ [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier)
41
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
42
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
43
+ [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
44
+
45
+ `sphinx-config-schema` is a [Sphinx](https://www.sphinx-doc.org) extension that renders a [JSON schema](https://json-schema.org) as a copyable TOML, YAML, or JSON example. Each key shows the description of its setting as a tooltip and links to itself as a permalink.
46
+
47
+ ```bash
48
+ pip install sphinx-config-schema
49
+ ```
50
+
51
+ Then add the extension to your `conf.py`:
52
+
53
+ ```python
54
+ extensions = [
55
+ "sphinx_config_schema",
56
+ ]
57
+ ```
58
+
59
+ and render a schema in a reStructuredText page:
60
+
61
+ ```rst
62
+ .. config-schema:: example.schema.json
63
+ :caption: pyproject.toml
64
+ :table: tool.example
65
+ :format: toml
66
+ ```
67
+
68
+ or in a MyST Markdown page:
69
+
70
+ ````md
71
+ ```{config-schema} example.schema.json
72
+ :caption: pyproject.toml
73
+ :table: tool.example
74
+ :format: toml
75
+ ```
76
+ ````
77
+
78
+ See [compwa.github.io/sphinx-config-schema](https://compwa.github.io/sphinx-config-schema) for the documentation and a rendered example.
@@ -0,0 +1,9 @@
1
+ sphinx_config_schema/__init__.py,sha256=1usCvRfpB-O0LDSujXuvFLrQN4iFMFN_qPFzTKjeuaE,22953
2
+ sphinx_config_schema/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ sphinx_config_schema/version.py,sha256=8OsTLsIVB9D0HdPTmt5rVwyVUBe9xTVGkRslXicxzkM,520
4
+ sphinx_config_schema/_static/config-schema.css,sha256=KRilB3qebc6YM-z_VbInZzFbDXTCAf2mqDwnp_nbhIY,280
5
+ sphinx_config_schema-0.0.1.dist-info/licenses/LICENSE,sha256=dzJ_ZtrgvJ92Mgk-EVPf9wIJ-_WaoWS78_M3NZmlgzY,1063
6
+ sphinx_config_schema-0.0.1.dist-info/METADATA,sha256=Par95Cl0ewXSRLNvPhwZavpfwkfG3lA21KBjGiqjmuw,3659
7
+ sphinx_config_schema-0.0.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ sphinx_config_schema-0.0.1.dist-info/top_level.txt,sha256=VmphkaEXe0yYlgr8zDyLB7y8GegXYyLmyXCHGziBQgM,21
9
+ sphinx_config_schema-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ComPWA
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ sphinx_config_schema