xref: /linux/scripts/sphinx-pre-install (revision c71c5d6dcb34423c0f15c146240b25a0e78f65aa)
1#!/usr/bin/env python3
2# SPDX-License-Identifier: GPL-2.0-or-later
3# Copyright (c) 2017-2025 Mauro Carvalho Chehab <mchehab+huawei@kernel.org>
4#
5# pylint: disable=C0103,C0114,C0115,C0116,C0301,C0302
6# pylint: disable=R0902,R0904,R0911,R0912,R0914,R0915,R1705,R1710,E1121
7
8# Note: this script requires at least Python 3.6 to run.
9# Don't add changes not compatible with it, it is meant to report
10# incompatible python versions.
11
12"""
13Dependency checker for Sphinx documentation Kernel build.
14
15This module provides tools to check for all required dependencies needed to
16build documentation using Sphinx, including system packages, Python modules
17and LaTeX packages for PDF generation.
18
19It detect packages for a subset of Linux distributions used by Kernel
20maintainers, showing hints and missing dependencies.
21
22The main class SphinxDependencyChecker handles the dependency checking logic
23and provides recommendations for installing missing packages. It supports both
24system package installations and  Python virtual environments. By default,
25system pacage install is recommended.
26"""
27
28import argparse
29import os
30import re
31import subprocess
32import sys
33from glob import glob
34
35
36def parse_version(version):
37    """Convert a major.minor.patch version into a tuple"""
38    return tuple(int(x) for x in version.split("."))
39
40
41def ver_str(version):
42    """Returns a version tuple as major.minor.patch"""
43
44    return ".".join([str(x) for x in version])
45
46
47RECOMMENDED_VERSION = parse_version("3.4.3")
48MIN_PYTHON_VERSION = parse_version("3.7")
49
50
51class DepManager:
52    """
53    Manage package dependencies. There are three types of dependencies:
54
55    - System: dependencies required for docs build;
56    - Python: python dependencies for a native distro Sphinx install;
57    - PDF: dependencies needed by PDF builds.
58
59    Each dependency can be mandatory or optional. Not installing an optional
60    dependency won't break the build, but will cause degradation at the
61    docs output.
62    """
63
64    # Internal types of dependencies. Don't use them outside DepManager class.
65    _SYS_TYPE = 0
66    _PHY_TYPE = 1
67    _PDF_TYPE = 2
68
69    # Dependencies visible outside the class.
70    # The keys are tuple with: (type, is_mandatory flag).
71    #
72    # Currently we're not using all optional dep types. Yet, we'll keep all
73    # possible combinations here. They're not many, and that makes easier
74    # if later needed and for the name() method below
75
76    SYSTEM_MANDATORY = (_SYS_TYPE, True)
77    PYTHON_MANDATORY = (_PHY_TYPE, True)
78    PDF_MANDATORY = (_PDF_TYPE, True)
79
80    SYSTEM_OPTIONAL = (_SYS_TYPE, False)
81    PYTHON_OPTIONAL = (_PHY_TYPE, False)
82    PDF_OPTIONAL = (_PDF_TYPE, True)
83
84    def __init__(self, pdf):
85        """
86        Initialize internal vars:
87
88        - missing: missing dependencies list, containing a distro-independent
89                   name for a missing dependency and its type.
90        - missing_pkg: ancillary dict containing missing dependencies in
91                       distro namespace, organized by type.
92        - need: total number of needed dependencies. Never cleaned.
93        - optional: total number of optional dependencies. Never cleaned.
94        - pdf: Is PDF support enabled?
95        """
96        self.missing = {}
97        self.missing_pkg = {}
98        self.need = 0
99        self.optional = 0
100        self.pdf = pdf
101
102    @staticmethod
103    def name(dtype):
104        """
105        Ancillary routine to output a warn/error message reporting
106        missing dependencies.
107        """
108        if dtype[0] == DepManager._SYS_TYPE:
109            msg = "build"
110        elif dtype[0] == DepManager._PHY_TYPE:
111            msg = "Python"
112        else:
113            msg = "PDF"
114
115        if dtype[1]:
116            return f"ERROR: {msg} mandatory deps missing"
117        else:
118            return f"Warning: {msg} optional deps missing"
119
120    @staticmethod
121    def is_optional(dtype):
122        """Ancillary routine to report if a dependency is optional"""
123        return not dtype[1]
124
125    @staticmethod
126    def is_pdf(dtype):
127        """Ancillary routine to report if a dependency is for PDF generation"""
128        if dtype[0] == DepManager._PDF_TYPE:
129            return True
130
131        return False
132
133    def add_package(self, package, dtype):
134        """
135        Add a package at the self.missing() dictionary.
136        Doesn't update missing_pkg.
137        """
138        is_optional = DepManager.is_optional(dtype)
139        self.missing[package] = dtype
140        if is_optional:
141            self.optional += 1
142        else:
143            self.need += 1
144
145    def del_package(self, package):
146        """
147        Remove a package at the self.missing() dictionary.
148        Doesn't update missing_pkg.
149        """
150        if package in self.missing:
151            del self.missing[package]
152
153    def clear_deps(self):
154        """
155        Clear dependencies without changing needed/optional.
156
157        This is an ackward way to have a separate section to recommend
158        a package after system main dependencies.
159
160        TODO: rework the logic to prevent needing it.
161        """
162
163        self.missing = {}
164        self.missing_pkg = {}
165
166    def check_missing(self, progs):
167        """
168        Update self.missing_pkg, using progs dict to convert from the
169        agnostic package name to distro-specific one.
170
171        Returns an string with the packages to be installed, sorted and
172        with eventual duplicates removed.
173        """
174
175        self.missing_pkg = {}
176
177        for prog, dtype in sorted(self.missing.items()):
178            # At least on some LTS distros like CentOS 7, texlive doesn't
179            # provide all packages we need. When such distros are
180            # detected, we have to disable PDF output.
181            #
182            # So, we need to ignore the packages that distros would
183            # need for LaTeX to work
184            if DepManager.is_pdf(dtype) and not self.pdf:
185                self.optional -= 1
186                continue
187
188            if not dtype in self.missing_pkg:
189                self.missing_pkg[dtype] = []
190
191            self.missing_pkg[dtype].append(progs.get(prog, prog))
192
193        install = []
194        for dtype, pkgs in self.missing_pkg.items():
195            install += pkgs
196
197        return " ".join(sorted(set(install)))
198
199    def warn_install(self):
200        """
201        Emit warnings/errors related to missing packages.
202        """
203
204        output_msg = ""
205
206        for dtype in sorted(self.missing_pkg.keys()):
207            progs = " ".join(sorted(set(self.missing_pkg[dtype])))
208
209            try:
210                name = DepManager.name(dtype)
211                output_msg += f'{name}:\t{progs}\n'
212            except KeyError:
213                raise KeyError(f"ERROR!!!: invalid dtype for {progs}: {dtype}")
214
215        if output_msg:
216            print(f"\n{output_msg}")
217
218class AncillaryMethods:
219    """
220    Ancillary methods that checks for missing dependencies for different
221    types of types, like binaries, python modules, rpm deps, etc.
222    """
223
224    @staticmethod
225    def which(prog):
226        """
227        Our own implementation of which(). We could instead use
228        shutil.which(), but this function is simple enough.
229        Probably faster to use this implementation than to import shutil.
230        """
231        for path in os.environ.get("PATH", "").split(":"):
232            full_path = os.path.join(path, prog)
233            if os.access(full_path, os.X_OK):
234                return full_path
235
236        return None
237
238    @staticmethod
239    def get_python_version(cmd):
240        """
241        Get python version from a Python binary. As we need to detect if
242        are out there newer python binaries, we can't rely on sys.release here.
243        """
244
245        result = SphinxDependencyChecker.run([cmd, "--version"],
246                                            capture_output=True, text=True)
247        version = result.stdout.strip()
248
249        match = re.search(r"(\d+\.\d+\.\d+)", version)
250        if match:
251            return parse_version(match.group(1))
252
253        print(f"Can't parse version {version}")
254        return (0, 0, 0)
255
256    @staticmethod
257    def find_python():
258        """
259        Detect if are out there any python 3.xy version newer than the
260        current one.
261
262        Note: this routine is limited to up to 2 digits for python3. We
263        may need to update it one day, hopefully on a distant future.
264        """
265        patterns = [
266            "python3.[0-9]",
267            "python3.[0-9][0-9]",
268        ]
269
270        # Seek for a python binary newer than MIN_PYTHON_VERSION
271        for path in os.getenv("PATH", "").split(":"):
272            for pattern in patterns:
273                for cmd in glob(os.path.join(path, pattern)):
274                    if os.path.isfile(cmd) and os.access(cmd, os.X_OK):
275                        version = SphinxDependencyChecker.get_python_version(cmd)
276                        if version >= MIN_PYTHON_VERSION:
277                            return cmd
278
279    @staticmethod
280    def check_python():
281        """
282        Check if the current python binary satisfies our minimal requirement
283        for Sphinx build. If not, re-run with a newer version if found.
284        """
285        cur_ver = sys.version_info[:3]
286        if cur_ver >= MIN_PYTHON_VERSION:
287            ver = ver_str(cur_ver)
288            print(f"Python version: {ver}")
289
290            # This could be useful for debugging purposes
291            if SphinxDependencyChecker.which("docutils"):
292                result = SphinxDependencyChecker.run(["docutils", "--version"],
293                                                    capture_output=True, text=True)
294                ver = result.stdout.strip()
295                match = re.search(r"(\d+\.\d+\.\d+)", ver)
296                if match:
297                    ver = match.group(1)
298
299                print(f"Docutils version: {ver}")
300
301            return
302
303        python_ver = ver_str(cur_ver)
304
305        new_python_cmd = SphinxDependencyChecker.find_python()
306        if not new_python_cmd:
307            print(f"ERROR: Python version {python_ver} is not spported anymore\n")
308            print("       Can't find a new version. This script may fail")
309            return
310
311        # Restart script using the newer version
312        script_path = os.path.abspath(sys.argv[0])
313        args = [new_python_cmd, script_path] + sys.argv[1:]
314
315        print(f"Python {python_ver} not supported. Changing to {new_python_cmd}")
316
317        try:
318            os.execv(new_python_cmd, args)
319        except OSError as e:
320            sys.exit(f"Failed to restart with {new_python_cmd}: {e}")
321
322    @staticmethod
323    def run(*args, **kwargs):
324        """
325        Excecute a command, hiding its output by default.
326        Preserve comatibility with older Python versions.
327        """
328
329        capture_output = kwargs.pop('capture_output', False)
330
331        if capture_output:
332            if 'stdout' not in kwargs:
333                kwargs['stdout'] = subprocess.PIPE
334            if 'stderr' not in kwargs:
335                kwargs['stderr'] = subprocess.PIPE
336        else:
337            if 'stdout' not in kwargs:
338                kwargs['stdout'] = subprocess.DEVNULL
339            if 'stderr' not in kwargs:
340                kwargs['stderr'] = subprocess.DEVNULL
341
342        # Don't break with older Python versions
343        if 'text' in kwargs and sys.version_info < (3, 7):
344            kwargs['universal_newlines'] = kwargs.pop('text')
345
346        return subprocess.run(*args, **kwargs)
347
348class MissingCheckers(AncillaryMethods):
349    """
350    Contains some ancillary checkers for different types of binaries and
351    package managers.
352    """
353
354    def __init__(self, args, texlive):
355        """
356        Initialize its internal variables
357        """
358        self.pdf = args.pdf
359        self.virtualenv = args.virtualenv
360        self.version_check = args.version_check
361        self.texlive = texlive
362
363        self.min_version = (0, 0, 0)
364        self.cur_version = (0, 0, 0)
365
366        self.deps = DepManager(self.pdf)
367
368        self.need_symlink = 0
369        self.need_sphinx = 0
370
371        self.verbose_warn_install = 1
372
373        self.virtenv_dir = ""
374        self.install = ""
375        self.python_cmd = ""
376
377        self.virtenv_prefix = ["sphinx_", "Sphinx_" ]
378
379    def check_missing_file(self, files, package, dtype):
380        """
381        Does the file exists? If not, add it to missing dependencies.
382        """
383        for f in files:
384            if os.path.exists(f):
385                return
386        self.deps.add_package(package, dtype)
387
388    def check_program(self, prog, dtype):
389        """
390        Does the program exists and it is at the PATH?
391        If not, add it to missing dependencies.
392        """
393        found = self.which(prog)
394        if found:
395            return found
396
397        self.deps.add_package(prog, dtype)
398
399        return None
400
401    def check_perl_module(self, prog, dtype):
402        """
403        Does perl have a dependency? Is it available?
404        If not, add it to missing dependencies.
405
406        Right now, we still need Perl for doc build, as it is required
407        by some tools called at docs or kernel build time, like:
408
409            scripts/documentation-file-ref-check
410
411        Also, checkpatch is on Perl.
412        """
413
414        # While testing with lxc download template, one of the
415        # distros (Oracle) didn't have perl - nor even an option to install
416        # before installing oraclelinux-release-el9 package.
417        #
418        # Check it before running an error. If perl is not there,
419        # add it as a mandatory package, as some parts of the doc builder
420        # needs it.
421        if not self.which("perl"):
422            self.deps.add_package("perl", DepManager.SYSTEM_MANDATORY)
423            self.deps.add_package(prog, dtype)
424            return
425
426        try:
427            self.run(["perl", f"-M{prog}", "-e", "1"], check=True)
428        except subprocess.CalledProcessError:
429            self.deps.add_package(prog, dtype)
430
431    def check_python_module(self, module, is_optional=False):
432        """
433        Does a python module exists outside venv? If not, add it to missing
434        dependencies.
435        """
436        if is_optional:
437            dtype = DepManager.PYTHON_OPTIONAL
438        else:
439            dtype = DepManager.PYTHON_MANDATORY
440
441        try:
442            self.run([self.python_cmd, "-c", f"import {module}"], check=True)
443        except subprocess.CalledProcessError:
444            self.deps.add_package(module, dtype)
445
446    def check_rpm_missing(self, pkgs, dtype):
447        """
448        Does a rpm package exists? If not, add it to missing dependencies.
449        """
450        for prog in pkgs:
451            try:
452                self.run(["rpm", "-q", prog], check=True)
453            except subprocess.CalledProcessError:
454                self.deps.add_package(prog, dtype)
455
456    def check_pacman_missing(self, pkgs, dtype):
457        """
458        Does a pacman package exists? If not, add it to missing dependencies.
459        """
460        for prog in pkgs:
461            try:
462                self.run(["pacman", "-Q", prog], check=True)
463            except subprocess.CalledProcessError:
464                self.deps.add_package(prog, dtype)
465
466    def check_missing_tex(self, is_optional=False):
467        """
468        Does a LaTeX package exists? If not, add it to missing dependencies.
469        """
470        if is_optional:
471            dtype = DepManager.PDF_OPTIONAL
472        else:
473            dtype = DepManager.PDF_MANDATORY
474
475        kpsewhich = self.which("kpsewhich")
476        for prog, package in self.texlive.items():
477
478            # If kpsewhich is not there, just add it to deps
479            if not kpsewhich:
480                self.deps.add_package(package, dtype)
481                continue
482
483            # Check if the package is needed
484            try:
485                result = self.run(
486                    [kpsewhich, prog], stdout=subprocess.PIPE, text=True, check=True
487                )
488
489                # Didn't find. Add it
490                if not result.stdout.strip():
491                    self.deps.add_package(package, dtype)
492
493            except subprocess.CalledProcessError:
494                # kpsewhich returned an error. Add it, just in case
495                self.deps.add_package(package, dtype)
496
497    def get_sphinx_fname(self):
498        """
499        Gets the binary filename for sphinx-build.
500        """
501        if "SPHINXBUILD" in os.environ:
502            return os.environ["SPHINXBUILD"]
503
504        fname = "sphinx-build"
505        if self.which(fname):
506            return fname
507
508        fname = "sphinx-build-3"
509        if self.which(fname):
510            self.need_symlink = 1
511            return fname
512
513        return ""
514
515    def get_sphinx_version(self, cmd):
516        """
517        Gets sphinx-build version.
518        """
519        try:
520            result = self.run([cmd, "--version"],
521                              stdout=subprocess.PIPE,
522                              stderr=subprocess.STDOUT,
523                              text=True, check=True)
524        except (subprocess.CalledProcessError, FileNotFoundError):
525            return None
526
527        for line in result.stdout.split("\n"):
528            match = re.match(r"^sphinx-build\s+([\d\.]+)(?:\+(?:/[\da-f]+)|b\d+)?\s*$", line)
529            if match:
530                return parse_version(match.group(1))
531
532            match = re.match(r"^Sphinx.*\s+([\d\.]+)\s*$", line)
533            if match:
534                return parse_version(match.group(1))
535
536    def check_sphinx(self, conf):
537        """
538        Checks Sphinx minimal requirements
539        """
540        try:
541            with open(conf, "r", encoding="utf-8") as f:
542                for line in f:
543                    match = re.match(r"^\s*needs_sphinx\s*=\s*[\'\"]([\d\.]+)[\'\"]", line)
544                    if match:
545                        self.min_version = parse_version(match.group(1))
546                        break
547        except IOError:
548            sys.exit(f"Can't open {conf}")
549
550        if not self.min_version:
551            sys.exit(f"Can't get needs_sphinx version from {conf}")
552
553        self.virtenv_dir = self.virtenv_prefix[0] + "latest"
554
555        sphinx = self.get_sphinx_fname()
556        if not sphinx:
557            self.need_sphinx = 1
558            return
559
560        self.cur_version = self.get_sphinx_version(sphinx)
561        if not self.cur_version:
562            sys.exit(f"{sphinx} didn't return its version")
563
564        if self.cur_version < self.min_version:
565            curver = ver_str(self.cur_version)
566            minver = ver_str(self.min_version)
567
568            print(f"ERROR: Sphinx version is {curver}. It should be >= {minver}")
569            self.need_sphinx = 1
570            return
571
572        # On version check mode, just assume Sphinx has all mandatory deps
573        if self.version_check and self.cur_version >= RECOMMENDED_VERSION:
574            sys.exit(0)
575
576    def catcheck(self, filename):
577        """
578        Reads a file if it exists, returning as string.
579        If not found, returns an empty string.
580        """
581        if os.path.exists(filename):
582            with open(filename, "r", encoding="utf-8") as f:
583                return f.read().strip()
584        return ""
585
586    def get_system_release(self):
587        """
588        Determine the system type. There's no unique way that would work
589        with all distros with a minimal package install. So, several
590        methods are used here.
591
592        By default, it will use lsb_release function. If not available, it will
593        fail back to reading the known different places where the distro name
594        is stored.
595
596        Several modern distros now have /etc/os-release, which usually have
597        a decent coverage.
598        """
599
600        system_release = ""
601
602        if self.which("lsb_release"):
603            result = self.run(["lsb_release", "-d"], capture_output=True, text=True)
604            system_release = result.stdout.replace("Description:", "").strip()
605
606        release_files = [
607            "/etc/system-release",
608            "/etc/redhat-release",
609            "/etc/lsb-release",
610            "/etc/gentoo-release",
611        ]
612
613        if not system_release:
614            for f in release_files:
615                system_release = self.catcheck(f)
616                if system_release:
617                    break
618
619        # This seems more common than LSB these days
620        if not system_release:
621            os_var = {}
622            try:
623                with open("/etc/os-release", "r", encoding="utf-8") as f:
624                    for line in f:
625                        match = re.match(r"^([\w\d\_]+)=\"?([^\"]*)\"?\n", line)
626                        if match:
627                            os_var[match.group(1)] = match.group(2)
628
629                system_release = os_var.get("NAME", "")
630                if "VERSION_ID" in os_var:
631                    system_release += " " + os_var["VERSION_ID"]
632                elif "VERSION" in os_var:
633                    system_release += " " + os_var["VERSION"]
634            except IOError:
635                pass
636
637        if not system_release:
638            system_release = self.catcheck("/etc/issue")
639
640        system_release = system_release.strip()
641
642        return system_release
643
644class SphinxDependencyChecker(MissingCheckers):
645    """
646    Main class for checking Sphinx documentation build dependencies.
647
648    - Check for missing system packages;
649    - Check for missing Python modules;
650    - Check for missing LaTeX packages needed by PDF generation;
651    - Propose Sphinx install via Python Virtual environment;
652    - Propose Sphinx install via distro-specific package install.
653    """
654    def __init__(self, args):
655        """Initialize checker variables"""
656
657        # List of required texlive packages on Fedora and OpenSuse
658        texlive = {
659            "amsfonts.sty":       "texlive-amsfonts",
660            "amsmath.sty":        "texlive-amsmath",
661            "amssymb.sty":        "texlive-amsfonts",
662            "amsthm.sty":         "texlive-amscls",
663            "anyfontsize.sty":    "texlive-anyfontsize",
664            "atbegshi.sty":       "texlive-oberdiek",
665            "bm.sty":             "texlive-tools",
666            "capt-of.sty":        "texlive-capt-of",
667            "cmap.sty":           "texlive-cmap",
668            "ctexhook.sty":       "texlive-ctex",
669            "ecrm1000.tfm":       "texlive-ec",
670            "eqparbox.sty":       "texlive-eqparbox",
671            "eu1enc.def":         "texlive-euenc",
672            "fancybox.sty":       "texlive-fancybox",
673            "fancyvrb.sty":       "texlive-fancyvrb",
674            "float.sty":          "texlive-float",
675            "fncychap.sty":       "texlive-fncychap",
676            "footnote.sty":       "texlive-mdwtools",
677            "framed.sty":         "texlive-framed",
678            "luatex85.sty":       "texlive-luatex85",
679            "multirow.sty":       "texlive-multirow",
680            "needspace.sty":      "texlive-needspace",
681            "palatino.sty":       "texlive-psnfss",
682            "parskip.sty":        "texlive-parskip",
683            "polyglossia.sty":    "texlive-polyglossia",
684            "tabulary.sty":       "texlive-tabulary",
685            "threeparttable.sty": "texlive-threeparttable",
686            "titlesec.sty":       "texlive-titlesec",
687            "ucs.sty":            "texlive-ucs",
688            "upquote.sty":        "texlive-upquote",
689            "wrapfig.sty":        "texlive-wrapfig",
690        }
691
692        super().__init__(args, texlive)
693
694        self.need_pip = False
695        self.rec_sphinx_upgrade = 0
696
697        self.system_release = self.get_system_release()
698        self.activate_cmd = ""
699
700        # Some distros may not have a Sphinx shipped package compatible with
701        # our minimal requirements
702        self.package_supported = True
703
704        # Recommend a new python version
705        self.recommend_python = None
706
707        # Certain hints are meant to be shown only once
708        self.distro_msg = None
709
710        self.latest_avail_ver = (0, 0, 0)
711        self.venv_ver = (0, 0, 0)
712
713        prefix = os.environ.get("srctree", ".") + "/"
714
715        self.conf = prefix + "Documentation/conf.py"
716        self.requirement_file = prefix + "Documentation/sphinx/requirements.txt"
717
718    def get_install_progs(self, progs, cmd, extra=None):
719        """
720        Check for missing dependencies using the provided program mapping.
721
722        The actual distro-specific programs are mapped via progs argument.
723        """
724        install = self.deps.check_missing(progs)
725
726        if self.verbose_warn_install:
727            self.deps.warn_install()
728
729        if not install:
730            return
731
732        if cmd:
733            if self.verbose_warn_install:
734                msg = "You should run:"
735            else:
736                msg = ""
737
738            if extra:
739                msg += "\n\t" + extra.replace("\n", "\n\t")
740
741            return(msg + "\n\tsudo " + cmd + " " + install)
742
743        return None
744
745    #
746    # Distro-specific hints methods
747    #
748
749    def give_debian_hints(self):
750        """
751        Provide package installation hints for Debian-based distros.
752        """
753        progs = {
754            "Pod::Usage":    "perl-modules",
755            "convert":       "imagemagick",
756            "dot":           "graphviz",
757            "ensurepip":     "python3-venv",
758            "python-sphinx": "python3-sphinx",
759            "rsvg-convert":  "librsvg2-bin",
760            "virtualenv":    "virtualenv",
761            "xelatex":       "texlive-xetex",
762            "yaml":          "python3-yaml",
763        }
764
765        if self.pdf:
766            pdf_pkgs = {
767                "fonts-dejavu": [
768                    "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
769                ],
770                "fonts-noto-cjk": [
771                    "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
772                    "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
773                    "/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
774                ],
775                "tex-gyre": [
776                    "/usr/share/texmf/tex/latex/tex-gyre/tgtermes.sty"
777                ],
778                "texlive-fonts-recommended": [
779                    "/usr/share/texlive/texmf-dist/fonts/tfm/adobe/zapfding/pzdr.tfm",
780                ],
781                "texlive-lang-chinese": [
782                    "/usr/share/texlive/texmf-dist/tex/latex/ctex/ctexhook.sty",
783                ],
784            }
785
786            for package, files in pdf_pkgs.items():
787                self.check_missing_file(files, package, DepManager.PDF_MANDATORY)
788
789            self.check_program("dvipng", DepManager.PDF_MANDATORY)
790
791        if not self.distro_msg:
792            self.distro_msg = \
793                "Note: ImageMagick is broken on some distros, affecting PDF output. For more details:\n" \
794                "\thttps://askubuntu.com/questions/1158894/imagemagick-still-broken-using-with-usr-bin-convert"
795
796        return self.get_install_progs(progs, "apt-get install")
797
798    def give_redhat_hints(self):
799        """
800        Provide package installation hints for RedHat-based distros
801        (Fedora, RHEL and RHEL-based variants).
802        """
803        progs = {
804            "Pod::Usage":       "perl-Pod-Usage",
805            "convert":          "ImageMagick",
806            "dot":              "graphviz",
807            "python-sphinx":    "python3-sphinx",
808            "rsvg-convert":     "librsvg2-tools",
809            "virtualenv":       "python3-virtualenv",
810            "xelatex":          "texlive-xetex-bin",
811            "yaml":             "python3-pyyaml",
812        }
813
814        fedora_tex_pkgs = [
815            "dejavu-sans-fonts",
816            "dejavu-sans-mono-fonts",
817            "dejavu-serif-fonts",
818            "texlive-collection-fontsrecommended",
819            "texlive-collection-latex",
820            "texlive-xecjk",
821        ]
822
823        fedora = False
824        rel = None
825
826        match = re.search(r"(release|Linux)\s+(\d+)", self.system_release)
827        if match:
828            rel = int(match.group(2))
829
830        if not rel:
831            print("Couldn't identify release number")
832            noto_sans_redhat = None
833            self.pdf = False
834        elif re.search("Fedora", self.system_release):
835            # Fedora 38 and upper use this CJK font
836
837            noto_sans_redhat = "google-noto-sans-cjk-fonts"
838            fedora = True
839        else:
840            # Almalinux, CentOS, RHEL, ...
841
842            # at least up to version 9 (and Fedora < 38), that's the CJK font
843            noto_sans_redhat = "google-noto-sans-cjk-ttc-fonts"
844
845            progs["virtualenv"] = "python-virtualenv"
846
847            if not rel or rel < 8:
848                print("ERROR: Distro not supported. Too old?")
849                return
850
851            # RHEL 8 uses Python 3.6, which is not compatible with
852            # the build system anymore. Suggest Python 3.11
853            if rel == 8:
854                self.check_program("python3.9", DepManager.SYSTEM_MANDATORY)
855                progs["python3.9"] = "python39"
856                progs["yaml"] = "python39-pyyaml"
857
858                self.recommend_python = True
859
860                # There's no python39-sphinx package. Only pip is supported
861                self.package_supported = False
862
863            if not self.distro_msg:
864                self.distro_msg = \
865                    "Note: RHEL-based distros typically require extra repositories.\n" \
866                    "For most, enabling epel and crb are enough:\n" \
867                    "\tsudo dnf install -y epel-release\n" \
868                    "\tsudo dnf config-manager --set-enabled crb\n" \
869                    "Yet, some may have other required repositories. Those commands could be useful:\n" \
870                    "\tsudo dnf repolist all\n" \
871                    "\tsudo dnf repoquery --available --info <pkgs>\n" \
872                    "\tsudo dnf config-manager --set-enabled '*' # enable all - probably not what you want"
873
874        if self.pdf:
875            pdf_pkgs = [
876                "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
877                "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc",
878            ]
879
880            self.check_missing_file(pdf_pkgs, noto_sans_redhat, DepManager.PDF_MANDATORY)
881
882            self.check_rpm_missing(fedora_tex_pkgs, DepManager.PDF_MANDATORY)
883
884            self.check_missing_tex(DepManager.PDF_MANDATORY)
885
886            # There's no texlive-ctex on RHEL 8 repositories. This will
887            # likely affect CJK pdf build only.
888            if not fedora and rel == 8:
889                self.deps.del_package("texlive-ctex")
890
891        return self.get_install_progs(progs, "dnf install")
892
893    def give_opensuse_hints(self):
894        """
895        Provide package installation hints for openSUSE-based distros
896        (Leap and Tumbleweed).
897        """
898        progs = {
899            "Pod::Usage":    "perl-Pod-Usage",
900            "convert":       "ImageMagick",
901            "dot":           "graphviz",
902            "python-sphinx": "python3-sphinx",
903            "virtualenv":    "python3-virtualenv",
904            "xelatex":       "texlive-xetex-bin texlive-dejavu",
905            "yaml":          "python3-pyyaml",
906        }
907
908        suse_tex_pkgs = [
909            "texlive-babel-english",
910            "texlive-caption",
911            "texlive-colortbl",
912            "texlive-courier",
913            "texlive-dvips",
914            "texlive-helvetic",
915            "texlive-makeindex",
916            "texlive-metafont",
917            "texlive-metapost",
918            "texlive-palatino",
919            "texlive-preview",
920            "texlive-times",
921            "texlive-zapfchan",
922            "texlive-zapfding",
923        ]
924
925        progs["latexmk"] = "texlive-latexmk-bin"
926
927        match = re.search(r"(Leap)\s+(\d+).(\d)", self.system_release)
928        if match:
929            rel = int(match.group(2))
930
931            # Leap 15.x uses Python 3.6, which is not compatible with
932            # the build system anymore. Suggest Python 3.11
933            if rel == 15:
934                if not self.which(self.python_cmd):
935                    self.check_program("python3.11", DepManager.SYSTEM_MANDATORY)
936                    progs["python3.11"] = "python311"
937                    self.recommend_python = True
938
939                progs.update({
940                    "python-sphinx": "python311-Sphinx python311-Sphinx-latex",
941                    "virtualenv":    "python311-virtualenv",
942                    "yaml":          "python311-PyYAML",
943                })
944        else:
945            # Tumbleweed defaults to Python 3.11
946
947            progs.update({
948                "python-sphinx": "python313-Sphinx python313-Sphinx-latex",
949                "virtualenv":    "python313-virtualenv",
950                "yaml":          "python313-PyYAML",
951            })
952
953        # FIXME: add support for installing CJK fonts
954        #
955        # I tried hard, but was unable to find a way to install
956        # "Noto Sans CJK SC" on openSUSE
957
958        if self.pdf:
959            self.check_rpm_missing(suse_tex_pkgs, DepManager.PDF_MANDATORY)
960        if self.pdf:
961            self.check_missing_tex()
962
963        return self.get_install_progs(progs, "zypper install --no-recommends")
964
965    def give_mageia_hints(self):
966        """
967        Provide package installation hints for Mageia and OpenMandriva.
968        """
969        progs = {
970            "Pod::Usage":    "perl-Pod-Usage",
971            "convert":       "ImageMagick",
972            "dot":           "graphviz",
973            "python-sphinx": "python3-sphinx",
974            "rsvg-convert":  "librsvg2",
975            "virtualenv":    "python3-virtualenv",
976            "xelatex":       "texlive",
977            "yaml":          "python3-yaml",
978        }
979
980        tex_pkgs = [
981            "texlive-fontsextra",
982            "texlive-fonts-asian",
983            "fonts-ttf-dejavu",
984        ]
985
986        if re.search(r"OpenMandriva", self.system_release):
987            packager_cmd = "dnf install"
988            noto_sans = "noto-sans-cjk-fonts"
989            tex_pkgs = [
990                "texlive-collection-basic",
991                "texlive-collection-langcjk",
992                "texlive-collection-fontsextra",
993                "texlive-collection-fontsrecommended"
994            ]
995
996            # Tested on OpenMandriva Lx 4.3
997            progs["convert"] = "imagemagick"
998            progs["yaml"] = "python-pyyaml"
999            progs["python-virtualenv"] = "python-virtualenv"
1000            progs["python-sphinx"] = "python-sphinx"
1001            progs["xelatex"] = "texlive"
1002
1003            self.check_program("python-virtualenv", DepManager.PYTHON_MANDATORY)
1004
1005            # On my tests with openMandriva LX 4.0 docker image, upgraded
1006            # to 4.3, python-virtualenv package is broken: it is missing
1007            # ensurepip. Without it, the alternative would be to run:
1008            # python3 -m venv --without-pip ~/sphinx_latest, but running
1009            # pip there won't install sphinx at venv.
1010            #
1011            # Add a note about that.
1012
1013            if not self.distro_msg:
1014                self.distro_msg = \
1015                    "Notes:\n"\
1016                    "1. for venv, ensurepip could be broken, preventing its install method.\n" \
1017                    "2. at least on OpenMandriva LX 4.3, texlive packages seem broken"
1018
1019        else:
1020            packager_cmd = "urpmi"
1021            noto_sans = "google-noto-sans-cjk-ttc-fonts"
1022
1023        progs["latexmk"] = "texlive-collection-basic"
1024
1025        if self.pdf:
1026            pdf_pkgs = [
1027                "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
1028                "/usr/share/fonts/TTF/NotoSans-Regular.ttf",
1029            ]
1030
1031            self.check_missing_file(pdf_pkgs, noto_sans, DepManager.PDF_MANDATORY)
1032            self.check_rpm_missing(tex_pkgs, DepManager.PDF_MANDATORY)
1033
1034        return self.get_install_progs(progs, packager_cmd)
1035
1036    def give_arch_linux_hints(self):
1037        """
1038        Provide package installation hints for ArchLinux.
1039        """
1040        progs = {
1041            "convert":      "imagemagick",
1042            "dot":          "graphviz",
1043            "latexmk":      "texlive-core",
1044            "rsvg-convert": "extra/librsvg",
1045            "virtualenv":   "python-virtualenv",
1046            "xelatex":      "texlive-xetex",
1047            "yaml":         "python-yaml",
1048        }
1049
1050        archlinux_tex_pkgs = [
1051            "texlive-core",
1052            "texlive-latexextra",
1053            "ttf-dejavu",
1054        ]
1055
1056        if self.pdf:
1057            self.check_pacman_missing(archlinux_tex_pkgs,
1058                                      DepManager.PDF_MANDATORY)
1059
1060            self.check_missing_file(["/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"],
1061                                    "noto-fonts-cjk",
1062                                    DepManager.PDF_MANDATORY)
1063
1064
1065        return self.get_install_progs(progs, "pacman -S")
1066
1067    def give_gentoo_hints(self):
1068        """
1069        Provide package installation hints for Gentoo.
1070        """
1071        texlive_deps = [
1072            "dev-texlive/texlive-latexextra",
1073            "dev-texlive/texlive-xetex",
1074            "media-fonts/dejavu",
1075            "media-fonts/lm",
1076        ]
1077
1078        progs = {
1079            "convert":       "media-gfx/imagemagick",
1080            "dot":           "media-gfx/graphviz",
1081            "rsvg-convert":  "gnome-base/librsvg",
1082            "virtualenv":    "dev-python/virtualenv",
1083            "xelatex":       " ".join(texlive_deps),
1084            "yaml":          "dev-python/pyyaml",
1085            "python-sphinx": "dev-python/sphinx",
1086        }
1087
1088        if self.pdf:
1089            pdf_pkgs = {
1090                "media-fonts/dejavu": [
1091                    "/usr/share/fonts/dejavu/DejaVuSans.ttf",
1092                ],
1093                "media-fonts/noto-cjk": [
1094                    "/usr/share/fonts/noto-cjk/NotoSansCJKsc-Regular.otf",
1095                    "/usr/share/fonts/noto-cjk/NotoSerifCJK-Regular.ttc",
1096                ],
1097            }
1098            for package, files in pdf_pkgs.items():
1099                self.check_missing_file(files, package, DepManager.PDF_MANDATORY)
1100
1101        # Handling dependencies is a nightmare, as Gentoo refuses to emerge
1102        # some packages if there's no package.use file describing them.
1103        # To make it worse, compilation flags shall also be present there
1104        # for some packages. If USE is not perfect, error/warning messages
1105        #   like those are shown:
1106        #
1107        #   !!! The following binary packages have been ignored due to non matching USE:
1108        #
1109        #    =media-gfx/graphviz-12.2.1-r1 X pdf -python_single_target_python3_13 qt6 svg
1110        #    =media-gfx/graphviz-12.2.1-r1 X pdf python_single_target_python3_12 -python_single_target_python3_13 qt6 svg
1111        #    =media-gfx/graphviz-12.2.1-r1 X pdf qt6 svg
1112        #    =media-gfx/graphviz-12.2.1-r1 X pdf -python_single_target_python3_10 qt6 svg
1113        #    =media-gfx/graphviz-12.2.1-r1 X pdf -python_single_target_python3_10 python_single_target_python3_12 -python_single_target_python3_13 qt6 svg
1114        #    =media-fonts/noto-cjk-20190416 X
1115        #    =app-text/texlive-core-2024-r1 X cjk -xetex
1116        #    =app-text/texlive-core-2024-r1 X -xetex
1117        #    =app-text/texlive-core-2024-r1 -xetex
1118        #    =dev-libs/zziplib-0.13.79-r1 sdl
1119        #
1120        # And will ignore such packages, installing the remaining ones. That
1121        # affects mostly the image extension and PDF generation.
1122
1123        # Package dependencies and the minimal needed args:
1124        portages = {
1125            "graphviz": "media-gfx/graphviz",
1126            "imagemagick": "media-gfx/imagemagick",
1127            "media-libs": "media-libs/harfbuzz icu",
1128            "media-fonts": "media-fonts/noto-cjk",
1129            "texlive": "app-text/texlive-core xetex",
1130            "zziblib": "dev-libs/zziplib sdl",
1131        }
1132
1133        extra_cmds = ""
1134        if not self.distro_msg:
1135            self.distro_msg = "Note: Gentoo requires package.use to be adjusted before emerging packages"
1136
1137            use_base = "/etc/portage/package.use"
1138            files = glob(f"{use_base}/*")
1139
1140            for fname, portage in portages.items():
1141                install = False
1142
1143                while install is False:
1144                    if not files:
1145                        # No files under package.usage. Install all
1146                        install = True
1147                        break
1148
1149                    args = portage.split(" ")
1150
1151                    name = args.pop(0)
1152
1153                    cmd = ["grep", "-l", "-E", rf"^{name}\b" ] + files
1154                    result = self.run(cmd, stdout=subprocess.PIPE, text=True)
1155                    if result.returncode or not result.stdout.strip():
1156                        # File containing portage name not found
1157                        install = True
1158                        break
1159
1160                    # Ensure that needed USE flags are present
1161                    if args:
1162                        match_fname = result.stdout.strip()
1163                        with open(match_fname, 'r', encoding='utf8',
1164                                errors='backslashreplace') as fp:
1165                            for line in fp:
1166                                for arg in args:
1167                                    if arg.startswith("-"):
1168                                        continue
1169
1170                                if not re.search(rf"\s*{arg}\b", line):
1171                                    # Needed file argument not found
1172                                    install = True
1173                                    break
1174
1175                    # Everything looks ok, don't install
1176                    break
1177
1178                # emit a code to setup missing USE
1179                if install:
1180                    extra_cmds += (f"sudo su -c 'echo \"{portage}\" > {use_base}/{fname}'\n")
1181
1182        # Now, we can use emerge and let it respect USE
1183        return self.get_install_progs(progs,
1184                                      "emerge --ask --changed-use --binpkg-respect-use=y",
1185                                      extra_cmds)
1186
1187    def get_install(self):
1188        """
1189        OS-specific hints logic. Seeks for a hinter. If found, use it to
1190        provide package-manager specific install commands.
1191
1192        Otherwise, outputs install instructions for the meta-packages.
1193
1194        Returns a string with the command to be executed to install the
1195        the needed packages, if distro found. Otherwise, return just a
1196        list of packages that require installation.
1197        """
1198        os_hints = {
1199            re.compile("Red Hat Enterprise Linux"):   self.give_redhat_hints,
1200            re.compile("Fedora"):                     self.give_redhat_hints,
1201            re.compile("AlmaLinux"):                  self.give_redhat_hints,
1202            re.compile("Amazon Linux"):               self.give_redhat_hints,
1203            re.compile("CentOS"):                     self.give_redhat_hints,
1204            re.compile("openEuler"):                  self.give_redhat_hints,
1205            re.compile("Oracle Linux Server"):        self.give_redhat_hints,
1206            re.compile("Rocky Linux"):                self.give_redhat_hints,
1207            re.compile("Springdale Open Enterprise"): self.give_redhat_hints,
1208
1209            re.compile("Ubuntu"):                     self.give_debian_hints,
1210            re.compile("Debian"):                     self.give_debian_hints,
1211            re.compile("Devuan"):                     self.give_debian_hints,
1212            re.compile("Kali"):                       self.give_debian_hints,
1213            re.compile("Mint"):                       self.give_debian_hints,
1214
1215            re.compile("openSUSE"):                   self.give_opensuse_hints,
1216
1217            re.compile("Mageia"):                     self.give_mageia_hints,
1218            re.compile("OpenMandriva"):               self.give_mageia_hints,
1219
1220            re.compile("Arch Linux"):                 self.give_arch_linux_hints,
1221            re.compile("Gentoo"):                     self.give_gentoo_hints,
1222        }
1223
1224        # If the OS is detected, use per-OS hint logic
1225        for regex, os_hint in os_hints.items():
1226            if regex.search(self.system_release):
1227                return os_hint()
1228
1229        #
1230        # Fall-back to generic hint code for other distros
1231        # That's far from ideal, specially for LaTeX dependencies.
1232        #
1233        progs = {"sphinx-build": "sphinx"}
1234        if self.pdf:
1235            self.check_missing_tex()
1236
1237        self.distro_msg = \
1238            f"I don't know distro {self.system_release}.\n" \
1239            "So, I can't provide you a hint with the install procedure.\n" \
1240            "There are likely missing dependencies."
1241
1242        return self.get_install_progs(progs, None)
1243
1244    #
1245    # Common dependencies
1246    #
1247    def deactivate_help(self):
1248        """
1249        Print a helper message to disable a virtual environment.
1250        """
1251
1252        print("\n    If you want to exit the virtualenv, you can use:")
1253        print("\tdeactivate")
1254
1255    def get_virtenv(self):
1256        """
1257        Give a hint about how to activate an already-existing virtual
1258        environment containing sphinx-build.
1259
1260        Returns a tuble with (activate_cmd_path, sphinx_version) with
1261        the newest available virtual env.
1262        """
1263
1264        cwd = os.getcwd()
1265
1266        activates = []
1267
1268        # Add all sphinx prefixes with possible version numbers
1269        for p in self.virtenv_prefix:
1270            activates += glob(f"{cwd}/{p}[0-9]*/bin/activate")
1271
1272        activates.sort(reverse=True, key=str.lower)
1273
1274        # Place sphinx_latest first, if it exists
1275        for p in self.virtenv_prefix:
1276            activates = glob(f"{cwd}/{p}*latest/bin/activate") + activates
1277
1278        ver = (0, 0, 0)
1279        for f in activates:
1280            # Discard too old Sphinx virtual environments
1281            match = re.search(r"(\d+)\.(\d+)\.(\d+)", f)
1282            if match:
1283                ver = (int(match.group(1)), int(match.group(2)), int(match.group(3)))
1284
1285                if ver < self.min_version:
1286                    continue
1287
1288            sphinx_cmd = f.replace("activate", "sphinx-build")
1289            if not os.path.isfile(sphinx_cmd):
1290                continue
1291
1292            ver = self.get_sphinx_version(sphinx_cmd)
1293
1294            if not ver:
1295                venv_dir = f.replace("/bin/activate", "")
1296                print(f"Warning: virtual environment {venv_dir} is not working.\n" \
1297                      "Python version upgrade? Remove it with:\n\n" \
1298                      "\trm -rf {venv_dir}\n\n")
1299            else:
1300                if self.need_sphinx and ver >= self.min_version:
1301                    return (f, ver)
1302                elif parse_version(ver) > self.cur_version:
1303                    return (f, ver)
1304
1305        return ("", ver)
1306
1307    def recommend_sphinx_upgrade(self):
1308        """
1309        Check if Sphinx needs to be upgraded.
1310
1311        Returns a tuple with the higest available Sphinx version if found.
1312        Otherwise, returns None to indicate either that no upgrade is needed
1313        or no venv was found.
1314        """
1315
1316        # Avoid running sphinx-builds from venv if cur_version is good
1317        if self.cur_version and self.cur_version >= RECOMMENDED_VERSION:
1318            self.latest_avail_ver = self.cur_version
1319            return None
1320
1321        # Get the highest version from sphinx_*/bin/sphinx-build and the
1322        # corresponding command to activate the venv/virtenv
1323        self.activate_cmd, self.venv_ver = self.get_virtenv()
1324
1325        # Store the highest version from Sphinx existing virtualenvs
1326        if self.activate_cmd and self.venv_ver > self.cur_version:
1327            self.latest_avail_ver = self.venv_ver
1328        else:
1329            if self.cur_version:
1330                self.latest_avail_ver = self.cur_version
1331            else:
1332                self.latest_avail_ver = (0, 0, 0)
1333
1334        # As we don't know package version of Sphinx, and there's no
1335        # virtual environments, don't check if upgrades are needed
1336        if not self.virtualenv:
1337            if not self.latest_avail_ver:
1338                return None
1339
1340            return self.latest_avail_ver
1341
1342        # Either there are already a virtual env or a new one should be created
1343        self.need_pip = True
1344
1345        if not self.latest_avail_ver:
1346            return None
1347
1348        # Return if the reason is due to an upgrade or not
1349        if self.latest_avail_ver != (0, 0, 0):
1350            if self.latest_avail_ver < RECOMMENDED_VERSION:
1351                self.rec_sphinx_upgrade = 1
1352
1353        return self.latest_avail_ver
1354
1355    def recommend_package(self):
1356        """
1357        Recommend installing Sphinx as a distro-specific package.
1358        """
1359
1360        print("\n2) As a package with:")
1361
1362        old_need = self.deps.need
1363        old_optional = self.deps.optional
1364
1365        self.pdf = False
1366        self.deps.optional = 0
1367        old_verbose = self.verbose_warn_install
1368        self.verbose_warn_install = 0
1369
1370        self.deps.clear_deps()
1371
1372        self.deps.add_package("python-sphinx", DepManager.PYTHON_MANDATORY)
1373
1374        cmd = self.get_install()
1375        if cmd:
1376            print(cmd)
1377
1378        self.deps.need = old_need
1379        self.deps.optional = old_optional
1380        self.verbose_warn_install = old_verbose
1381
1382    def recommend_sphinx_version(self, virtualenv_cmd):
1383        """
1384        Provide recommendations for installing or upgrading Sphinx based
1385        on current version.
1386
1387        The logic here is complex, as it have to deal with different versions:
1388
1389        - minimal supported version;
1390        - minimal PDF version;
1391        - recommended version.
1392
1393        It also needs to work fine with both distro's package and
1394        venv/virtualenv
1395        """
1396
1397        if self.recommend_python:
1398            cur_ver = sys.version_info[:3]
1399            if cur_ver < MIN_PYTHON_VERSION:
1400                print(f"\nPython version {cur_ver} is incompatible with doc build.\n" \
1401                    "Please upgrade it and re-run.\n")
1402                return
1403
1404        # Version is OK. Nothing to do.
1405        if self.cur_version != (0, 0, 0) and self.cur_version >= RECOMMENDED_VERSION:
1406            return
1407
1408        if self.latest_avail_ver:
1409            latest_avail_ver = ver_str(self.latest_avail_ver)
1410
1411        if not self.need_sphinx:
1412            # sphinx-build is present and its version is >= $min_version
1413
1414            # only recommend enabling a newer virtenv version if makes sense.
1415            if self.latest_avail_ver and self.latest_avail_ver > self.cur_version:
1416                print(f"\nYou may also use the newer Sphinx version {latest_avail_ver} with:")
1417                if f"{self.virtenv_prefix}" in os.getcwd():
1418                    print("\tdeactivate")
1419                print(f"\t. {self.activate_cmd}")
1420                self.deactivate_help()
1421                return
1422
1423            if self.latest_avail_ver and self.latest_avail_ver >= RECOMMENDED_VERSION:
1424                return
1425
1426        if not self.virtualenv:
1427            # No sphinx either via package or via virtenv. As we can't
1428            # Compare the versions here, just return, recommending the
1429            # user to install it from the package distro.
1430            if not self.latest_avail_ver or self.latest_avail_ver == (0, 0, 0):
1431                return
1432
1433            # User doesn't want a virtenv recommendation, but he already
1434            # installed one via virtenv with a newer version.
1435            # So, print commands to enable it
1436            if self.latest_avail_ver > self.cur_version:
1437                print(f"\nYou may also use the Sphinx virtualenv version {latest_avail_ver} with:")
1438                if f"{self.virtenv_prefix}" in os.getcwd():
1439                    print("\tdeactivate")
1440                print(f"\t. {self.activate_cmd}")
1441                self.deactivate_help()
1442                return
1443            print("\n")
1444        else:
1445            if self.need_sphinx:
1446                self.deps.need += 1
1447
1448        # Suggest newer versions if current ones are too old
1449        if self.latest_avail_ver and self.latest_avail_ver >= self.min_version:
1450            if self.latest_avail_ver >= RECOMMENDED_VERSION:
1451                print(f"\nNeed to activate Sphinx (version {latest_avail_ver}) on virtualenv with:")
1452                print(f"\t. {self.activate_cmd}")
1453                self.deactivate_help()
1454                return
1455
1456            # Version is above the minimal required one, but may be
1457            # below the recommended one. So, print warnings/notes
1458            if self.latest_avail_ver < RECOMMENDED_VERSION:
1459                print(f"Warning: It is recommended at least Sphinx version {RECOMMENDED_VERSION}.")
1460
1461        # At this point, either it needs Sphinx or upgrade is recommended,
1462        # both via pip
1463
1464        if self.rec_sphinx_upgrade:
1465            if not self.virtualenv:
1466                print("Instead of install/upgrade Python Sphinx pkg, you could use pip/pypi with:\n\n")
1467            else:
1468                print("To upgrade Sphinx, use:\n\n")
1469        else:
1470            print("\nSphinx needs to be installed either:\n1) via pip/pypi with:\n")
1471
1472        if not virtualenv_cmd:
1473            print("   Currently not possible.\n")
1474            print("   Please upgrade Python to a newer version and run this script again")
1475        else:
1476            print(f"\t{virtualenv_cmd} {self.virtenv_dir}")
1477            print(f"\t. {self.virtenv_dir}/bin/activate")
1478            print(f"\tpip install -r {self.requirement_file}")
1479            self.deactivate_help()
1480
1481        if self.package_supported:
1482            self.recommend_package()
1483
1484        print("\n" \
1485              "   Please note that Sphinx currentlys produce false-positive\n" \
1486              "   warnings when the same name is used for more than one type (functions,\n" \
1487              "   structs, enums,...). This is known Sphinx bug. For more details, see:\n" \
1488              "\thttps://github.com/sphinx-doc/sphinx/pull/8313")
1489
1490    def check_needs(self):
1491        """
1492        Main method that checks needed dependencies and provides
1493        recommendations.
1494        """
1495        self.python_cmd = sys.executable
1496
1497        # Check if Sphinx is already accessible from current environment
1498        self.check_sphinx(self.conf)
1499
1500        if self.system_release:
1501            print(f"Detected OS: {self.system_release}.")
1502        else:
1503            print("Unknown OS")
1504        if self.cur_version != (0, 0, 0):
1505            ver = ver_str(self.cur_version)
1506            print(f"Sphinx version: {ver}\n")
1507
1508        # Check the type of virtual env, depending on Python version
1509        virtualenv_cmd = None
1510
1511        if sys.version_info < MIN_PYTHON_VERSION:
1512            min_ver = ver_str(MIN_PYTHON_VERSION)
1513            print(f"ERROR: at least python {min_ver} is required to build the kernel docs")
1514            self.need_sphinx = 1
1515
1516        self.venv_ver = self.recommend_sphinx_upgrade()
1517
1518        if self.need_pip:
1519            if sys.version_info < MIN_PYTHON_VERSION:
1520                self.need_pip = False
1521                print("Warning: python version is not supported.")
1522            else:
1523                virtualenv_cmd = f"{self.python_cmd} -m venv"
1524                self.check_python_module("ensurepip")
1525
1526        # Check for needed programs/tools
1527        self.check_perl_module("Pod::Usage", DepManager.SYSTEM_MANDATORY)
1528
1529        self.check_program("make", DepManager.SYSTEM_MANDATORY)
1530        self.check_program("which", DepManager.SYSTEM_MANDATORY)
1531
1532        self.check_program("dot", DepManager.SYSTEM_OPTIONAL)
1533        self.check_program("convert", DepManager.SYSTEM_OPTIONAL)
1534
1535        self.check_python_module("yaml")
1536
1537        if self.pdf:
1538            self.check_program("xelatex", DepManager.PDF_MANDATORY)
1539            self.check_program("rsvg-convert", DepManager.PDF_MANDATORY)
1540            self.check_program("latexmk", DepManager.PDF_MANDATORY)
1541
1542        # Do distro-specific checks and output distro-install commands
1543        cmd = self.get_install()
1544        if cmd:
1545            print(cmd)
1546
1547        # If distro requires some special instructions, print here.
1548        # Please notice that get_install() needs to be called first.
1549        if self.distro_msg:
1550            print("\n" + self.distro_msg)
1551
1552        if not self.python_cmd:
1553            if self.need == 1:
1554                sys.exit("Can't build as 1 mandatory dependency is missing")
1555            elif self.need:
1556                sys.exit(f"Can't build as {self.need} mandatory dependencies are missing")
1557
1558        # Check if sphinx-build is called sphinx-build-3
1559        if self.need_symlink:
1560            sphinx_path = self.which("sphinx-build-3")
1561            if sphinx_path:
1562                print(f"\tsudo ln -sf {sphinx_path} /usr/bin/sphinx-build\n")
1563
1564        self.recommend_sphinx_version(virtualenv_cmd)
1565        print("")
1566
1567        if not self.deps.optional:
1568            print("All optional dependencies are met.")
1569
1570        if self.deps.need == 1:
1571            sys.exit("Can't build as 1 mandatory dependency is missing")
1572        elif self.deps.need:
1573            sys.exit(f"Can't build as {self.deps.need} mandatory dependencies are missing")
1574
1575        print("Needed package dependencies are met.")
1576
1577DESCRIPTION = """
1578Process some flags related to Sphinx installation and documentation build.
1579"""
1580
1581
1582def main():
1583    """Main function"""
1584    parser = argparse.ArgumentParser(description=DESCRIPTION)
1585
1586    parser.add_argument(
1587        "--no-virtualenv",
1588        action="store_false",
1589        dest="virtualenv",
1590        help="Recommend installing Sphinx instead of using a virtualenv",
1591    )
1592
1593    parser.add_argument(
1594        "--no-pdf",
1595        action="store_false",
1596        dest="pdf",
1597        help="Don't check for dependencies required to build PDF docs",
1598    )
1599
1600    parser.add_argument(
1601        "--version-check",
1602        action="store_true",
1603        dest="version_check",
1604        help="If version is compatible, don't check for missing dependencies",
1605    )
1606
1607    args = parser.parse_args()
1608
1609    checker = SphinxDependencyChecker(args)
1610
1611    checker.check_python()
1612    checker.check_needs()
1613
1614# Call main if not used as module
1615if __name__ == "__main__":
1616    main()
1617