Skip to content

Commit de0ad97

Browse files
committed
[docs] Overhaul documentation for #589
The documentation was in dire need of some improvements as the volume of features PySceneDetect supports has grown. This splits out the CLI and API docs as separate entities, while making it easier to switch between the two. It also improves the ability to find config file docs and sections.
1 parent 36ca022 commit de0ad97

35 files changed

Lines changed: 3513 additions & 237 deletions

‎.github/workflows/check-docs.yml‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ on:
77
paths:
88
- docs/**
99
- scenedetect/**
10+
- scenedetect.cfg
1011
- website/**
1112
push:
1213
paths:
1314
- docs/**
1415
- scenedetect/**
16+
- scenedetect.cfg
1517
- website/**
1618
branches:
1719
- main
@@ -46,9 +48,11 @@ jobs:
4648
- name: Check CLI Documentation
4749
shell: bash
4850
run: |
49-
if [[ `git status --porcelain=1 | wc -l` -ne 0 ]]; then
50-
echo "CLI documentation is of date: docs/cli.rst does not match output after running docs/generate_cli_docs.py!"
51-
echo "Re-run `python docs/generate_cli_docs.py` to update and commit the result."
51+
python docs/generate_cli_docs.py
52+
if [[ $(git status --porcelain=1 -- docs | wc -l) -ne 0 ]]; then
53+
git --no-pager diff -- docs
54+
echo 'CLI documentation is out of date: the generated docs do not match the output of docs/generate_cli_docs.py!'
55+
echo 'Re-run "python docs/generate_cli_docs.py" and commit the result.'
5256
exit 1
5357
fi
5458

‎.github/workflows/generate-docs.yml‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ on:
88
- 'releases/**' # docs/**
99
paths:
1010
- 'docs/**'
11+
- 'scenedetect.cfg'
1112
workflow_dispatch:
1213

1314
jobs:
@@ -59,9 +60,11 @@ jobs:
5960
- name: Check CLI Documentation
6061
shell: bash
6162
run: |
62-
if [[ `git status --porcelain=1 | wc -l` -ne 0 ]]; then
63-
echo "CLI documentation is of date: docs/cli.rst does not match output after running docs/generate_cli_docs.py!"
64-
echo "Re-run `python docs/generate_cli_docs.py` to update and commit the result."
63+
python docs/generate_cli_docs.py
64+
if [[ $(git status --porcelain=1 -- docs | wc -l) -ne 0 ]]; then
65+
git --no-pager diff -- docs
66+
echo 'CLI documentation is out of date: the generated docs do not match the output of docs/generate_cli_docs.py!'
67+
echo 'Re-run "python docs/generate_cli_docs.py" and commit the result.'
6568
exit 1
6669
fi
6770

‎docs/_ext/section_sidebar.py‎

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
# Copyright (C) 2026 Brandon Castellano <http://www.bcastell.com>.
2+
# PySceneDetect is licensed under the BSD 3-Clause License; see the
3+
# included LICENSE file, or visit one of the above pages for details.
4+
"""Sphinx extension: show only the current section (e.g. CLI or API) in the sidebar.
5+
6+
The root document has one named toctree per section. For every page inside a section, the
7+
sidebar template gets ``section_toctree()``, which renders that section's toctree fully
8+
expanded (other pages to the section's ``maxdepth``, the current page to ``current_maxdepth``),
9+
plus ``sidebar_section`` and ``sidebar_sections`` for a switch between sections. The root
10+
document itself renders every labelled section's tree, each pruned to
11+
``section_sidebar_root_depth`` (by default the section's ``maxdepth``). Pages in no section
12+
(genindex, search, py-modindex) and the singlehtml builder fall back to the regular global
13+
toctree.
14+
15+
Configure with ``section_sidebar_sections`` in conf.py::
16+
17+
section_sidebar_sections = {
18+
"clitoc": ("CLI", "cli", 2, 2), # toctree name: (label, landing doc, depth, cur depth)
19+
"reftoc": (None, None, 2, 2), # no label: every page in the section is a small link
20+
}
21+
section_sidebar_root_depth = {"apitoc": 1} # on the root document: page titles only
22+
23+
A section whose label is ``None`` is not part of the switch; the template gets its pages as
24+
``sidebar_section_docs[name]`` (a list of ``(title, docname)``) and draws them as a row of
25+
smaller links. Its tree is only drawn on its own pages, and then holds just the current page's
26+
headings, since the row already lists the pages.
27+
28+
``section_sidebar_summary`` trims what a page contributes to the tree when it is NOT the
29+
current page: only entries (at any depth) whose text matches one of the glob patterns are
30+
kept, together with the headings above them; an empty list keeps just the page title.
31+
``section_sidebar_page_depth`` overrides the section's current-page depth for one page::
32+
33+
section_sidebar_summary = {
34+
"api/backends": ["VideoStream*"], # glob on the entry text
35+
"api/platform": [], # title only
36+
}
37+
section_sidebar_page_depth = {"api/backends": 3} # classes, but not their members
38+
"""
39+
40+
import fnmatch
41+
42+
from docutils import nodes
43+
from sphinx import addnodes
44+
from sphinx.application import Sphinx
45+
from sphinx.environment.adapters.toctree import TocTree
46+
from sphinx.util import logging
47+
48+
logger = logging.getLogger(__name__)
49+
50+
_LEVEL_PREFIX = "toctree-l"
51+
# Depth the section tree is resolved to before pruning; deeper than any configured depth.
52+
_RESOLVE_DEPTH = 6
53+
54+
55+
def _section_toctree_node(app: Sphinx, pagename: str) -> addnodes.toctree | None:
56+
"""Root-doc toctree node whose entries contain `pagename` or one of its ancestors."""
57+
env = app.env
58+
if pagename not in env.all_docs:
59+
return None
60+
lineage = {pagename, *TocTree(env).get_toctree_ancestors(pagename)}
61+
for node in env.get_doctree(env.config.root_doc).findall(addnodes.toctree):
62+
if lineage.intersection(node["includefiles"]):
63+
return node
64+
return None
65+
66+
67+
def _entry_target(item: nodes.list_item) -> str:
68+
"""URI a toctree entry links to (page-relative, as rendered), without the anchor."""
69+
ref = next(item.findall(nodes.reference), None)
70+
return ref.get("refuri", "").split("#", 1)[0] if ref is not None else ""
71+
72+
73+
def _drop_sublists(item: nodes.list_item) -> None:
74+
item.children = [c for c in item.children if not isinstance(c, nodes.bullet_list)]
75+
76+
77+
def _entry_text(item: nodes.list_item) -> str:
78+
"""Text of the entry itself (its first paragraph), not of the entries nested under it."""
79+
return item[0].astext() if len(item) else ""
80+
81+
82+
def _apply_summary(top: nodes.list_item, patterns: list[str]) -> bool:
83+
"""Keep only the entries under `top` that match `patterns`, plus the headings above them.
84+
85+
Returns False if a non-empty pattern list matched nothing.
86+
"""
87+
matches = [
88+
item
89+
for item in top.findall(nodes.list_item)
90+
if item is not top and any(fnmatch.fnmatchcase(_entry_text(item), p) for p in patterns)
91+
]
92+
keep: set[int] = set()
93+
for item in matches:
94+
_drop_sublists(item)
95+
node = item
96+
while node is not top:
97+
keep.add(id(node))
98+
node = node.parent
99+
for item in list(top.findall(nodes.list_item)):
100+
if item is not top and id(item) not in keep and item.parent is not None:
101+
item.parent.remove(item)
102+
for empty in [bl for bl in top.findall(nodes.bullet_list) if not bl.children]:
103+
empty.parent.remove(empty)
104+
return bool(matches) or not patterns
105+
106+
107+
def _entry_level(item: nodes.list_item) -> int | None:
108+
return next(
109+
(int(c[len(_LEVEL_PREFIX) :]) for c in item["classes"] if c.startswith(_LEVEL_PREFIX)),
110+
None,
111+
)
112+
113+
114+
def _prune_depth(top: nodes.list_item, maxdepth: int) -> None:
115+
"""Drop the sub-lists of every entry under `top` at `maxdepth` or deeper."""
116+
for item in list(top.findall(nodes.list_item)):
117+
level = _entry_level(item)
118+
if level is not None and level >= maxdepth:
119+
_drop_sublists(item)
120+
121+
122+
def _keep_current_headings(tree: nodes.Element) -> None:
123+
"""Replace the section's page list with the headings of the current page, one level up.
124+
125+
For sections without a switch label: the link row under the switch already lists their
126+
pages, so the tree repeats neither the current page's title nor its siblings.
127+
"""
128+
for top in list(tree.findall(nodes.list_item)):
129+
if "toctree-l1" not in top["classes"] or not top.get("iscurrent"):
130+
continue
131+
pages = top.parent
132+
headings = next((c for c in top.children if isinstance(c, nodes.bullet_list)), None)
133+
if headings is None:
134+
pages.parent.remove(pages)
135+
return
136+
for item in headings.findall(nodes.list_item):
137+
level = _entry_level(item)
138+
if level is not None:
139+
item["classes"].remove(f"{_LEVEL_PREFIX}{level}")
140+
item["classes"].append(f"{_LEVEL_PREFIX}{level - 1}")
141+
pages.replace_self(headings)
142+
return
143+
144+
145+
def _prune_tree(
146+
tree: nodes.Element, maxdepth: int, current_maxdepth: int, summary: dict[str, list[str]]
147+
) -> None:
148+
"""Trim the resolved section tree to the configured depths.
149+
150+
The current page is cut at `current_maxdepth`. Every other page is cut at `maxdepth`, or,
151+
if `summary` (keyed by rendered URI) has patterns for it, reduced to the matching entries
152+
(none for an empty list) plus the headings above them.
153+
"""
154+
for top in list(tree.findall(nodes.list_item)):
155+
if "toctree-l1" not in top["classes"]:
156+
continue
157+
if top.get("iscurrent"):
158+
_prune_depth(top, current_maxdepth)
159+
continue
160+
patterns = summary.get(_entry_target(top))
161+
if patterns is None:
162+
_prune_depth(top, maxdepth)
163+
elif not _apply_summary(top, patterns):
164+
logger.warning(
165+
"section_sidebar_summary: no entry of %s matches %r",
166+
_entry_target(top),
167+
patterns,
168+
once=True,
169+
)
170+
171+
172+
def _add_section_toctree(app: Sphinx, pagename: str, templatename, context, doctree) -> None:
173+
sections = app.config.section_sidebar_sections
174+
if app.builder.name == "singlehtml":
175+
# Everything is one page: the switch would link to itself, so hide it.
176+
sections = {}
177+
node = _section_toctree_node(app, pagename) if sections else None
178+
names = node.parent.get("names", []) if node is not None else []
179+
section = next((n for n in names if n in sections), None)
180+
root_nodes: list[tuple[str, addnodes.toctree]] = []
181+
if sections and node is None and pagename == app.env.config.root_doc:
182+
for root_node in app.env.get_doctree(pagename).findall(addnodes.toctree):
183+
name = next((n for n in root_node.parent.get("names", []) if n in sections), None)
184+
if name is not None:
185+
root_nodes.append((name, root_node))
186+
context["sidebar_section"] = section
187+
context["sidebar_sections"] = sections
188+
context["sidebar_section_docs"] = _section_docs(app, sections) if sections else {}
189+
190+
def render(name: str, toctree_node: addnodes.toctree) -> str:
191+
_, _, maxdepth, current_maxdepth = sections[name]
192+
current_maxdepth = app.config.section_sidebar_page_depth.get(pagename, current_maxdepth)
193+
tree = TocTree(app.env).resolve(
194+
pagename,
195+
app.builder,
196+
toctree_node,
197+
prune=True,
198+
maxdepth=_RESOLVE_DEPTH,
199+
collapse=False,
200+
includehidden=True,
201+
)
202+
if tree is None:
203+
return ""
204+
# The switch and link row above the tree already name the sections; the toctree
205+
# caption would only repeat them (the captions still head the global toctree used by
206+
# the fallback pages and the singlehtml build).
207+
for child in [c for c in tree.children if isinstance(c, nodes.title)]:
208+
tree.remove(child)
209+
root_depth = app.config.section_sidebar_root_depth
210+
if pagename == app.env.config.root_doc and name in root_depth:
211+
# The root page has no current entry; one depth for every page, no summaries.
212+
_prune_tree(tree, root_depth[name], root_depth[name], {})
213+
return app.builder.render_partial(tree)["fragment"]
214+
# The resolved tree links by page-relative URI, so key the summary patterns the same way.
215+
summary = {
216+
app.builder.get_relative_uri(pagename, docname): patterns
217+
for docname, patterns in app.config.section_sidebar_summary.items()
218+
}
219+
_prune_tree(tree, maxdepth, current_maxdepth, summary)
220+
if sections[name][0] is None:
221+
_keep_current_headings(tree)
222+
return app.builder.render_partial(tree)["fragment"]
223+
224+
def section_toctree() -> str:
225+
if section is not None:
226+
return render(section, node)
227+
return "".join(
228+
render(name, root_node) for name, root_node in root_nodes if sections[name][0]
229+
)
230+
231+
context["section_toctree"] = section_toctree
232+
233+
234+
def _section_docs(app: Sphinx, sections: dict) -> dict[str, list[tuple[str, str]]]:
235+
"""(title, docname) of every page listed by each section's toctree, in order."""
236+
env = app.env
237+
docs: dict[str, list[tuple[str, str]]] = {}
238+
for node in env.get_doctree(env.config.root_doc).findall(addnodes.toctree):
239+
name = next((n for n in node.parent.get("names", []) if n in sections), None)
240+
if name is not None:
241+
docs[name] = [
242+
(env.titles[d].astext(), d) for d in node["includefiles"] if d in env.titles
243+
]
244+
return docs
245+
246+
247+
def _check_config(app: Sphinx, env) -> None:
248+
"""Warn about summary / depth entries that name pages or sections which do not exist."""
249+
configured = set(app.config.section_sidebar_summary)
250+
configured |= set(app.config.section_sidebar_page_depth)
251+
for docname in sorted(configured - set(env.all_docs)):
252+
logger.warning("section_sidebar: %r is not a document in this project", docname)
253+
sections = set(app.config.section_sidebar_sections)
254+
for name in sorted(set(app.config.section_sidebar_root_depth) - sections):
255+
logger.warning("section_sidebar_root_depth: %r is not a configured section", name)
256+
257+
258+
def setup(app: Sphinx) -> dict:
259+
app.add_config_value("section_sidebar_sections", {}, "html", dict)
260+
app.add_config_value("section_sidebar_summary", {}, "html", dict)
261+
app.add_config_value("section_sidebar_page_depth", {}, "html", dict)
262+
app.add_config_value("section_sidebar_root_depth", {}, "html", dict)
263+
app.connect("env-check-consistency", _check_config)
264+
app.connect("html-page-context", _add_section_toctree)
265+
return {"parallel_read_safe": True, "parallel_write_safe": True}

0 commit comments

Comments
 (0)