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