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