xref: /linux/tools/docs/sphinx-build-wrapper (revision 7e8a8143ecc3940dbc3664b24b132ec7420d1053)
1#!/usr/bin/env python3
2# SPDX-License-Identifier: GPL-2.0
3# Copyright (C) 2025 Mauro Carvalho Chehab <mchehab+huawei@kernel.org>
4#
5# pylint: disable=R0902, R0912, R0913, R0914, R0915, R0917, C0103
6#
7# Converted from docs Makefile and parallel-wrapper.sh, both under
8# GPLv2, copyrighted since 2008 by the following authors:
9#
10#    Akira Yokosawa <akiyks@gmail.com>
11#    Arnd Bergmann <arnd@arndb.de>
12#    Breno Leitao <leitao@debian.org>
13#    Carlos Bilbao <carlos.bilbao@amd.com>
14#    Dave Young <dyoung@redhat.com>
15#    Donald Hunter <donald.hunter@gmail.com>
16#    Geert Uytterhoeven <geert+renesas@glider.be>
17#    Jani Nikula <jani.nikula@intel.com>
18#    Jan Stancek <jstancek@redhat.com>
19#    Jonathan Corbet <corbet@lwn.net>
20#    Joshua Clayton <stillcompiling@gmail.com>
21#    Kees Cook <keescook@chromium.org>
22#    Linus Torvalds <torvalds@linux-foundation.org>
23#    Magnus Damm <damm+renesas@opensource.se>
24#    Masahiro Yamada <masahiroy@kernel.org>
25#    Mauro Carvalho Chehab <mchehab+huawei@kernel.org>
26#    Maxim Cournoyer <maxim.cournoyer@gmail.com>
27#    Peter Foley <pefoley2@pefoley.com>
28#    Randy Dunlap <rdunlap@infradead.org>
29#    Rob Herring <robh@kernel.org>
30#    Shuah Khan <shuahkh@osg.samsung.com>
31#    Thorsten Blum <thorsten.blum@toblux.com>
32#    Tomas Winkler <tomas.winkler@intel.com>
33
34
35"""
36Sphinx build wrapper that handles Kernel-specific business rules:
37
38- it gets the Kernel build environment vars;
39- it determines what's the best parallelism;
40- it handles SPHINXDIRS
41
42This tool ensures that MIN_PYTHON_VERSION is satisfied. If version is
43below that, it seeks for a new Python version. If found, it re-runs using
44the newer version.
45"""
46
47import argparse
48import locale
49import os
50import re
51import shlex
52import shutil
53import subprocess
54import sys
55
56from concurrent import futures
57from glob import glob
58
59from lib.python_version import PythonVersion
60from lib.latex_fonts import LatexFontChecker
61
62LIB_DIR = "../../scripts/lib"
63SRC_DIR = os.path.dirname(os.path.realpath(__file__))
64
65sys.path.insert(0, os.path.join(SRC_DIR, LIB_DIR))
66
67from jobserver import JobserverExec         # pylint: disable=C0413,C0411,E0401
68
69#
70#  Some constants
71#
72MIN_PYTHON_VERSION = PythonVersion("3.7").version
73PAPER = ["", "a4", "letter"]
74
75TARGETS = {
76    "cleandocs":     { "builder": "clean" },
77    "linkcheckdocs": { "builder": "linkcheck" },
78    "htmldocs":      { "builder": "html" },
79    "epubdocs":      { "builder": "epub",    "out_dir": "epub" },
80    "texinfodocs":   { "builder": "texinfo", "out_dir": "texinfo" },
81    "infodocs":      { "builder": "texinfo", "out_dir": "texinfo" },
82    "mandocs":       { "builder": "man",     "out_dir": "man" },
83    "latexdocs":     { "builder": "latex",   "out_dir": "latex" },
84    "pdfdocs":       { "builder": "latex",   "out_dir": "latex" },
85    "xmldocs":       { "builder": "xml",     "out_dir": "xml" },
86}
87
88
89#
90# SphinxBuilder class
91#
92
93class SphinxBuilder:
94    """
95    Handles a sphinx-build target, adding needed arguments to build
96    with the Kernel.
97    """
98
99    def is_rust_enabled(self):
100        """Check if rust is enabled at .config"""
101        config_path = os.path.join(self.srctree, ".config")
102        if os.path.isfile(config_path):
103            with open(config_path, "r", encoding="utf-8") as f:
104                return "CONFIG_RUST=y" in f.read()
105        return False
106
107    def get_path(self, path, use_cwd=False, abs_path=False):
108        """
109        Ancillary routine to handle patches the right way, as shell does.
110
111        It first expands "~" and "~user". Then, if patch is not absolute,
112        join self.srctree. Finally, if requested, convert to abspath.
113        """
114
115        path = os.path.expanduser(path)
116        if not path.startswith("/"):
117            if use_cwd:
118                base = os.getcwd()
119            else:
120                base = self.srctree
121
122            path = os.path.join(base, path)
123
124        if abs_path:
125            return os.path.abspath(path)
126
127        return path
128
129    def get_sphinx_extra_opts(self, n_jobs):
130        """
131        Get the number of jobs to be used for docs build passed via command
132        line and desired sphinx verbosity.
133
134        The number of jobs can be on different places:
135
136        1) It can be passed via "-j" argument;
137        2) The SPHINXOPTS="-j8" env var may have "-j";
138        3) if called via GNU make, -j specifies the desired number of jobs.
139           with GNU makefile, this number is available via POSIX jobserver;
140        4) if none of the above is available, it should default to "-jauto",
141           and let sphinx decide the best value.
142        """
143
144        #
145        # SPHINXOPTS env var, if used, contains extra arguments to be used
146        # by sphinx-build time. Among them, it may contain sphinx verbosity
147        # and desired number of parallel jobs.
148        #
149        parser = argparse.ArgumentParser()
150        parser.add_argument('-j', '--jobs', type=int)
151        parser.add_argument('-q', '--quiet', type=int)
152
153        #
154        # Other sphinx-build arguments go as-is, so place them
155        # at self.sphinxopts, using shell parser
156        #
157        sphinxopts = shlex.split(os.environ.get("SPHINXOPTS", ""))
158
159        #
160        # Build a list of sphinx args, honoring verbosity here if specified
161        #
162
163        verbose = self.verbose
164        sphinx_args, self.sphinxopts = parser.parse_known_args(sphinxopts)
165        if sphinx_args.quiet is True:
166            verbose = False
167
168        #
169        # If the user explicitly sets "-j" at command line, use it.
170        # Otherwise, pick it from SPHINXOPTS args
171        #
172        if n_jobs:
173            self.n_jobs = n_jobs
174        elif sphinx_args.jobs:
175            self.n_jobs = sphinx_args.jobs
176        else:
177            self.n_jobs = None
178
179        if not verbose:
180            self.sphinxopts += ["-q"]
181
182    def __init__(self, builddir, verbose=False, n_jobs=None, interactive=None):
183        """Initialize internal variables"""
184        self.verbose = None
185
186        #
187        # Normal variables passed from Kernel's makefile
188        #
189        self.kernelversion = os.environ.get("KERNELVERSION", "unknown")
190        self.kernelrelease = os.environ.get("KERNELRELEASE", "unknown")
191        self.pdflatex = os.environ.get("PDFLATEX", "xelatex")
192
193        if not interactive:
194            self.latexopts = os.environ.get("LATEXOPTS", "-interaction=batchmode -no-shell-escape")
195        else:
196            self.latexopts = os.environ.get("LATEXOPTS", "")
197
198        if not verbose:
199            verbose = bool(os.environ.get("KBUILD_VERBOSE", "") != "")
200
201        if verbose is not None:
202            self.verbose = verbose
203
204        #
205        # Source tree directory. This needs to be at os.environ, as
206        # Sphinx extensions use it
207        #
208        self.srctree = os.environ.get("srctree")
209        if not self.srctree:
210            self.srctree = "."
211            os.environ["srctree"] = self.srctree
212
213        #
214        # Now that we can expand srctree, get other directories as well
215        #
216        self.sphinxbuild = os.environ.get("SPHINXBUILD", "sphinx-build")
217        self.kerneldoc = self.get_path(os.environ.get("KERNELDOC",
218                                                      "scripts/kernel-doc.py"))
219        self.builddir = self.get_path(builddir, use_cwd=True, abs_path=True)
220
221        self.config_rust = self.is_rust_enabled()
222
223        #
224        # Get directory locations for LaTeX build toolchain
225        #
226        self.pdflatex_cmd = shutil.which(self.pdflatex)
227        self.latexmk_cmd = shutil.which("latexmk")
228
229        self.env = os.environ.copy()
230
231        self.get_sphinx_extra_opts(n_jobs)
232
233    def run_sphinx(self, sphinx_build, build_args, *args, **pwargs):
234        """
235        Executes sphinx-build using current python3 command.
236
237        When calling via GNU make, POSIX jobserver is used to tell how
238        many jobs are still available from a job pool. claim all remaining
239        jobs, as we don't want sphinx-build to run in parallel with other
240        jobs.
241
242        Despite that, the user may actually force a different value than
243        the number of available jobs via command line.
244
245        The "with" logic here is used to ensure that the claimed jobs will
246        be freed once subprocess finishes
247        """
248
249        with JobserverExec() as jobserver:
250            if jobserver.claim:
251                #
252                # when GNU make is used, claim available jobs from jobserver
253                #
254                n_jobs = str(jobserver.claim)
255            else:
256                #
257                # Otherwise, let sphinx decide by default
258                #
259                n_jobs = "auto"
260
261            #
262            # If explicitly requested via command line, override default
263            #
264            if self.n_jobs:
265                n_jobs = str(self.n_jobs)
266
267            cmd = [sys.executable, sphinx_build]
268            cmd += [f"-j{n_jobs}"]
269            cmd += self.sphinxopts
270            cmd += build_args
271
272            if self.verbose:
273                print(" ".join(cmd))
274
275            return subprocess.call(cmd, *args, **pwargs)
276
277    def handle_html(self, css, output_dir):
278        """
279        Extra steps for HTML and epub output.
280
281        For such targets, we need to ensure that CSS will be properly
282        copied to the output _static directory
283        """
284
285        if not css:
286            return
287
288        css = os.path.expanduser(css)
289        if not css.startswith("/"):
290            css = os.path.join(self.srctree, css)
291
292        static_dir = os.path.join(output_dir, "_static")
293        os.makedirs(static_dir, exist_ok=True)
294
295        try:
296            shutil.copy2(css, static_dir)
297        except (OSError, IOError) as e:
298            print(f"Warning: Failed to copy CSS: {e}", file=sys.stderr)
299
300    def build_pdf_file(self, latex_cmd, from_dir, path):
301        """Builds a single pdf file using latex_cmd"""
302        try:
303            subprocess.run(latex_cmd + [path],
304                            cwd=from_dir, check=True, env=self.env)
305
306            return True
307        except subprocess.CalledProcessError:
308            return False
309
310    def pdf_parallel_build(self, tex_suffix, latex_cmd, tex_files, n_jobs):
311        """Build PDF files in parallel if possible"""
312        builds = {}
313        build_failed = False
314        max_len = 0
315        has_tex = False
316
317        #
318        # LaTeX PDF error code is almost useless for us:
319        # any warning makes it non-zero. For kernel doc builds it always return
320        # non-zero even when build succeeds. So, let's do the best next thing:
321        # Ignore build errors. At the end, check if all PDF files were built,
322        # printing a summary with the built ones and returning 0 if all of
323        # them were actually built.
324        #
325        with futures.ThreadPoolExecutor(max_workers=n_jobs) as executor:
326            jobs = {}
327
328            for from_dir, pdf_dir, entry in tex_files:
329                name = entry.name
330
331                if not name.endswith(tex_suffix):
332                    continue
333
334                name = name[:-len(tex_suffix)]
335                has_tex = True
336
337                future = executor.submit(self.build_pdf_file, latex_cmd,
338                                         from_dir, entry.path)
339                jobs[future] = (from_dir, pdf_dir, name)
340
341            for future in futures.as_completed(jobs):
342                from_dir, pdf_dir, name = jobs[future]
343
344                pdf_name = name + ".pdf"
345                pdf_from = os.path.join(from_dir, pdf_name)
346                pdf_to = os.path.join(pdf_dir, pdf_name)
347                out_name = os.path.relpath(pdf_to, self.builddir)
348                max_len = max(max_len, len(out_name))
349
350                try:
351                    success = future.result()
352
353                    if success and os.path.exists(pdf_from):
354                        os.rename(pdf_from, pdf_to)
355
356                        #
357                        # if verbose, get the name of built PDF file
358                        #
359                        if self.verbose:
360                           builds[out_name] = "SUCCESS"
361                    else:
362                        builds[out_name] = "FAILED"
363                        build_failed = True
364                except futures.Error as e:
365                    builds[out_name] = f"FAILED ({repr(e)})"
366                    build_failed = True
367
368        #
369        # Handle case where no .tex files were found
370        #
371        if not has_tex:
372            out_name = "LaTeX files"
373            max_len = max(max_len, len(out_name))
374            builds[out_name] = "FAILED: no .tex files were generated"
375            build_failed = True
376
377        return builds, build_failed, max_len
378
379    def handle_pdf(self, output_dirs, deny_vf):
380        """
381        Extra steps for PDF output.
382
383        As PDF is handled via a LaTeX output, after building the .tex file,
384        a new build is needed to create the PDF output from the latex
385        directory.
386        """
387        builds = {}
388        max_len = 0
389        tex_suffix = ".tex"
390        tex_files = []
391
392        #
393        # Since early 2024, Fedora and openSUSE tumbleweed have started
394        # deploying variable-font format of "Noto CJK", causing LaTeX
395        # to break with CJK. Work around it, by denying the variable font
396        # usage during xelatex build by passing the location of a config
397        # file with a deny list.
398        #
399        # See tools/docs/lib/latex_fonts.py for more details.
400        #
401        if deny_vf:
402            deny_vf = os.path.expanduser(deny_vf)
403            if os.path.isdir(deny_vf):
404                self.env["XDG_CONFIG_HOME"] = deny_vf
405
406        for from_dir in output_dirs:
407            pdf_dir = os.path.join(from_dir, "../pdf")
408            os.makedirs(pdf_dir, exist_ok=True)
409
410            if self.latexmk_cmd:
411                latex_cmd = [self.latexmk_cmd, f"-{self.pdflatex}"]
412            else:
413                latex_cmd = [self.pdflatex]
414
415            latex_cmd.extend(shlex.split(self.latexopts))
416
417            # Get a list of tex files to process
418            with os.scandir(from_dir) as it:
419                for entry in it:
420                    if entry.name.endswith(tex_suffix):
421                        tex_files.append((from_dir, pdf_dir, entry))
422
423        #
424        # When using make, this won't be used, as the number of jobs comes
425        # from POSIX jobserver. So, this covers the case where build comes
426        # from command line. On such case, serialize by default, except if
427        # the user explicitly sets the number of jobs.
428        #
429        n_jobs = 1
430
431        # n_jobs is either an integer or "auto". Only use it if it is a number
432        if self.n_jobs:
433            try:
434                n_jobs = int(self.n_jobs)
435            except ValueError:
436                pass
437
438        #
439        # When using make, jobserver.claim is the number of jobs that were
440        # used with "-j" and that aren't used by other make targets
441        #
442        with JobserverExec() as jobserver:
443            n_jobs = 1
444
445            #
446            # Handle the case when a parameter is passed via command line,
447            # using it as default, if jobserver doesn't claim anything
448            #
449            if self.n_jobs:
450                try:
451                    n_jobs = int(self.n_jobs)
452                except ValueError:
453                    pass
454
455            if jobserver.claim:
456                n_jobs = jobserver.claim
457
458            builds, build_failed, max_len = self.pdf_parallel_build(tex_suffix,
459                                                                    latex_cmd,
460                                                                    tex_files,
461                                                                    n_jobs)
462
463        #
464        # In verbose mode, print a summary with the build results per file.
465        # Otherwise, print a single line with all failures, if any.
466        # On both cases, return code 1 indicates build failures,
467        #
468        if self.verbose:
469            msg = "Summary"
470            msg += "\n" + "=" * len(msg)
471            print()
472            print(msg)
473
474            for pdf_name, pdf_file in builds.items():
475                print(f"{pdf_name:<{max_len}}: {pdf_file}")
476
477            print()
478            if build_failed:
479                msg = LatexFontChecker().check()
480                if msg:
481                    print(msg)
482
483                sys.exit("Error: not all PDF files were created.")
484
485        elif build_failed:
486            n_failures = len(builds)
487            failures = ", ".join(builds.keys())
488
489            msg = LatexFontChecker().check()
490            if msg:
491                print(msg)
492
493            sys.exit(f"Error: Can't build {n_failures} PDF file(s): {failures}")
494
495    def handle_info(self, output_dirs):
496        """
497        Extra steps for Info output.
498
499        For texinfo generation, an additional make is needed from the
500        texinfo directory.
501        """
502
503        for output_dir in output_dirs:
504            try:
505                subprocess.run(["make", "info"], cwd=output_dir, check=True)
506            except subprocess.CalledProcessError as e:
507                sys.exit(f"Error generating info docs: {e}")
508
509    def handle_man(self, kerneldoc, docs_dir, src_dir, output_dir):
510        """
511        Create man pages from kernel-doc output
512        """
513
514        re_kernel_doc = re.compile(r"^\.\.\s+kernel-doc::\s*(\S+)")
515        re_man = re.compile(r'^\.TH "[^"]*" (\d+) "([^"]*)"')
516
517        if docs_dir == src_dir:
518            #
519            # Pick the entire set of kernel-doc markups from the entire tree
520            #
521            kdoc_files = set([self.srctree])
522        else:
523            kdoc_files = set()
524
525            for fname in glob(os.path.join(src_dir, "**"), recursive=True):
526                if os.path.isfile(fname) and fname.endswith(".rst"):
527                    with open(fname, "r", encoding="utf-8") as in_fp:
528                        data = in_fp.read()
529
530                    for line in data.split("\n"):
531                        match = re_kernel_doc.match(line)
532                        if match:
533                            if os.path.isfile(match.group(1)):
534                                kdoc_files.add(match.group(1))
535
536        if not kdoc_files:
537                sys.exit(f"Directory {src_dir} doesn't contain kernel-doc tags")
538
539        cmd = [ kerneldoc, "-m" ] + sorted(kdoc_files)
540        try:
541            if self.verbose:
542                print(" ".join(cmd))
543
544            result = subprocess.run(cmd, stdout=subprocess.PIPE, text= True)
545
546            if result.returncode:
547                print(f"Warning: kernel-doc returned {result.returncode} warnings")
548
549        except (OSError, ValueError, subprocess.SubprocessError) as e:
550            sys.exit(f"Failed to create man pages for {src_dir}: {repr(e)}")
551
552        fp = None
553        try:
554            for line in result.stdout.split("\n"):
555                match = re_man.match(line)
556                if not match:
557                    if fp:
558                        fp.write(line + '\n')
559                    continue
560
561                if fp:
562                    fp.close()
563
564                fname = f"{output_dir}/{match.group(2)}.{match.group(1)}"
565
566                if self.verbose:
567                    print(f"Creating {fname}")
568                fp = open(fname, "w", encoding="utf-8")
569                fp.write(line + '\n')
570        finally:
571            if fp:
572                fp.close()
573
574    def cleandocs(self, builder):           # pylint: disable=W0613
575        """Remove documentation output directory"""
576        shutil.rmtree(self.builddir, ignore_errors=True)
577
578    def build(self, target, sphinxdirs=None, conf="conf.py",
579              theme=None, css=None, paper=None, deny_vf=None):
580        """
581        Build documentation using Sphinx. This is the core function of this
582        module. It prepares all arguments required by sphinx-build.
583        """
584
585        builder = TARGETS[target]["builder"]
586        out_dir = TARGETS[target].get("out_dir", "")
587
588        #
589        # Cleandocs doesn't require sphinx-build
590        #
591        if target == "cleandocs":
592            self.cleandocs(builder)
593            return
594
595        if theme:
596            os.environ["DOCS_THEME"] = theme
597
598        #
599        # Other targets require sphinx-build, so check if it exists
600        #
601        sphinxbuild = shutil.which(self.sphinxbuild, path=self.env["PATH"])
602        if not sphinxbuild and target != "mandocs":
603            sys.exit(f"Error: {self.sphinxbuild} not found in PATH.\n")
604
605        if builder == "latex":
606            if not self.pdflatex_cmd and not self.latexmk_cmd:
607                sys.exit("Error: pdflatex or latexmk required for PDF generation")
608
609        docs_dir = os.path.abspath(os.path.join(self.srctree, "Documentation"))
610
611        #
612        # Fill in base arguments for Sphinx build
613        #
614        kerneldoc = self.kerneldoc
615        if kerneldoc.startswith(self.srctree):
616            kerneldoc = os.path.relpath(kerneldoc, self.srctree)
617
618        args = [ "-b", builder, "-c", docs_dir ]
619
620        if builder == "latex":
621            if not paper:
622                paper = PAPER[1]
623
624            args.extend(["-D", f"latex_elements.papersize={paper}paper"])
625
626        if self.config_rust:
627            args.extend(["-t", "rustdoc"])
628
629        if conf:
630            self.env["SPHINX_CONF"] = self.get_path(conf, abs_path=True)
631
632        if not sphinxdirs:
633            sphinxdirs = os.environ.get("SPHINXDIRS", ".")
634
635        #
636        # The sphinx-build tool has a bug: internally, it tries to set
637        # locale with locale.setlocale(locale.LC_ALL, ''). This causes a
638        # crash if language is not set. Detect and fix it.
639        #
640        try:
641            locale.setlocale(locale.LC_ALL, '')
642        except locale.Error:
643            self.env["LC_ALL"] = "C"
644
645        #
646        # sphinxdirs can be a list or a whitespace-separated string
647        #
648        sphinxdirs_list = []
649        for sphinxdir in sphinxdirs:
650            if isinstance(sphinxdir, list):
651                sphinxdirs_list += sphinxdir
652            else:
653                sphinxdirs_list += sphinxdir.split()
654
655        #
656        # Step 1:  Build each directory in separate.
657        #
658        # This is not the best way of handling it, as cross-references between
659        # them will be broken, but this is what we've been doing since
660        # the beginning.
661        #
662        output_dirs = []
663        for sphinxdir in sphinxdirs_list:
664            src_dir = os.path.join(docs_dir, sphinxdir)
665            doctree_dir = os.path.join(self.builddir, ".doctrees")
666            output_dir = os.path.join(self.builddir, sphinxdir, out_dir)
667
668            #
669            # Make directory names canonical
670            #
671            src_dir = os.path.normpath(src_dir)
672            doctree_dir = os.path.normpath(doctree_dir)
673            output_dir = os.path.normpath(output_dir)
674
675            os.makedirs(doctree_dir, exist_ok=True)
676            os.makedirs(output_dir, exist_ok=True)
677
678            output_dirs.append(output_dir)
679
680            build_args = args + [
681                "-d", doctree_dir,
682                "-D", f"kerneldoc_bin={kerneldoc}",
683                "-D", f"version={self.kernelversion}",
684                "-D", f"release={self.kernelrelease}",
685                "-D", f"kerneldoc_srctree={self.srctree}",
686                src_dir,
687                output_dir,
688            ]
689
690            if target == "mandocs":
691                self.handle_man(kerneldoc, docs_dir, src_dir, output_dir)
692            else:
693                try:
694                    self.run_sphinx(sphinxbuild, build_args, env=self.env)
695                except (OSError, ValueError, subprocess.SubprocessError) as e:
696                    sys.exit(f"Build failed: {repr(e)}")
697
698            #
699            # Ensure that each html/epub output will have needed static files
700            #
701            if target in ["htmldocs", "epubdocs"]:
702                self.handle_html(css, output_dir)
703
704        #
705        # Step 2: Some targets (PDF and info) require an extra step once
706        #         sphinx-build finishes
707        #
708        if target == "pdfdocs":
709            self.handle_pdf(output_dirs, deny_vf)
710        elif target == "infodocs":
711            self.handle_info(output_dirs)
712
713def jobs_type(value):
714    """
715    Handle valid values for -j. Accepts Sphinx "-jauto", plus a number
716    equal or bigger than one.
717    """
718    if value is None:
719        return None
720
721    if value.lower() == 'auto':
722        return value.lower()
723
724    try:
725        if int(value) >= 1:
726            return value
727
728        raise argparse.ArgumentTypeError(f"Minimum jobs is 1, got {value}")
729    except ValueError:
730        raise argparse.ArgumentTypeError(f"Must be 'auto' or positive integer, got {value}")  # pylint: disable=W0707
731
732def main():
733    """
734    Main function. The only mandatory argument is the target. If not
735    specified, the other arguments will use default values if not
736    specified at os.environ.
737    """
738    parser = argparse.ArgumentParser(description="Kernel documentation builder")
739
740    parser.add_argument("target", choices=list(TARGETS.keys()),
741                        help="Documentation target to build")
742    parser.add_argument("--sphinxdirs", nargs="+",
743                        help="Specific directories to build")
744    parser.add_argument("--conf", default="conf.py",
745                        help="Sphinx configuration file")
746    parser.add_argument("--builddir", default="output",
747                        help="Sphinx configuration file")
748
749    parser.add_argument("--theme", help="Sphinx theme to use")
750
751    parser.add_argument("--css", help="Custom CSS file for HTML/EPUB")
752
753    parser.add_argument("--paper", choices=PAPER, default=PAPER[0],
754                        help="Paper size for LaTeX/PDF output")
755
756    parser.add_argument('--deny-vf',
757                        help="Configuration to deny variable fonts on pdf builds")
758
759    parser.add_argument("-v", "--verbose", action='store_true',
760                        help="place build in verbose mode")
761
762    parser.add_argument('-j', '--jobs', type=jobs_type,
763                        help="Sets number of jobs to use with sphinx-build")
764
765    parser.add_argument('-i', '--interactive', action='store_true',
766                        help="Change latex default to run in interactive mode")
767
768    args = parser.parse_args()
769
770    PythonVersion.check_python(MIN_PYTHON_VERSION)
771
772    builder = SphinxBuilder(builddir=args.builddir,
773                            verbose=args.verbose, n_jobs=args.jobs,
774                            interactive=args.interactive)
775
776    builder.build(args.target, sphinxdirs=args.sphinxdirs, conf=args.conf,
777                  theme=args.theme, css=args.css, paper=args.paper,
778                  deny_vf=args.deny_vf)
779
780if __name__ == "__main__":
781    main()
782