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