| #!/usr/bin/env python3 |
| # SPDX-License-Identifier: GPL-2.0 |
| # Copyright(c) 2025: Mauro Carvalho Chehab <mchehab+huawei@kernel.org> |
| # |
| # pylint: disable=R0903,R0912,R0913,R0914,R0917,C0301 |
| |
| """ |
| Install minimal supported requirements for different Sphinx versions |
| and optionally test the build. |
| """ |
| |
| import argparse |
| import asyncio |
| import os.path |
| import shutil |
| import sys |
| import time |
| import subprocess |
| |
| # Minimal python version supported by the building system. |
| |
| PYTHON = os.path.basename(sys.executable) |
| |
| min_python_bin = None |
| |
| for i in range(9, 13): |
| p = f"python3.{i}" |
| if shutil.which(p): |
| min_python_bin = p |
| break |
| |
| if not min_python_bin: |
| min_python_bin = PYTHON |
| |
| # Starting from 8.0, Python 3.9 is not supported anymore. |
| PYTHON_VER_CHANGES = {(8, 0, 0): PYTHON} |
| |
| DEFAULT_VERSIONS_TO_TEST = [ |
| (3, 4, 3), # Minimal supported version |
| (5, 3, 0), # CentOS Stream 9 / AlmaLinux 9 |
| (6, 1, 1), # Debian 12 |
| (7, 2, 1), # openSUSE Leap 15.6 |
| (7, 2, 6), # Ubuntu 24.04 LTS |
| (7, 4, 7), # Ubuntu 24.10 |
| (7, 3, 0), # openSUSE Tumbleweed |
| (8, 1, 3), # Fedora 42 |
| (8, 2, 3) # Latest version - covers rolling distros |
| ] |
| |
| # Sphinx versions to be installed and their incremental requirements |
| SPHINX_REQUIREMENTS = { |
| # Oldest versions we support for each package required by Sphinx 3.4.3 |
| (3, 4, 3): { |
| "docutils": "0.16", |
| "alabaster": "0.7.12", |
| "babel": "2.8.0", |
| "certifi": "2020.6.20", |
| "docutils": "0.16", |
| "idna": "2.10", |
| "imagesize": "1.2.0", |
| "Jinja2": "2.11.2", |
| "MarkupSafe": "1.1.1", |
| "packaging": "20.4", |
| "Pygments": "2.6.1", |
| "PyYAML": "5.1", |
| "requests": "2.24.0", |
| "snowballstemmer": "2.0.0", |
| "sphinxcontrib-applehelp": "1.0.2", |
| "sphinxcontrib-devhelp": "1.0.2", |
| "sphinxcontrib-htmlhelp": "1.0.3", |
| "sphinxcontrib-jsmath": "1.0.1", |
| "sphinxcontrib-qthelp": "1.0.3", |
| "sphinxcontrib-serializinghtml": "1.1.4", |
| "urllib3": "1.25.9", |
| }, |
| |
| # Update package dependencies to a more modern base. The goal here |
| # is to avoid to many incremental changes for the next entries |
| (3, 5, 0): { |
| "alabaster": "0.7.13", |
| "babel": "2.17.0", |
| "certifi": "2025.6.15", |
| "idna": "3.10", |
| "imagesize": "1.4.1", |
| "packaging": "25.0", |
| "Pygments": "2.8.1", |
| "requests": "2.32.4", |
| "snowballstemmer": "3.0.1", |
| "sphinxcontrib-applehelp": "1.0.4", |
| "sphinxcontrib-htmlhelp": "2.0.1", |
| "sphinxcontrib-serializinghtml": "1.1.5", |
| "urllib3": "2.0.0", |
| }, |
| |
| # Starting from here, ensure all docutils versions are covered with |
| # supported Sphinx versions. Other packages are upgraded only when |
| # required by pip |
| (4, 0, 0): { |
| "PyYAML": "5.1", |
| }, |
| (4, 1, 0): { |
| "docutils": "0.17", |
| "Pygments": "2.19.1", |
| "Jinja2": "3.0.3", |
| "MarkupSafe": "2.0", |
| }, |
| (4, 3, 0): {}, |
| (4, 4, 0): {}, |
| (4, 5, 0): { |
| "docutils": "0.17.1", |
| }, |
| (5, 0, 0): {}, |
| (5, 1, 0): {}, |
| (5, 2, 0): { |
| "docutils": "0.18", |
| "Jinja2": "3.1.2", |
| "MarkupSafe": "2.0", |
| "PyYAML": "5.3.1", |
| }, |
| (5, 3, 0): { |
| "docutils": "0.18.1", |
| }, |
| (6, 0, 0): {}, |
| (6, 1, 0): {}, |
| (6, 2, 0): { |
| "PyYAML": "5.4.1", |
| }, |
| (7, 0, 0): {}, |
| (7, 1, 0): {}, |
| (7, 2, 0): { |
| "docutils": "0.19", |
| "PyYAML": "6.0.1", |
| "sphinxcontrib-serializinghtml": "1.1.9", |
| }, |
| (7, 2, 6): { |
| "docutils": "0.20", |
| }, |
| (7, 3, 0): { |
| "alabaster": "0.7.14", |
| "PyYAML": "6.0.1", |
| "tomli": "2.0.1", |
| }, |
| (7, 4, 0): { |
| "docutils": "0.20.1", |
| "PyYAML": "6.0.1", |
| }, |
| (8, 0, 0): { |
| "docutils": "0.21", |
| }, |
| (8, 1, 0): { |
| "docutils": "0.21.1", |
| "PyYAML": "6.0.1", |
| "sphinxcontrib-applehelp": "1.0.7", |
| "sphinxcontrib-devhelp": "1.0.6", |
| "sphinxcontrib-htmlhelp": "2.0.6", |
| "sphinxcontrib-qthelp": "1.0.6", |
| }, |
| (8, 2, 0): { |
| "docutils": "0.21.2", |
| "PyYAML": "6.0.1", |
| "sphinxcontrib-serializinghtml": "1.1.9", |
| }, |
| } |
| |
| |
| class AsyncCommands: |
| """Excecute command synchronously""" |
| |
| def __init__(self, fp=None): |
| |
| self.stdout = None |
| self.stderr = None |
| self.output = None |
| self.fp = fp |
| |
| def log(self, out, verbose, is_info=True): |
| out = out.removesuffix('\n') |
| |
| if verbose: |
| if is_info: |
| print(out) |
| else: |
| print(out, file=sys.stderr) |
| |
| if self.fp: |
| self.fp.write(out + "\n") |
| |
| async def _read(self, stream, verbose, is_info): |
| """Ancillary routine to capture while displaying""" |
| |
| while stream is not None: |
| line = await stream.readline() |
| if line: |
| out = line.decode("utf-8", errors="backslashreplace") |
| self.log(out, verbose, is_info) |
| if is_info: |
| self.stdout += out |
| else: |
| self.stderr += out |
| else: |
| break |
| |
| async def run(self, cmd, capture_output=False, check=False, |
| env=None, verbose=True): |
| |
| """ |
| Execute an arbitrary command, handling errors. |
| |
| Please notice that this class is not thread safe |
| """ |
| |
| self.stdout = "" |
| self.stderr = "" |
| |
| self.log("$ " + " ".join(cmd), verbose) |
| |
| proc = await asyncio.create_subprocess_exec(cmd[0], |
| *cmd[1:], |
| env=env, |
| stdout=asyncio.subprocess.PIPE, |
| stderr=asyncio.subprocess.PIPE) |
| |
| # Handle input and output in realtime |
| await asyncio.gather( |
| self._read(proc.stdout, verbose, True), |
| self._read(proc.stderr, verbose, False), |
| ) |
| |
| await proc.wait() |
| |
| if check and proc.returncode > 0: |
| raise subprocess.CalledProcessError(returncode=proc.returncode, |
| cmd=" ".join(cmd), |
| output=self.stdout, |
| stderr=self.stderr) |
| |
| if capture_output: |
| if proc.returncode > 0: |
| self.log(f"Error {proc.returncode}", verbose=True, is_info=False) |
| return "" |
| |
| return self.output |
| |
| ret = subprocess.CompletedProcess(args=cmd, |
| returncode=proc.returncode, |
| stdout=self.stdout, |
| stderr=self.stderr) |
| |
| return ret |
| |
| |
| class SphinxVenv: |
| """ |
| Installs Sphinx on one virtual env per Sphinx version with a minimal |
| set of dependencies, adjusting them to each specific version. |
| """ |
| |
| def __init__(self): |
| """Initialize instance variables""" |
| |
| self.built_time = {} |
| self.first_run = True |
| |
| async def _handle_version(self, args, fp, |
| cur_ver, cur_requirements, python_bin): |
| """Handle a single Sphinx version""" |
| |
| cmd = AsyncCommands(fp) |
| |
| ver = ".".join(map(str, cur_ver)) |
| |
| if not self.first_run and args.wait_input and args.build: |
| ret = input("Press Enter to continue or 'a' to abort: ").strip().lower() |
| if ret == "a": |
| print("Aborted.") |
| sys.exit() |
| else: |
| self.first_run = False |
| |
| venv_dir = f"Sphinx_{ver}" |
| req_file = f"requirements_{ver}.txt" |
| |
| cmd.log(f"\nSphinx {ver} with {python_bin}", verbose=True) |
| |
| # Create venv |
| await cmd.run([python_bin, "-m", "venv", venv_dir], |
| verbose=args.verbose, check=True) |
| pip = os.path.join(venv_dir, "bin/pip") |
| |
| # Create install list |
| reqs = [] |
| for pkg, verstr in cur_requirements.items(): |
| reqs.append(f"{pkg}=={verstr}") |
| |
| reqs.append(f"Sphinx=={ver}") |
| |
| await cmd.run([pip, "install"] + reqs, check=True, verbose=args.verbose) |
| |
| # Freeze environment |
| result = await cmd.run([pip, "freeze"], verbose=False, check=True) |
| |
| # Pip install succeeded. Write requirements file |
| if args.req_file: |
| with open(req_file, "w", encoding="utf-8") as fp: |
| fp.write(result.stdout) |
| |
| if args.build: |
| start_time = time.time() |
| |
| # Prepare a venv environment |
| env = os.environ.copy() |
| bin_dir = os.path.join(venv_dir, "bin") |
| env["PATH"] = bin_dir + ":" + env["PATH"] |
| env["VIRTUAL_ENV"] = venv_dir |
| if "PYTHONHOME" in env: |
| del env["PYTHONHOME"] |
| |
| # Test doc build |
| await cmd.run(["make", "cleandocs"], env=env, check=True) |
| make = ["make"] |
| |
| if args.output: |
| sphinx_build = os.path.realpath(f"{bin_dir}/sphinx-build") |
| make += [f"O={args.output}", f"SPHINXBUILD={sphinx_build}"] |
| |
| if args.make_args: |
| make += args.make_args |
| |
| make += args.targets |
| |
| if args.verbose: |
| cmd.log(f". {bin_dir}/activate", verbose=True) |
| await cmd.run(make, env=env, check=True, verbose=True) |
| if args.verbose: |
| cmd.log("deactivate", verbose=True) |
| |
| end_time = time.time() |
| elapsed_time = end_time - start_time |
| hours, minutes = divmod(elapsed_time, 3600) |
| minutes, seconds = divmod(minutes, 60) |
| |
| hours = int(hours) |
| minutes = int(minutes) |
| seconds = int(seconds) |
| |
| self.built_time[ver] = f"{hours:02d}:{minutes:02d}:{seconds:02d}" |
| |
| cmd.log(f"Finished doc build for Sphinx {ver}. Elapsed time: {self.built_time[ver]}", verbose=True) |
| |
| async def run(self, args): |
| """ |
| Navigate though multiple Sphinx versions, handling each of them |
| on a loop. |
| """ |
| |
| if args.log: |
| fp = open(args.log, "w", encoding="utf-8") |
| if not args.verbose: |
| args.verbose = False |
| else: |
| fp = None |
| if not args.verbose: |
| args.verbose = True |
| |
| cur_requirements = {} |
| python_bin = min_python_bin |
| |
| vers = set(SPHINX_REQUIREMENTS.keys()) | set(args.versions) |
| |
| for cur_ver in sorted(vers): |
| if cur_ver in SPHINX_REQUIREMENTS: |
| new_reqs = SPHINX_REQUIREMENTS[cur_ver] |
| cur_requirements.update(new_reqs) |
| |
| if cur_ver in PYTHON_VER_CHANGES: # pylint: disable=R1715 |
| python_bin = PYTHON_VER_CHANGES[cur_ver] |
| |
| if cur_ver not in args.versions: |
| continue |
| |
| if args.min_version: |
| if cur_ver < args.min_version: |
| continue |
| |
| if args.max_version: |
| if cur_ver > args.max_version: |
| break |
| |
| await self._handle_version(args, fp, cur_ver, cur_requirements, |
| python_bin) |
| |
| if args.build: |
| cmd = AsyncCommands(fp) |
| cmd.log("\nSummary:", verbose=True) |
| for ver, elapsed_time in sorted(self.built_time.items()): |
| cmd.log(f"\tSphinx {ver} elapsed time: {elapsed_time}", |
| verbose=True) |
| |
| if fp: |
| fp.close() |
| |
| def parse_version(ver_str): |
| """Convert a version string into a tuple.""" |
| |
| return tuple(map(int, ver_str.split("."))) |
| |
| |
| DEFAULT_VERS = " - " |
| DEFAULT_VERS += "\n - ".join(map(lambda v: f"{v[0]}.{v[1]}.{v[2]}", |
| DEFAULT_VERSIONS_TO_TEST)) |
| |
| SCRIPT = os.path.relpath(__file__) |
| |
| DESCRIPTION = f""" |
| This tool allows creating Python virtual environments for different |
| Sphinx versions that are supported by the Linux Kernel build system. |
| |
| Besides creating the virtual environment, it can also test building |
| the documentation using "make htmldocs" (and/or other doc targets). |
| |
| If called without "--versions" argument, it covers the versions shipped |
| on major distros, plus the lowest supported version: |
| |
| {DEFAULT_VERS} |
| |
| A typical usage is to run: |
| |
| {SCRIPT} -m -l sphinx_builds.log |
| |
| This will create one virtual env for the default version set and run |
| "make htmldocs" for each version, creating a log file with the |
| excecuted commands on it. |
| |
| NOTE: The build time can be very long, specially on old versions. Also, there |
| is a known bug with Sphinx version 6.0.x: each subprocess uses a lot of |
| memory. That, together with "-jauto" may cause OOM killer to cause |
| failures at the doc generation. To minimize the risk, you may use the |
| "-a" command line parameter to constrain the built directories and/or |
| reduce the number of threads from "-jauto" to, for instance, "-j4": |
| |
| {SCRIPT} -m -V 6.0.1 -a "SPHINXDIRS=process" "SPHINXOPTS='-j4'" |
| |
| """ |
| |
| MAKE_TARGETS = [ |
| "htmldocs", |
| "texinfodocs", |
| "infodocs", |
| "latexdocs", |
| "pdfdocs", |
| "epubdocs", |
| "xmldocs", |
| ] |
| |
| async def main(): |
| """Main program""" |
| |
| parser = argparse.ArgumentParser(description=DESCRIPTION, |
| formatter_class=argparse.RawDescriptionHelpFormatter) |
| |
| ver_group = parser.add_argument_group("Version range options") |
| |
| ver_group.add_argument('-V', '--versions', nargs="*", |
| default=DEFAULT_VERSIONS_TO_TEST,type=parse_version, |
| help='Sphinx versions to test') |
| ver_group.add_argument('--min-version', "--min", type=parse_version, |
| help='Sphinx minimal version') |
| ver_group.add_argument('--max-version', "--max", type=parse_version, |
| help='Sphinx maximum version') |
| ver_group.add_argument('-f', '--full', action='store_true', |
| help='Add all Sphinx (major,minor) supported versions to the version range') |
| |
| build_group = parser.add_argument_group("Build options") |
| |
| build_group.add_argument('-b', '--build', action='store_true', |
| help='Build documentation') |
| build_group.add_argument('-a', '--make-args', nargs="*", |
| help='extra arguments for make, like SPHINXDIRS=netlink/specs', |
| ) |
| build_group.add_argument('-t', '--targets', nargs="+", choices=MAKE_TARGETS, |
| default=[MAKE_TARGETS[0]], |
| help="make build targets. Default: htmldocs.") |
| build_group.add_argument("-o", '--output', |
| help="output directory for the make O=OUTPUT") |
| |
| other_group = parser.add_argument_group("Other options") |
| |
| other_group.add_argument('-r', '--req-file', action='store_true', |
| help='write a requirements.txt file') |
| other_group.add_argument('-l', '--log', |
| help='Log command output on a file') |
| other_group.add_argument('-v', '--verbose', action='store_true', |
| help='Verbose all commands') |
| other_group.add_argument('-i', '--wait-input', action='store_true', |
| help='Wait for an enter before going to the next version') |
| |
| args = parser.parse_args() |
| |
| if not args.make_args: |
| args.make_args = [] |
| |
| sphinx_versions = sorted(list(SPHINX_REQUIREMENTS.keys())) |
| |
| if args.full: |
| args.versions += list(SPHINX_REQUIREMENTS.keys()) |
| |
| venv = SphinxVenv() |
| await venv.run(args) |
| |
| |
| # Call main method |
| if __name__ == "__main__": |
| asyncio.run(main()) |