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