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