Skip to content

Commit 4c60f93

Browse files
committed
docs: create sphinx extension to format schemas nicely
1 parent 5a3a8cd commit 4c60f93

File tree

10 files changed

+275
-2
lines changed

10 files changed

+275
-2
lines changed

.github/workflows/pypi-publish.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,26 @@ jobs:
4646
uses: pypa/gh-action-pypi-publish@release/v1
4747
with:
4848
packages-dir: packages/pytest-taskgraph/dist
49+
pypi-publish-sphinx-taskgraph:
50+
name: upload release to PyPI
51+
if: startsWith(github.ref, 'refs/tags/sphinx-taskgraph')
52+
runs-on: ubuntu-latest
53+
environment: sphinx-taskgraph-release
54+
permissions:
55+
id-token: write
56+
steps:
57+
- name: Checkout sources
58+
uses: actions/checkout@v4
59+
- uses: actions/setup-python@v5
60+
with:
61+
python-version: '3.11'
62+
cache: 'pip'
63+
- name: Build sphinx-taskgraph package distributions
64+
working-directory: packages/sphinx-taskgraph
65+
run: |
66+
pip install build
67+
python -m build
68+
- name: Publish package distributions to PyPI
69+
uses: pypa/gh-action-pypi-publish@release/v1
70+
with:
71+
packages-dir: packages/sphinx-taskgraph/dist

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"sphinx.ext.napoleon",
3333
"sphinxarg.ext",
3434
"sphinxcontrib.mermaid",
35+
"sphinx_taskgraph",
3536
]
3637

3738
# Add any paths that contain templates here, relative to this directory.

packages/sphinx-taskgraph/README.md

Whitespace-only changes.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[project]
2+
name = "sphinx-taskgraph"
3+
version = "0.1.0"
4+
description = "Sphinx extensions to assist with Taskgraph documentation"
5+
readme = "README.md"
6+
authors = [
7+
{ name = "Andrew Halberstadt", email = "ahal@mozilla.com" }
8+
]
9+
requires-python = ">=3.8"
10+
dependencies = [
11+
"sphinx",
12+
"voluptuous",
13+
]
14+
15+
[build-system]
16+
requires = ["hatchling"]
17+
build-backend = "hatchling.build"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# This Source Code Form is subject to the terms of the Mozilla Public
2+
# License, v. 2.0. If a copy of the MPL was not distributed with this
3+
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
"""
5+
Sphinx extension for rendering taskgraph schemas using autodoc with custom formatting.
6+
"""
7+
8+
9+
def setup(app):
10+
"""
11+
Entry point for the Sphinx extension.
12+
"""
13+
from .autoschema import SchemaDocumenter
14+
15+
# Register the custom autodocumenter.
16+
app.add_autodocumenter(SchemaDocumenter)
17+
18+
# Return metadata about the extension.
19+
return {
20+
"version": "1.0",
21+
"parallel_read_safe": True,
22+
"parallel_write_safe": True,
23+
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# This Source Code Form is subject to the terms of the Mozilla Public
2+
# License, v. 2.0. If a copy of the MPL was not distributed with this
3+
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
5+
"""
6+
Custom autodoc integration for rendering `taskgraph.util.schema.Schema` instances.
7+
"""
8+
9+
import typing as t
10+
from textwrap import dedent
11+
from types import FunctionType, NoneType
12+
13+
from sphinx.ext.autodoc import ClassDocumenter
14+
from voluptuous import All, Any, Exclusive, Length, Marker, Schema
15+
16+
if t.TYPE_CHECKING:
17+
from docutils.statemachine import StringList
18+
19+
20+
def name(item: t.Any) -> str:
21+
if isinstance(item, (bool, int, str, NoneType)):
22+
return str(item)
23+
24+
if isinstance(item, (type, FunctionType)):
25+
return item.__name__
26+
27+
if isinstance(item, Length):
28+
return repr(item)
29+
30+
return item.__class__.__name__
31+
32+
33+
def get_type_str(obj: t.Any) -> tuple[str, bool]:
34+
# Handle simple subtypes for lists and dicts so that we display
35+
# `type: dict[str, Any]` inline rather than expanded out at the
36+
# bottom.
37+
subtype_str = ""
38+
if isinstance(obj, (list, Any)):
39+
items = obj
40+
if isinstance(items, Any):
41+
items = items.validators
42+
43+
if all(not isinstance(i, (list, dict, Any)) for i in items):
44+
subtype_str = f"[{' | '.join(name(i) for i in items)}]"
45+
46+
elif isinstance(obj, dict):
47+
if all(not isinstance(k, (Marker, Any)) for k in obj.keys()) and all(
48+
not isinstance(v, (list, dict, Any, All)) for v in obj.values()
49+
):
50+
subtype_str = f"[{' | '.join(name(k) for k in obj.keys())}, {' | '.join({name(v) for v in obj.values()})}]"
51+
52+
return (
53+
f"{name(obj)}{subtype_str}",
54+
bool(subtype_str),
55+
)
56+
57+
58+
def iter_schema_lines(obj: t.Any, indent: int = 0) -> t.Generator[str, None, None]:
59+
prefix = " " * indent
60+
arg_prefix = " " * (indent + 2)
61+
62+
if isinstance(obj, Schema):
63+
# Display whether extra keys are allowed
64+
extra = obj._extra_to_name[obj.extra].split("_")[0].lower()
65+
yield f"{prefix}extra keys: {extra}{'d' if extra[-1] == 'e' else 'ed'}"
66+
yield ""
67+
yield from iter_schema_lines(obj.schema, indent)
68+
return
69+
70+
if isinstance(obj, dict):
71+
for i, (key, value) in enumerate(obj.items()):
72+
subtypes_handled = False
73+
74+
# Handle optionally_keyed_by
75+
keyed_by_str = ""
76+
if isinstance(value, FunctionType):
77+
keyed_by_str = ", ".join(getattr(value, "fields", ""))
78+
value = getattr(value, "schema", value)
79+
80+
# If the key is a marker (aka Required, Optional, Exclusive),
81+
# display additional information if available, like the
82+
# description.
83+
if isinstance(key, Marker):
84+
# Add marker name and group for Exclusive.
85+
marker_str = f"{name(key).lower()}"
86+
if isinstance(key, Exclusive):
87+
marker_str += f"={key.group_of_exclusion}"
88+
89+
# Make it clear if an allowed value must be constant.
90+
type_ = "type"
91+
if isinstance(obj, (bool, int, str, NoneType)):
92+
type_ = "constant"
93+
94+
# Create the key header + type lines.
95+
yield f"{prefix}{key.schema} ({marker_str})"
96+
type_str, subtypes_handled = get_type_str(value)
97+
yield f"{arg_prefix}{type_}: {type_str}"
98+
99+
# Create the keyed-by line if needed.
100+
if keyed_by_str:
101+
yield ""
102+
yield f"{arg_prefix}optionally keyed by: {keyed_by_str}"
103+
104+
# Create the description if needed.
105+
if desc := getattr(key, "description", None):
106+
yield ""
107+
yield arg_prefix + f"\n{arg_prefix}".join(
108+
dedent(l) for l in desc.splitlines()
109+
)
110+
111+
elif isinstance(key, Any):
112+
type_str, subtypes_handled = get_type_str(key)
113+
yield f"{prefix}{type_str}: {name(value)}"
114+
115+
else:
116+
# If not a marker, simply create a `key: value` line.
117+
yield f"{prefix}{name(key)}: {name(value)}"
118+
119+
yield ""
120+
121+
# Recurse into values that contain additional schema, unless the
122+
# types for said containers are simple and we handled them in the
123+
# type line.
124+
if isinstance(value, (list, dict, All, Any)) and not subtypes_handled:
125+
yield from iter_schema_lines(value, indent + 2)
126+
127+
elif isinstance(obj, (list, All, Any)):
128+
# Recurse into list, All and Any markers.
129+
op = "or" if isinstance(obj, (list, Any)) else "and"
130+
if isinstance(obj, (All, Any)):
131+
obj = obj.validators
132+
133+
for i, item in enumerate(obj):
134+
if i != 0:
135+
yield ""
136+
yield f"{prefix}{op}"
137+
yield ""
138+
139+
type_, subtypes_handled = get_type_str(item)
140+
yield f"{arg_prefix}{type_}"
141+
yield ""
142+
143+
if isinstance(item, (list, dict, All, Any)) and not subtypes_handled:
144+
yield from iter_schema_lines(item, indent + 2)
145+
else:
146+
# Create line for leaf values.
147+
yield prefix + name(obj)
148+
yield ""
149+
150+
151+
class SchemaDocumenter(ClassDocumenter):
152+
"""
153+
Custom Sphinx autodocumenter for instances of `Schema`.
154+
"""
155+
156+
# Document only `Schema` instances.
157+
objtype = "schema"
158+
directivetype = "class"
159+
content_indent = " "
160+
161+
# Priority over the default ClassDocumenter.
162+
priority = 10
163+
164+
@classmethod
165+
def can_document_member(
166+
cls, member: object, membername: str, isattr: bool, parent: object
167+
) -> bool:
168+
return isinstance(member, Schema)
169+
170+
def add_directive_header(self, sig: str) -> None:
171+
super().add_directive_header(sig)
172+
173+
def add_content(
174+
self, more_content: t.Union["StringList", None], no_docstring: bool = False
175+
) -> None:
176+
# Format schema rules recursively.
177+
for line in iter_schema_lines(self.object):
178+
self.add_line(line, "")
179+
self.add_line("", "")

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,13 @@ Issues = "https://github.com/taskcluster/taskgraph/issues"
4646

4747
[tool.uv.sources]
4848
pytest-taskgraph = { workspace = true }
49+
sphinx-taskgraph = { workspace = true }
4950

5051
[tool.uv.workspace]
51-
members = ["packages/pytest-taskgraph"]
52+
members = [
53+
"packages/pytest-taskgraph",
54+
"packages/sphinx-taskgraph",
55+
]
5256

5357
[tool.uv]
5458
dev-dependencies = [
@@ -64,6 +68,7 @@ dev-dependencies = [
6468
"sphinx-autobuild",
6569
"sphinx-argparse",
6670
"sphinx-book-theme >=1",
71+
"sphinx-taskgraph",
6772
"sphinxcontrib-mermaid",
6873
"zstandard",
6974
]

src/taskgraph/transforms/from_deps.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
and copy attributes (if `copy-attributes` is True).
4040
""".lstrip()
4141
),
42-
): list,
42+
): [str],
4343
Optional(
4444
"set-name",
4545
description=dedent(

src/taskgraph/util/schema.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ def validator(obj):
5959
return res
6060
return Schema(schema)(obj)
6161

62+
# set to assist autodoc
63+
setattr(validator, "schema", schema)
64+
setattr(validator, "fields", fields)
6265
return validator
6366

6467

uv.lock

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)