xref: /linux/scripts/sphinx-pre-install (revision ee2fe81cdcd17f875aeca074afe64d7e8f57750f)
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-basic",
1052            "texlive-binextra",
1053            "texlive-core",
1054            "texlive-fontsrecommended",
1055            "texlive-langchinese",
1056            "texlive-langcjk",
1057            "texlive-latexextra",
1058            "ttf-dejavu",
1059        ]
1060
1061        if self.pdf:
1062            self.check_pacman_missing(archlinux_tex_pkgs,
1063                                      DepManager.PDF_MANDATORY)
1064
1065            self.check_missing_file(["/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"],
1066                                    "noto-fonts-cjk",
1067                                    DepManager.PDF_MANDATORY)
1068
1069
1070        return self.get_install_progs(progs, "pacman -S")
1071
1072    def give_gentoo_hints(self):
1073        """
1074        Provide package installation hints for Gentoo.
1075        """
1076        texlive_deps = [
1077            "dev-texlive/texlive-fontsrecommended",
1078            "dev-texlive/texlive-latexextra",
1079            "dev-texlive/texlive-xetex",
1080            "media-fonts/dejavu",
1081        ]
1082
1083        progs = {
1084            "convert":       "media-gfx/imagemagick",
1085            "dot":           "media-gfx/graphviz",
1086            "rsvg-convert":  "gnome-base/librsvg",
1087            "virtualenv":    "dev-python/virtualenv",
1088            "xelatex":       " ".join(texlive_deps),
1089            "yaml":          "dev-python/pyyaml",
1090            "python-sphinx": "dev-python/sphinx",
1091        }
1092
1093        if self.pdf:
1094            pdf_pkgs = {
1095                "media-fonts/dejavu": [
1096                    "/usr/share/fonts/dejavu/DejaVuSans.ttf",
1097                ],
1098                "media-fonts/noto-cjk": [
1099                    "/usr/share/fonts/noto-cjk/NotoSansCJKsc-Regular.otf",
1100                    "/usr/share/fonts/noto-cjk/NotoSerifCJK-Regular.ttc",
1101                ],
1102            }
1103            for package, files in pdf_pkgs.items():
1104                self.check_missing_file(files, package, DepManager.PDF_MANDATORY)
1105
1106        # Handling dependencies is a nightmare, as Gentoo refuses to emerge
1107        # some packages if there's no package.use file describing them.
1108        # To make it worse, compilation flags shall also be present there
1109        # for some packages. If USE is not perfect, error/warning messages
1110        #   like those are shown:
1111        #
1112        #   !!! The following binary packages have been ignored due to non matching USE:
1113        #
1114        #    =media-gfx/graphviz-12.2.1-r1 X pdf -python_single_target_python3_13 qt6 svg
1115        #    =media-gfx/graphviz-12.2.1-r1 X pdf python_single_target_python3_12 -python_single_target_python3_13 qt6 svg
1116        #    =media-gfx/graphviz-12.2.1-r1 X pdf qt6 svg
1117        #    =media-gfx/graphviz-12.2.1-r1 X pdf -python_single_target_python3_10 qt6 svg
1118        #    =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
1119        #    =media-fonts/noto-cjk-20190416 X
1120        #    =app-text/texlive-core-2024-r1 X cjk -xetex
1121        #    =app-text/texlive-core-2024-r1 X -xetex
1122        #    =app-text/texlive-core-2024-r1 -xetex
1123        #    =dev-libs/zziplib-0.13.79-r1 sdl
1124        #
1125        # And will ignore such packages, installing the remaining ones. That
1126        # affects mostly the image extension and PDF generation.
1127
1128        # Package dependencies and the minimal needed args:
1129        portages = {
1130            "graphviz": "media-gfx/graphviz",
1131            "imagemagick": "media-gfx/imagemagick",
1132            "media-libs": "media-libs/harfbuzz icu",
1133            "media-fonts": "media-fonts/noto-cjk",
1134            "texlive": "app-text/texlive-core xetex",
1135            "zziblib": "dev-libs/zziplib sdl",
1136        }
1137
1138        extra_cmds = ""
1139        if not self.distro_msg:
1140            self.distro_msg = "Note: Gentoo requires package.use to be adjusted before emerging packages"
1141
1142            use_base = "/etc/portage/package.use"
1143            files = glob(f"{use_base}/*")
1144
1145            for fname, portage in portages.items():
1146                install = False
1147
1148                while install is False:
1149                    if not files:
1150                        # No files under package.usage. Install all
1151                        install = True
1152                        break
1153
1154                    args = portage.split(" ")
1155
1156                    name = args.pop(0)
1157
1158                    cmd = ["grep", "-l", "-E", rf"^{name}\b" ] + files
1159                    result = self.run(cmd, stdout=subprocess.PIPE, text=True)
1160                    if result.returncode or not result.stdout.strip():
1161                        # File containing portage name not found
1162                        install = True
1163                        break
1164
1165                    # Ensure that needed USE flags are present
1166                    if args:
1167                        match_fname = result.stdout.strip()
1168                        with open(match_fname, 'r', encoding='utf8',
1169                                errors='backslashreplace') as fp:
1170                            for line in fp:
1171                                for arg in args:
1172                                    if arg.startswith("-"):
1173                                        continue
1174
1175                                if not re.search(rf"\s*{arg}\b", line):
1176                                    # Needed file argument not found
1177                                    install = True
1178                                    break
1179
1180                    # Everything looks ok, don't install
1181                    break
1182
1183                # emit a code to setup missing USE
1184                if install:
1185                    extra_cmds += (f"sudo su -c 'echo \"{portage}\" > {use_base}/{fname}'\n")
1186
1187        # Now, we can use emerge and let it respect USE
1188        return self.get_install_progs(progs,
1189                                      "emerge --ask --changed-use --binpkg-respect-use=y",
1190                                      extra_cmds)
1191
1192    def get_install(self):
1193        """
1194        OS-specific hints logic. Seeks for a hinter. If found, use it to
1195        provide package-manager specific install commands.
1196
1197        Otherwise, outputs install instructions for the meta-packages.
1198
1199        Returns a string with the command to be executed to install the
1200        the needed packages, if distro found. Otherwise, return just a
1201        list of packages that require installation.
1202        """
1203        os_hints = {
1204            re.compile("Red Hat Enterprise Linux"):   self.give_redhat_hints,
1205            re.compile("Fedora"):                     self.give_redhat_hints,
1206            re.compile("AlmaLinux"):                  self.give_redhat_hints,
1207            re.compile("Amazon Linux"):               self.give_redhat_hints,
1208            re.compile("CentOS"):                     self.give_redhat_hints,
1209            re.compile("openEuler"):                  self.give_redhat_hints,
1210            re.compile("Oracle Linux Server"):        self.give_redhat_hints,
1211            re.compile("Rocky Linux"):                self.give_redhat_hints,
1212            re.compile("Springdale Open Enterprise"): self.give_redhat_hints,
1213
1214            re.compile("Ubuntu"):                     self.give_debian_hints,
1215            re.compile("Debian"):                     self.give_debian_hints,
1216            re.compile("Devuan"):                     self.give_debian_hints,
1217            re.compile("Kali"):                       self.give_debian_hints,
1218            re.compile("Mint"):                       self.give_debian_hints,
1219
1220            re.compile("openSUSE"):                   self.give_opensuse_hints,
1221
1222            re.compile("Mageia"):                     self.give_mageia_hints,
1223            re.compile("OpenMandriva"):               self.give_mageia_hints,
1224
1225            re.compile("Arch Linux"):                 self.give_arch_linux_hints,
1226            re.compile("Gentoo"):                     self.give_gentoo_hints,
1227        }
1228
1229        # If the OS is detected, use per-OS hint logic
1230        for regex, os_hint in os_hints.items():
1231            if regex.search(self.system_release):
1232                return os_hint()
1233
1234        #
1235        # Fall-back to generic hint code for other distros
1236        # That's far from ideal, specially for LaTeX dependencies.
1237        #
1238        progs = {"sphinx-build": "sphinx"}
1239        if self.pdf:
1240            self.check_missing_tex()
1241
1242        self.distro_msg = \
1243            f"I don't know distro {self.system_release}.\n" \
1244            "So, I can't provide you a hint with the install procedure.\n" \
1245            "There are likely missing dependencies."
1246
1247        return self.get_install_progs(progs, None)
1248
1249    #
1250    # Common dependencies
1251    #
1252    def deactivate_help(self):
1253        """
1254        Print a helper message to disable a virtual environment.
1255        """
1256
1257        print("\n    If you want to exit the virtualenv, you can use:")
1258        print("\tdeactivate")
1259
1260    def get_virtenv(self):
1261        """
1262        Give a hint about how to activate an already-existing virtual
1263        environment containing sphinx-build.
1264
1265        Returns a tuble with (activate_cmd_path, sphinx_version) with
1266        the newest available virtual env.
1267        """
1268
1269        cwd = os.getcwd()
1270
1271        activates = []
1272
1273        # Add all sphinx prefixes with possible version numbers
1274        for p in self.virtenv_prefix:
1275            activates += glob(f"{cwd}/{p}[0-9]*/bin/activate")
1276
1277        activates.sort(reverse=True, key=str.lower)
1278
1279        # Place sphinx_latest first, if it exists
1280        for p in self.virtenv_prefix:
1281            activates = glob(f"{cwd}/{p}*latest/bin/activate") + activates
1282
1283        ver = (0, 0, 0)
1284        for f in activates:
1285            # Discard too old Sphinx virtual environments
1286            match = re.search(r"(\d+)\.(\d+)\.(\d+)", f)
1287            if match:
1288                ver = (int(match.group(1)), int(match.group(2)), int(match.group(3)))
1289
1290                if ver < self.min_version:
1291                    continue
1292
1293            sphinx_cmd = f.replace("activate", "sphinx-build")
1294            if not os.path.isfile(sphinx_cmd):
1295                continue
1296
1297            ver = self.get_sphinx_version(sphinx_cmd)
1298
1299            if not ver:
1300                venv_dir = f.replace("/bin/activate", "")
1301                print(f"Warning: virtual environment {venv_dir} is not working.\n" \
1302                      "Python version upgrade? Remove it with:\n\n" \
1303                      "\trm -rf {venv_dir}\n\n")
1304            else:
1305                if self.need_sphinx and ver >= self.min_version:
1306                    return (f, ver)
1307                elif parse_version(ver) > self.cur_version:
1308                    return (f, ver)
1309
1310        return ("", ver)
1311
1312    def recommend_sphinx_upgrade(self):
1313        """
1314        Check if Sphinx needs to be upgraded.
1315
1316        Returns a tuple with the higest available Sphinx version if found.
1317        Otherwise, returns None to indicate either that no upgrade is needed
1318        or no venv was found.
1319        """
1320
1321        # Avoid running sphinx-builds from venv if cur_version is good
1322        if self.cur_version and self.cur_version >= RECOMMENDED_VERSION:
1323            self.latest_avail_ver = self.cur_version
1324            return None
1325
1326        # Get the highest version from sphinx_*/bin/sphinx-build and the
1327        # corresponding command to activate the venv/virtenv
1328        self.activate_cmd, self.venv_ver = self.get_virtenv()
1329
1330        # Store the highest version from Sphinx existing virtualenvs
1331        if self.activate_cmd and self.venv_ver > self.cur_version:
1332            self.latest_avail_ver = self.venv_ver
1333        else:
1334            if self.cur_version:
1335                self.latest_avail_ver = self.cur_version
1336            else:
1337                self.latest_avail_ver = (0, 0, 0)
1338
1339        # As we don't know package version of Sphinx, and there's no
1340        # virtual environments, don't check if upgrades are needed
1341        if not self.virtualenv:
1342            if not self.latest_avail_ver:
1343                return None
1344
1345            return self.latest_avail_ver
1346
1347        # Either there are already a virtual env or a new one should be created
1348        self.need_pip = True
1349
1350        if not self.latest_avail_ver:
1351            return None
1352
1353        # Return if the reason is due to an upgrade or not
1354        if self.latest_avail_ver != (0, 0, 0):
1355            if self.latest_avail_ver < RECOMMENDED_VERSION:
1356                self.rec_sphinx_upgrade = 1
1357
1358        return self.latest_avail_ver
1359
1360    def recommend_package(self):
1361        """
1362        Recommend installing Sphinx as a distro-specific package.
1363        """
1364
1365        print("\n2) As a package with:")
1366
1367        old_need = self.deps.need
1368        old_optional = self.deps.optional
1369
1370        self.pdf = False
1371        self.deps.optional = 0
1372        old_verbose = self.verbose_warn_install
1373        self.verbose_warn_install = 0
1374
1375        self.deps.clear_deps()
1376
1377        self.deps.add_package("python-sphinx", DepManager.PYTHON_MANDATORY)
1378
1379        cmd = self.get_install()
1380        if cmd:
1381            print(cmd)
1382
1383        self.deps.need = old_need
1384        self.deps.optional = old_optional
1385        self.verbose_warn_install = old_verbose
1386
1387    def recommend_sphinx_version(self, virtualenv_cmd):
1388        """
1389        Provide recommendations for installing or upgrading Sphinx based
1390        on current version.
1391
1392        The logic here is complex, as it have to deal with different versions:
1393
1394        - minimal supported version;
1395        - minimal PDF version;
1396        - recommended version.
1397
1398        It also needs to work fine with both distro's package and
1399        venv/virtualenv
1400        """
1401
1402        if self.recommend_python:
1403            cur_ver = sys.version_info[:3]
1404            if cur_ver < MIN_PYTHON_VERSION:
1405                print(f"\nPython version {cur_ver} is incompatible with doc build.\n" \
1406                    "Please upgrade it and re-run.\n")
1407                return
1408
1409        # Version is OK. Nothing to do.
1410        if self.cur_version != (0, 0, 0) and self.cur_version >= RECOMMENDED_VERSION:
1411            return
1412
1413        if self.latest_avail_ver:
1414            latest_avail_ver = ver_str(self.latest_avail_ver)
1415
1416        if not self.need_sphinx:
1417            # sphinx-build is present and its version is >= $min_version
1418
1419            # only recommend enabling a newer virtenv version if makes sense.
1420            if self.latest_avail_ver and self.latest_avail_ver > self.cur_version:
1421                print(f"\nYou may also use the newer Sphinx version {latest_avail_ver} with:")
1422                if f"{self.virtenv_prefix}" in os.getcwd():
1423                    print("\tdeactivate")
1424                print(f"\t. {self.activate_cmd}")
1425                self.deactivate_help()
1426                return
1427
1428            if self.latest_avail_ver and self.latest_avail_ver >= RECOMMENDED_VERSION:
1429                return
1430
1431        if not self.virtualenv:
1432            # No sphinx either via package or via virtenv. As we can't
1433            # Compare the versions here, just return, recommending the
1434            # user to install it from the package distro.
1435            if not self.latest_avail_ver or self.latest_avail_ver == (0, 0, 0):
1436                return
1437
1438            # User doesn't want a virtenv recommendation, but he already
1439            # installed one via virtenv with a newer version.
1440            # So, print commands to enable it
1441            if self.latest_avail_ver > self.cur_version:
1442                print(f"\nYou may also use the Sphinx virtualenv version {latest_avail_ver} with:")
1443                if f"{self.virtenv_prefix}" in os.getcwd():
1444                    print("\tdeactivate")
1445                print(f"\t. {self.activate_cmd}")
1446                self.deactivate_help()
1447                return
1448            print("\n")
1449        else:
1450            if self.need_sphinx:
1451                self.deps.need += 1
1452
1453        # Suggest newer versions if current ones are too old
1454        if self.latest_avail_ver and self.latest_avail_ver >= self.min_version:
1455            if self.latest_avail_ver >= RECOMMENDED_VERSION:
1456                print(f"\nNeed to activate Sphinx (version {latest_avail_ver}) on virtualenv with:")
1457                print(f"\t. {self.activate_cmd}")
1458                self.deactivate_help()
1459                return
1460
1461            # Version is above the minimal required one, but may be
1462            # below the recommended one. So, print warnings/notes
1463            if self.latest_avail_ver < RECOMMENDED_VERSION:
1464                print(f"Warning: It is recommended at least Sphinx version {RECOMMENDED_VERSION}.")
1465
1466        # At this point, either it needs Sphinx or upgrade is recommended,
1467        # both via pip
1468
1469        if self.rec_sphinx_upgrade:
1470            if not self.virtualenv:
1471                print("Instead of install/upgrade Python Sphinx pkg, you could use pip/pypi with:\n\n")
1472            else:
1473                print("To upgrade Sphinx, use:\n\n")
1474        else:
1475            print("\nSphinx needs to be installed either:\n1) via pip/pypi with:\n")
1476
1477        if not virtualenv_cmd:
1478            print("   Currently not possible.\n")
1479            print("   Please upgrade Python to a newer version and run this script again")
1480        else:
1481            print(f"\t{virtualenv_cmd} {self.virtenv_dir}")
1482            print(f"\t. {self.virtenv_dir}/bin/activate")
1483            print(f"\tpip install -r {self.requirement_file}")
1484            self.deactivate_help()
1485
1486        if self.package_supported:
1487            self.recommend_package()
1488
1489        print("\n" \
1490              "   Please note that Sphinx currentlys produce false-positive\n" \
1491              "   warnings when the same name is used for more than one type (functions,\n" \
1492              "   structs, enums,...). This is known Sphinx bug. For more details, see:\n" \
1493              "\thttps://github.com/sphinx-doc/sphinx/pull/8313")
1494
1495    def check_needs(self):
1496        """
1497        Main method that checks needed dependencies and provides
1498        recommendations.
1499        """
1500        self.python_cmd = sys.executable
1501
1502        # Check if Sphinx is already accessible from current environment
1503        self.check_sphinx(self.conf)
1504
1505        if self.system_release:
1506            print(f"Detected OS: {self.system_release}.")
1507        else:
1508            print("Unknown OS")
1509        if self.cur_version != (0, 0, 0):
1510            ver = ver_str(self.cur_version)
1511            print(f"Sphinx version: {ver}\n")
1512
1513        # Check the type of virtual env, depending on Python version
1514        virtualenv_cmd = None
1515
1516        if sys.version_info < MIN_PYTHON_VERSION:
1517            min_ver = ver_str(MIN_PYTHON_VERSION)
1518            print(f"ERROR: at least python {min_ver} is required to build the kernel docs")
1519            self.need_sphinx = 1
1520
1521        self.venv_ver = self.recommend_sphinx_upgrade()
1522
1523        if self.need_pip:
1524            if sys.version_info < MIN_PYTHON_VERSION:
1525                self.need_pip = False
1526                print("Warning: python version is not supported.")
1527            else:
1528                virtualenv_cmd = f"{self.python_cmd} -m venv"
1529                self.check_python_module("ensurepip")
1530
1531        # Check for needed programs/tools
1532        self.check_perl_module("Pod::Usage", DepManager.SYSTEM_MANDATORY)
1533
1534        self.check_program("make", DepManager.SYSTEM_MANDATORY)
1535        self.check_program("which", DepManager.SYSTEM_MANDATORY)
1536
1537        self.check_program("dot", DepManager.SYSTEM_OPTIONAL)
1538        self.check_program("convert", DepManager.SYSTEM_OPTIONAL)
1539
1540        self.check_python_module("yaml")
1541
1542        if self.pdf:
1543            self.check_program("xelatex", DepManager.PDF_MANDATORY)
1544            self.check_program("rsvg-convert", DepManager.PDF_MANDATORY)
1545            self.check_program("latexmk", DepManager.PDF_MANDATORY)
1546
1547        # Do distro-specific checks and output distro-install commands
1548        cmd = self.get_install()
1549        if cmd:
1550            print(cmd)
1551
1552        # If distro requires some special instructions, print here.
1553        # Please notice that get_install() needs to be called first.
1554        if self.distro_msg:
1555            print("\n" + self.distro_msg)
1556
1557        if not self.python_cmd:
1558            if self.need == 1:
1559                sys.exit("Can't build as 1 mandatory dependency is missing")
1560            elif self.need:
1561                sys.exit(f"Can't build as {self.need} mandatory dependencies are missing")
1562
1563        # Check if sphinx-build is called sphinx-build-3
1564        if self.need_symlink:
1565            sphinx_path = self.which("sphinx-build-3")
1566            if sphinx_path:
1567                print(f"\tsudo ln -sf {sphinx_path} /usr/bin/sphinx-build\n")
1568
1569        self.recommend_sphinx_version(virtualenv_cmd)
1570        print("")
1571
1572        if not self.deps.optional:
1573            print("All optional dependencies are met.")
1574
1575        if self.deps.need == 1:
1576            sys.exit("Can't build as 1 mandatory dependency is missing")
1577        elif self.deps.need:
1578            sys.exit(f"Can't build as {self.deps.need} mandatory dependencies are missing")
1579
1580        print("Needed package dependencies are met.")
1581
1582DESCRIPTION = """
1583Process some flags related to Sphinx installation and documentation build.
1584"""
1585
1586
1587def main():
1588    """Main function"""
1589    parser = argparse.ArgumentParser(description=DESCRIPTION)
1590
1591    parser.add_argument(
1592        "--no-virtualenv",
1593        action="store_false",
1594        dest="virtualenv",
1595        help="Recommend installing Sphinx instead of using a virtualenv",
1596    )
1597
1598    parser.add_argument(
1599        "--no-pdf",
1600        action="store_false",
1601        dest="pdf",
1602        help="Don't check for dependencies required to build PDF docs",
1603    )
1604
1605    parser.add_argument(
1606        "--version-check",
1607        action="store_true",
1608        dest="version_check",
1609        help="If version is compatible, don't check for missing dependencies",
1610    )
1611
1612    args = parser.parse_args()
1613
1614    checker = SphinxDependencyChecker(args)
1615
1616    checker.check_python()
1617    checker.check_needs()
1618
1619# Call main if not used as module
1620if __name__ == "__main__":
1621    main()
1622