xref: /linux/tools/docs/sphinx-build-wrapper (revision 35b9d338e40142c71b7270cc69b2b5fa1f24cc29)
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#
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', type=int)
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 += self.sphinxopts
302            cmd += build_args
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        """
627        Build documentation using Sphinx. This is the core function of this
628        module. It prepares all arguments required by sphinx-build.
629        """
630
631        builder = TARGETS[target]["builder"]
632        out_dir = TARGETS[target].get("out_dir", "")
633
634        #
635        # Cleandocs doesn't require sphinx-build
636        #
637        if target == "cleandocs":
638            self.cleandocs(builder)
639            return
640
641        if theme:
642            os.environ["DOCS_THEME"] = theme
643
644        #
645        # Other targets require sphinx-build, so check if it exists
646        #
647        sphinxbuild = shutil.which(self.sphinxbuild, path=self.env["PATH"])
648        if not sphinxbuild and target != "mandocs":
649            sys.exit(f"Error: {self.sphinxbuild} not found in PATH.\n")
650
651        if builder == "latex":
652            if not self.pdflatex_cmd and not self.latexmk_cmd:
653                sys.exit("Error: pdflatex or latexmk required for PDF generation")
654
655        docs_dir = os.path.abspath(os.path.join(self.srctree, "Documentation"))
656
657        #
658        # Fill in base arguments for Sphinx build
659        #
660        kerneldoc = self.kerneldoc
661        if kerneldoc.startswith(self.srctree):
662            kerneldoc = os.path.relpath(kerneldoc, self.srctree)
663
664        args = [ "-b", builder, "-c", docs_dir ]
665
666        if builder == "latex":
667            if not paper:
668                paper = PAPER[1]
669
670            args.extend(["-D", f"latex_elements.papersize={paper}paper"])
671
672        if rustdoc:
673            args.extend(["-t", "rustdoc"])
674
675        if not sphinxdirs:
676            sphinxdirs = os.environ.get("SPHINXDIRS", ".")
677
678        #
679        # The sphinx-build tool has a bug: internally, it tries to set
680        # locale with locale.setlocale(locale.LC_ALL, ''). This causes a
681        # crash if language is not set. Detect and fix it.
682        #
683        try:
684            locale.setlocale(locale.LC_ALL, '')
685        except locale.Error:
686            self.env["LC_ALL"] = "C"
687
688        #
689        # sphinxdirs can be a list or a whitespace-separated string
690        #
691        sphinxdirs_list = []
692        for sphinxdir in sphinxdirs:
693            if isinstance(sphinxdir, list):
694                sphinxdirs_list += sphinxdir
695            else:
696                sphinxdirs_list += sphinxdir.split()
697
698        #
699        # Step 1:  Build each directory in separate.
700        #
701        # This is not the best way of handling it, as cross-references between
702        # them will be broken, but this is what we've been doing since
703        # the beginning.
704        #
705        output_dirs = []
706        for sphinxdir in sphinxdirs_list:
707            src_dir = os.path.join(docs_dir, sphinxdir)
708            doctree_dir = os.path.join(self.builddir, ".doctrees")
709            output_dir = os.path.join(self.builddir, sphinxdir, out_dir)
710
711            #
712            # Make directory names canonical
713            #
714            src_dir = os.path.normpath(src_dir)
715            doctree_dir = os.path.normpath(doctree_dir)
716            output_dir = os.path.normpath(output_dir)
717
718            os.makedirs(doctree_dir, exist_ok=True)
719            os.makedirs(output_dir, exist_ok=True)
720
721            output_dirs.append(output_dir)
722
723            build_args = args + [
724                "-d", doctree_dir,
725                "-D", f"kerneldoc_bin={kerneldoc}",
726                "-D", f"version={self.kernelversion}",
727                "-D", f"release={self.kernelrelease}",
728                "-D", f"kerneldoc_srctree={self.srctree}",
729                src_dir,
730                output_dir,
731            ]
732
733            if target == "mandocs":
734                self.handle_man(kerneldoc, docs_dir, src_dir, output_dir)
735            else:
736                try:
737                    result = self.run_sphinx(sphinxbuild, build_args,
738                                             env=self.env)
739
740                    if result:
741                        sys.exit(f"Build failed: return code: {result}")
742
743                except (OSError, ValueError, subprocess.SubprocessError) as e:
744                    sys.exit(f"Build failed: {repr(e)}")
745
746            #
747            # Ensure that each html/epub output will have needed static files
748            #
749            if target in ["htmldocs", "epubdocs"]:
750                self.handle_html(css, output_dir, rustdoc)
751
752        #
753        # Step 2: Some targets (PDF and info) require an extra step once
754        #         sphinx-build finishes
755        #
756        if target == "pdfdocs":
757            self.handle_pdf(output_dirs, deny_vf)
758        elif target == "infodocs":
759            self.handle_info(output_dirs)
760
761def jobs_type(value):
762    """
763    Handle valid values for -j. Accepts Sphinx "-jauto", plus a number
764    equal or bigger than one.
765    """
766    if value is None:
767        return None
768
769    if value.lower() == 'auto':
770        return value.lower()
771
772    try:
773        if int(value) >= 1:
774            return value
775
776        raise argparse.ArgumentTypeError(f"Minimum jobs is 1, got {value}")
777    except ValueError:
778        raise argparse.ArgumentTypeError(f"Must be 'auto' or positive integer, got {value}")  # pylint: disable=W0707
779
780def main():
781    """
782    Main function. The only mandatory argument is the target. If not
783    specified, the other arguments will use default values if not
784    specified at os.environ.
785    """
786    parser = argparse.ArgumentParser(description="Kernel documentation builder")
787
788    parser.add_argument("target", choices=list(TARGETS.keys()),
789                        help="Documentation target to build")
790    parser.add_argument("--sphinxdirs", nargs="+",
791                        help="Specific directories to build")
792    parser.add_argument("--builddir", default="output",
793                        help="Sphinx configuration file")
794
795    parser.add_argument("--theme", help="Sphinx theme to use")
796
797    parser.add_argument("--css", help="Custom CSS file for HTML/EPUB")
798
799    parser.add_argument("--paper", choices=PAPER, default=PAPER[0],
800                        help="Paper size for LaTeX/PDF output")
801
802    parser.add_argument('--deny-vf',
803                        help="Configuration to deny variable fonts on pdf builds")
804
805    parser.add_argument('--rustdoc', action="store_true",
806                        help="Enable rustdoc build. Requires CONFIG_RUST")
807
808    parser.add_argument("-v", "--verbose", action='store_true',
809                        help="place build in verbose mode")
810
811    parser.add_argument('-j', '--jobs', type=jobs_type,
812                        help="Sets number of jobs to use with sphinx-build")
813
814    parser.add_argument('-i', '--interactive', action='store_true',
815                        help="Change latex default to run in interactive mode")
816
817    parser.add_argument("-V", "--venv", nargs='?', const=f'{VENV_DEFAULT}',
818                        default=None,
819                        help=f'If used, run Sphinx from a venv dir (default dir: {VENV_DEFAULT})')
820
821    args = parser.parse_args()
822
823    PythonVersion.check_python(MIN_PYTHON_VERSION, show_alternatives=True,
824                               bail_out=True)
825
826    builder = SphinxBuilder(builddir=args.builddir, venv=args.venv,
827                            verbose=args.verbose, n_jobs=args.jobs,
828                            interactive=args.interactive)
829
830    builder.build(args.target, sphinxdirs=args.sphinxdirs,
831                  theme=args.theme, css=args.css, paper=args.paper,
832                  rustdoc=args.rustdoc, deny_vf=args.deny_vf)
833
834if __name__ == "__main__":
835    main()
836