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+
2124Run from main repo folder as working directory."""
2225
2326import inspect
2427import os
2528import re
2629import sys
2730import typing as ty
28- from dataclasses import dataclass , field
31+ from dataclasses import dataclass , field , replace
2932from enum import Enum
3033
3134# Add parent folder to path so we can resolve `scenedetect` imports.
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:
383349NO_API = "*no Python API equivalent*"
384350API_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+
386365CLI_INDEX_PATH = "docs/cli_index.inc"
387366
388367ROOT_SEEALSO = (
@@ -497,7 +476,9 @@ def format_config(config: str | None) -> str:
497476
498477
499478def 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
503484def format_cli (entry : Counterpart , options : dict [str , click .Parameter ]) -> str :
@@ -515,14 +496,18 @@ def table_row(first: str, *rest: str) -> str:
515496
516497
517498def 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
528513def 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+
540543def 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
561576def 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
574589def patch_help (s : str , commands : list [str ]) -> str :
0 commit comments