Skip to content

Commit c0d4870

Browse files
committed
[docs] Fix Improve concordance encapsulation
1 parent b211f0b commit c0d4870

1 file changed

Lines changed: 88 additions & 73 deletions

File tree

‎docs/generate_cli_docs.py‎

Lines changed: 88 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,17 @@
1818
file section, from `CONFIG_MAP` and the shipped template scenedetect.cfg, which must agree
1919
(see `check_template`).
2020
21+
A new command or global option is documented as soon as this script is re-run. Add a row for it
22+
to `CONCORDANCE` to link its Python API; the script warns until it has one.
23+
2124
Run from main repo folder as working directory."""
2225

2326
import inspect
2427
import os
2528
import re
2629
import sys
2730
import typing as ty
28-
from dataclasses import dataclass, field
31+
from dataclasses import dataclass, field, replace
2932
from enum import Enum
3033

3134
# Add parent folder to path so we can resolve `scenedetect` imports.
@@ -110,8 +113,10 @@
110113
# file reference. The reverse links (API pages -> CLI) are hand-written `.. seealso::` blocks in
111114
# docs/api/*.rst; `check_reverse_links` warns when one is missing for a command listed here.
112115
#
113-
# Every visible command and global option needs exactly one row (`check_concordance`); rows that
114-
# have neither are only needed for config file sections and keys that have no CLI option.
116+
# Every visible command and global option should have exactly one row, which says what its Python
117+
# API counterpart is (or, with no `api`, that it has none). One without a row is still documented,
118+
# with no claim about a Python API, and the run warns (`check_concordance`). Rows that name
119+
# neither are only needed for config file sections and keys that have no CLI option.
115120
#
116121

117122

@@ -122,8 +127,10 @@ class Counterpart:
122127
`thing` names the row for readers of this file (it is not emitted). Exactly one of `command`
123128
(a CLI command name), `option` (the Click parameter name of a global `scenedetect` option) or
124129
`cli` (free-form RST) locates the CLI side. `config` is a config file section or
125-
`[section] key`, and `api` is a list of RST snippets (Sphinx roles plus optional argument
126-
names); if `api` is empty, the thing is CLI only.
130+
`[section] key`; leave it unset on a `command` or `option` row, where it is derived (see
131+
`derived_config`), unless the config key has a different name. `api` is a list of RST
132+
snippets (Sphinx roles plus optional argument names); if `api` is empty, the thing is CLI
133+
only. `inferred` marks the stand-in for a command or option that has no row.
127134
"""
128135

129136
thing: str
@@ -132,6 +139,7 @@ class Counterpart:
132139
cli: str | None = None
133140
config: str | None = None
134141
api: tuple[str, ...] = ()
142+
inferred: bool = False
135143

136144

137145
# Short aliases used to keep the table below readable.
@@ -148,31 +156,26 @@ class Counterpart:
148156
Counterpart(
149157
"Fast cuts (adaptive)",
150158
command="detect-adaptive",
151-
config="[detect-adaptive]",
152159
api=(f":class:`~{_DET}.adaptive_detector.AdaptiveDetector`",),
153160
),
154161
Counterpart(
155162
"Fast cuts (content)",
156163
command="detect-content",
157-
config="[detect-content]",
158164
api=(f":class:`~{_DET}.content_detector.ContentDetector`",),
159165
),
160166
Counterpart(
161167
"Fast cuts (perceptual hash)",
162168
command="detect-hash",
163-
config="[detect-hash]",
164169
api=(f":class:`~{_DET}.hash_detector.HashDetector`",),
165170
),
166171
Counterpart(
167172
"Fast cuts (histogram)",
168173
command="detect-hist",
169-
config="[detect-hist]",
170174
api=(f":class:`~{_DET}.histogram_detector.HistogramDetector`",),
171175
),
172176
Counterpart(
173177
"Fades in/out (threshold)",
174178
command="detect-threshold",
175-
config="[detect-threshold]",
176179
api=(f":class:`~{_DET}.threshold_detector.ThresholdDetector`",),
177180
),
178181
Counterpart(
@@ -183,43 +186,30 @@ class Counterpart:
183186
Counterpart(
184187
"Minimum scene length",
185188
option="min_scene_len",
186-
config="[global] min-scene-len",
187189
api=(":ref:`Detector <scenedetect-detectors>` argument ``min_scene_len``",),
188190
),
189-
Counterpart(
190-
"Drop short scenes",
191-
option="drop_short_scenes",
192-
config="[global] drop-short-scenes",
193-
),
194-
Counterpart(
195-
"Merge short last scene",
196-
option="merge_last_scene",
197-
config="[global] merge-last-scene",
198-
),
191+
Counterpart("Drop short scenes", option="drop_short_scenes"),
192+
Counterpart("Merge short last scene", option="merge_last_scene"),
199193
],
200194
"Output": [
201195
Counterpart(
202196
"Scene list (CSV)",
203197
command="list-scenes",
204-
config="[list-scenes]",
205198
api=(f":func:`~{_OUT}.write_scene_list`",),
206199
),
207200
Counterpart(
208201
"Scene list (HTML)",
209202
command="save-html",
210-
config="[save-html]",
211203
api=(f":func:`~{_OUT}.write_scene_list_html`",),
212204
),
213205
Counterpart(
214206
"Edit decision list (EDL)",
215207
command="save-edl",
216-
config="[save-edl]",
217208
api=(f":func:`~{_OUT}.write_scene_list_edl`",),
218209
),
219210
Counterpart(
220211
"Final Cut Pro XML",
221212
command="save-fcp",
222-
config="[save-fcp]",
223213
api=(
224214
f":func:`~{_OUT}.write_scene_list_fcpx`",
225215
f":func:`~{_OUT}.write_scene_list_fcp7`",
@@ -228,19 +218,16 @@ class Counterpart:
228218
Counterpart(
229219
"OpenTimelineIO",
230220
command="save-otio",
231-
config="[save-otio]",
232221
api=(f":func:`~{_OUT}.write_scene_list_otio`",),
233222
),
234223
Counterpart(
235224
"Images from each scene",
236225
command="save-images",
237-
config="[save-images]",
238226
api=(f":func:`~{_OUT}.save_images`",),
239227
),
240228
Counterpart(
241229
"Split video into scenes",
242230
command="split-video",
243-
config="[split-video]",
244231
api=(f":func:`~{_OUT}.split_video_ffmpeg`", f":func:`~{_OUT}.split_video_mkvmerge`"),
245232
),
246233
Counterpart(
@@ -252,28 +239,15 @@ class Counterpart:
252239
Counterpart(
253240
"Output directory",
254241
option="output",
255-
config="[global] output",
256242
api=(
257243
"``output_dir`` argument of :func:`~scenedetect.output.save_images` and "
258244
":func:`~scenedetect.output.split_video_ffmpeg`; output path argument of each "
259245
"``write_scene_list*`` function",
260246
),
261247
),
262-
Counterpart(
263-
"Keyframes for x264/x265 (QP file)",
264-
command="save-qp",
265-
config="[save-qp]",
266-
),
267-
Counterpart(
268-
"Keyframes for Aegisub",
269-
command="save-keyframes",
270-
config="[save-keyframes]",
271-
),
272-
Counterpart(
273-
"Load a scene list (CSV)",
274-
command="load-scenes",
275-
config="[load-scenes]",
276-
),
248+
Counterpart("Keyframes for x264/x265 (QP file)", command="save-qp"),
249+
Counterpart("Keyframes for Aegisub", command="save-keyframes"),
250+
Counterpart("Load a scene list (CSV)", command="load-scenes"),
277251
],
278252
"Input and Processing": [
279253
Counterpart(
@@ -298,13 +272,11 @@ class Counterpart:
298272
Counterpart(
299273
"Crop",
300274
option="crop",
301-
config="[global] crop",
302275
api=(f":attr:`SceneManager.crop <{_SM}.crop>`",),
303276
),
304277
Counterpart(
305278
"Downscale",
306279
option="downscale",
307-
config="[global] downscale",
308280
api=(
309281
f":attr:`SceneManager.downscale <{_SM}.downscale>`",
310282
f":attr:`SceneManager.auto_downscale <{_SM}.auto_downscale>`",
@@ -319,7 +291,6 @@ class Counterpart:
319291
Counterpart(
320292
"Skip frames",
321293
option="frame_skip",
322-
config="[global] frame-skip",
323294
api=(f"{_DETECT_SCENES} (``frame_skip``)",),
324295
),
325296
Counterpart(
@@ -330,16 +301,12 @@ class Counterpart:
330301
":func:`~scenedetect.detect` (``stats_file_path``)",
331302
),
332303
),
333-
Counterpart(
334-
"Config file path",
335-
option="config",
336-
),
304+
Counterpart("Config file path", option="config"),
337305
],
338306
"Video Backends": [
339307
Counterpart(
340308
"Choose a backend",
341309
option="backend",
342-
config="[global] backend",
343310
api=(
344311
":func:`~scenedetect.open_video` (``backend``)",
345312
f":data:`~{_BE}.AVAILABLE_BACKENDS`",
@@ -362,7 +329,6 @@ class Counterpart:
362329
Counterpart(
363330
"Verbosity",
364331
option="verbosity",
365-
config="[global] verbosity",
366332
api=(f"{_INIT_LOGGER} (``log_level``)",),
367333
),
368334
Counterpart(
@@ -383,6 +349,19 @@ class Counterpart:
383349
NO_API = "*no Python API equivalent*"
384350
API_ONLY = "*Python API only*"
385351

352+
# Group that `check_concordance` adds inferred rows to, and the warning it prints for each.
353+
INFERRED_GROUP = "No row"
354+
MISSING_ROW = """{kind} {name!r} has no row in CONCORDANCE (docs/generate_cli_docs.py), so its \
355+
docs do not link to a Python API
356+
Add a row like this one (leave out `api` if it has no Python API):
357+
358+
Counterpart(
359+
"<short description>",
360+
{kind}="{name}",
361+
api=(f":func:`~{{_OUT}}.<function>`",),
362+
),
363+
"""
364+
386365
CLI_INDEX_PATH = "docs/cli_index.inc"
387366

388367
ROOT_SEEALSO = (
@@ -497,7 +476,9 @@ def format_config(config: str | None) -> str:
497476

498477

499478
def format_api(entry: Counterpart) -> str:
500-
return ", ".join(entry.api) if entry.api else CLI_ONLY
479+
if entry.api:
480+
return ", ".join(entry.api)
481+
return "" if entry.inferred else CLI_ONLY
501482

502483

503484
def format_cli(entry: Counterpart, options: dict[str, click.Parameter]) -> str:
@@ -515,14 +496,18 @@ def table_row(first: str, *rest: str) -> str:
515496

516497

517498
def generate_seealso(entry: Counterpart) -> StrGenerator:
518-
"""One-line pointer from a CLI command to its Python API and config file counterparts."""
519-
api = format_api(entry) if entry.api else NO_API
520-
if entry.command in DETECTOR_OPTIONS:
521-
api += f" (:ref:`option table <options-{entry.command}>`)"
522-
parts = [f"Python API: {api}"]
499+
"""One-line pointer from a CLI command to its Python API and config file counterparts. An
500+
inferred row makes no claim about the Python API."""
501+
parts = []
502+
if not entry.inferred:
503+
api = format_api(entry) if entry.api else NO_API
504+
if entry.command in DETECTOR_OPTIONS:
505+
api += f" (:ref:`option table <options-{entry.command}>`)"
506+
parts.append(f"Python API: {api}")
523507
if entry.config:
524508
parts.append(f"Config file: {format_config(entry.config)}")
525-
yield f"\n.. seealso:: {' | '.join(parts)}\n\n"
509+
if parts:
510+
yield f"\n.. seealso:: {' | '.join(parts)}\n\n"
526511

527512

528513
def generate_global_option_table(options: dict[str, click.Parameter]) -> StrGenerator:
@@ -537,26 +522,56 @@ def generate_global_option_table(options: dict[str, click.Parameter]) -> StrGene
537522
yield "\n"
538523

539524

525+
def warn(message: str):
526+
"""Print a warning. On GitHub Actions, its first line also annotates the run."""
527+
print(f"WARNING: {message}", file=sys.stderr)
528+
if os.environ.get("GITHUB_ACTIONS"):
529+
print(f"::warning file=docs/generate_cli_docs.py::{message.splitlines()[0]}")
530+
531+
532+
def derived_config(entry: Counterpart, options: dict[str, click.Parameter]) -> str | None:
533+
"""The config file section of a command, or the `[global]` key of a global option, if it has
534+
one. This is the `config` of a `command` or `option` row that leaves it unset."""
535+
if entry.command:
536+
return f"[{entry.command}]" if entry.command in CONFIG_MAP else None
537+
option = options[entry.option]
538+
assert isinstance(option, click.Option)
539+
key = option_key(option)
540+
return f"[global] {key}" if key in CONFIG_MAP["global"] else None
541+
542+
540543
def check_concordance(commands: list[str], options: dict[str, click.Parameter]):
541-
"""Every visible command and global option must have exactly one row in CONCORDANCE."""
544+
"""Complete `CONCORDANCE` so that every visible command and global option has exactly one row.
545+
546+
A row for something that is not in the CLI, or a second row for the same thing, fails the
547+
run. A command or option with no row gets an inferred one and a warning: adding one to the
548+
CLI does not require editing this file before its docs can be generated. Rows that leave
549+
`config` unset get the derived value (see `derived_config`)."""
550+
expected = {"command": [c for c in commands if c not in INFO_COMMANDS], "option": list(options)}
542551
problems = []
543-
for kind, mapped, expected in (
544-
(
545-
"command",
546-
[e.command for e in counterpart_entries() if e.command],
547-
[c for c in commands if c not in INFO_COMMANDS],
548-
),
549-
("option", [e.option for e in counterpart_entries() if e.option], list(options)),
550-
):
551-
for name in sorted(set(expected) - set(mapped)):
552-
problems.append(f"{kind} {name!r} has no CONCORDANCE row: add a Counterpart(...)")
553-
for name in sorted(set(mapped) - set(expected)):
552+
missing = []
553+
for kind, names in expected.items():
554+
mapped = [getattr(e, kind) for e in counterpart_entries() if getattr(e, kind)]
555+
missing += [(kind, name) for name in names if name not in mapped]
556+
for name in sorted(set(mapped) - set(names)):
554557
problems.append(f"{kind} {name!r} is in CONCORDANCE but not in the CLI: remove it")
555558
for name in sorted(n for n in set(mapped) if mapped.count(n) > 1):
556559
problems.append(f"{kind} {name!r} has more than one CONCORDANCE row")
557560
if problems:
558561
raise SystemExit("generate_cli_docs.py: " + "; ".join(problems))
559562

563+
def with_config(entry: Counterpart) -> Counterpart:
564+
if entry.config is None and (entry.command or entry.option):
565+
return replace(entry, config=derived_config(entry, options))
566+
return entry
567+
568+
for entries in CONCORDANCE.values():
569+
entries[:] = [with_config(entry) for entry in entries]
570+
for kind, name in missing:
571+
entry = Counterpart(name, inferred=True, **{kind: name})
572+
CONCORDANCE.setdefault(INFERRED_GROUP, []).append(with_config(entry))
573+
warn(MISSING_ROW.format(kind=kind, name=name))
574+
560575

561576
def check_reverse_links():
562577
"""Warn if an API page lacks the hand-written link back to a command that has an API."""
@@ -568,7 +583,7 @@ def check_reverse_links():
568583
api_rst += f.read()
569584
for entry in counterpart_entries():
570585
if entry.command and entry.api and f"<command-{entry.command}>" not in api_rst:
571-
print(f"WARNING: no link to command-{entry.command} in docs/api/*.rst", file=sys.stderr)
586+
warn(f"no link to command-{entry.command} in docs/api/*.rst")
572587

573588

574589
def patch_help(s: str, commands: list[str]) -> str:

0 commit comments

Comments
 (0)