xref: /linux/tools/docs/sphinx-build-wrapper (revision 72603d73fae3d727fd1c97ab003940f1c2309226)
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,
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 not sphinxdirs:
657            sphinxdirs = os.environ.get("SPHINXDIRS", ".")
658
659        #
660        # The sphinx-build tool has a bug: internally, it tries to set
661        # locale with locale.setlocale(locale.LC_ALL, ''). This causes a
662        # crash if language is not set. Detect and fix it.
663        #
664        try:
665            locale.setlocale(locale.LC_ALL, '')
666        except locale.Error:
667            self.env["LC_ALL"] = "C"
668
669        #
670        # sphinxdirs can be a list or a whitespace-separated string
671        #
672        sphinxdirs_list = []
673        for sphinxdir in sphinxdirs:
674            if isinstance(sphinxdir, list):
675                sphinxdirs_list += sphinxdir
676            else:
677                sphinxdirs_list += sphinxdir.split()
678
679        #
680        # Step 1:  Build each directory in separate.
681        #
682        # This is not the best way of handling it, as cross-references between
683        # them will be broken, but this is what we've been doing since
684        # the beginning.
685        #
686        output_dirs = []
687        for sphinxdir in sphinxdirs_list:
688            src_dir = os.path.join(docs_dir, sphinxdir)
689            doctree_dir = os.path.join(self.builddir, ".doctrees")
690            output_dir = os.path.join(self.builddir, sphinxdir, out_dir)
691
692            #
693            # Make directory names canonical
694            #
695            src_dir = os.path.normpath(src_dir)
696            doctree_dir = os.path.normpath(doctree_dir)
697            output_dir = os.path.normpath(output_dir)
698
699            os.makedirs(doctree_dir, exist_ok=True)
700            os.makedirs(output_dir, exist_ok=True)
701
702            output_dirs.append(output_dir)
703
704            build_args = args + [
705                "-d", doctree_dir,
706                "-D", f"kerneldoc_bin={kerneldoc}",
707                "-D", f"version={self.kernelversion}",
708                "-D", f"release={self.kernelrelease}",
709                "-D", f"kerneldoc_srctree={self.srctree}",
710                src_dir,
711                output_dir,
712            ]
713
714            if target == "mandocs":
715                self.handle_man(kerneldoc, docs_dir, src_dir, output_dir)
716            else:
717                try:
718                    result = self.run_sphinx(sphinxbuild, build_args,
719                                             env=self.env)
720
721                    if result:
722                        sys.exit(f"Build failed: return code: {result}")
723
724                except (OSError, ValueError, subprocess.SubprocessError) as e:
725                    sys.exit(f"Build failed: {repr(e)}")
726
727            #
728            # Ensure that each html/epub output will have needed static files
729            #
730            if target in ["htmldocs", "epubdocs"]:
731                self.handle_html(css, output_dir, rustdoc)
732
733        #
734        # Step 2: Some targets (PDF and info) require an extra step once
735        #         sphinx-build finishes
736        #
737        if target == "pdfdocs":
738            self.handle_pdf(output_dirs, deny_vf)
739        elif target == "infodocs":
740            self.handle_info(output_dirs)
741
742def jobs_type(value):
743    """
744    Handle valid values for -j. Accepts Sphinx "-jauto", plus a number
745    equal or bigger than one.
746    """
747    if value is None:
748        return None
749
750    if value.lower() == 'auto':
751        return value.lower()
752
753    try:
754        if int(value) >= 1:
755            return value
756
757        raise argparse.ArgumentTypeError(f"Minimum jobs is 1, got {value}")
758    except ValueError:
759        raise argparse.ArgumentTypeError(f"Must be 'auto' or positive integer, got {value}")  # pylint: disable=W0707
760
761def main():
762    """
763    Main function. The only mandatory argument is the target. If not
764    specified, the other arguments will use default values if not
765    specified at os.environ.
766    """
767    parser = argparse.ArgumentParser(description="Kernel documentation builder")
768
769    parser.add_argument("target", choices=list(TARGETS.keys()),
770                        help="Documentation target to build")
771    parser.add_argument("--sphinxdirs", nargs="+",
772                        help="Specific directories to build")
773    parser.add_argument("--builddir", default="output",
774                        help="Sphinx configuration file")
775
776    parser.add_argument("--theme", help="Sphinx theme to use")
777
778    parser.add_argument("--css", help="Custom CSS file for HTML/EPUB")
779
780    parser.add_argument("--paper", choices=PAPER, default=PAPER[0],
781                        help="Paper size for LaTeX/PDF output")
782
783    parser.add_argument('--deny-vf',
784                        help="Configuration to deny variable fonts on pdf builds")
785
786    parser.add_argument('--rustdoc', action="store_true",
787                        help="Enable rustdoc build. Requires CONFIG_RUST")
788
789    parser.add_argument("-v", "--verbose", action='store_true',
790                        help="place build in verbose mode")
791
792    parser.add_argument('-j', '--jobs', type=jobs_type,
793                        help="Sets number of jobs to use with sphinx-build")
794
795    parser.add_argument('-i', '--interactive', action='store_true',
796                        help="Change latex default to run in interactive mode")
797
798    parser.add_argument("-V", "--venv", nargs='?', const=f'{VENV_DEFAULT}',
799                        default=None,
800                        help=f'If used, run Sphinx from a venv dir (default dir: {VENV_DEFAULT})')
801
802    args = parser.parse_args()
803
804    PythonVersion.check_python(MIN_PYTHON_VERSION, show_alternatives=True,
805                               bail_out=True)
806
807    builder = SphinxBuilder(builddir=args.builddir, venv=args.venv,
808                            verbose=args.verbose, n_jobs=args.jobs,
809                            interactive=args.interactive)
810
811    builder.build(args.target, sphinxdirs=args.sphinxdirs,
812                  theme=args.theme, css=args.css, paper=args.paper,
813                  rustdoc=args.rustdoc, deny_vf=args.deny_vf)
814
815if __name__ == "__main__":
816    main()
817