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.
- sphinx_config_schema/__init__.py +658 -0
- sphinx_config_schema/_static/config-schema.css +12 -0
- sphinx_config_schema/py.typed +0 -0
- sphinx_config_schema/version.py +24 -0
- sphinx_config_schema-0.0.1.dist-info/METADATA +78 -0
- sphinx_config_schema-0.0.1.dist-info/RECORD +9 -0
- sphinx_config_schema-0.0.1.dist-info/WHEEL +5 -0
- sphinx_config_schema-0.0.1.dist-info/licenses/LICENSE +21 -0
- sphinx_config_schema-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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>"a"</code>, <code>"b"</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
|
+
[](https://opensource.org/licenses/MIT)
|
|
35
|
+
[](https://pypi.org/project/sphinx-config-schema)
|
|
36
|
+
[](https://pypi.org/project/sphinx-config-schema)
|
|
37
|
+
[](https://results.pre-commit.ci/latest/github/ComPWA/sphinx-config-schema/main)
|
|
38
|
+
[](https://github.com/ComPWA/sphinx-config-schema/actions/workflows/ci.yml?query=branch%3Amain)
|
|
39
|
+
[](https://github.com/streetsidesoftware/cspell/tree/main/packages/cspell)
|
|
40
|
+
[](https://github.com/prettier/prettier)
|
|
41
|
+
[](https://github.com/astral-sh/ruff)
|
|
42
|
+
[](https://github.com/astral-sh/uv)
|
|
43
|
+
[](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,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
|