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