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