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