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