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