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