From ae4d2370a92750e64fbd54e9f7ecee35cadc060c Mon Sep 17 00:00:00 2001 From: Harvey Lynden Date: Fri, 21 Aug 2026 15:08:34 +0200 Subject: [PATCH 01/11] avocado.core.spawners: add universal wheel bootstrap helper Python eggs are CPython-minor specific, need pkg_resources, and already fail inside Fedora 39+ / Python 3.12 containers. Avocado is pure Python, so one py3-none-any wheel is enough: unpack it on the host and put that directory on PYTHONPATH. When running from a source tree the helper builds the wheel with pip; otherwise it fetches the GitHub release asset. Eggs remain as a deprecated zipimport fallback. Reference: https://github.com/avocado-framework/avocado/issues/6108 Reference: https://github.com/avocado-framework/avocado/issues/6115 Signed-off-by: Harvey Lynden --- avocado/core/spawners/wheel_bootstrap.py | 226 +++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 avocado/core/spawners/wheel_bootstrap.py diff --git a/avocado/core/spawners/wheel_bootstrap.py b/avocado/core/spawners/wheel_bootstrap.py new file mode 100644 index 0000000000..641abd248c --- /dev/null +++ b/avocado/core/spawners/wheel_bootstrap.py @@ -0,0 +1,226 @@ +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# +# See LICENSE for more details. +# +# Copyright: Red Hat Inc. 2026 +"""Bootstrap Avocado into isolated environments from a universal wheel. + +Python eggs are a discontinued format: they are CPython-minor specific, +need pkg_resources (removed in setuptools 82), and already fail inside +Fedora 39+ containers. Avocado is pure Python, so one +``avocado_framework-{version}-py3-none-any.whl`` works for every +supported interpreter. + +The wheel is unpacked once on the host (stdlib zipfile) and bind-mounted +read-only. nrunner then uses ``python -m avocado.plugins.runners...``, +so console scripts and a venv are unnecessary. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import sys +import zipfile +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import unquote, urlparse + +from avocado.core.version import VERSION +from avocado.utils.asset import Asset + +LOG = logging.getLogger(__name__) + +#: Where the unpacked wheel (or a legacy egg file) is visible inside +#: the isolated environment. +CONTAINER_SITE = "/opt/avocado-wheel" +_EXTRACT_MARKER = ".avocado-wheel-extracted" + + +@dataclass(frozen=True) +class BootstrapMount: + """Host path to bind-mount and the PYTHONPATH to use inside.""" + + host_path: str + container_path: str + pythonpath: str + + +def source_root(): + """Return the Avocado git/source tree if this checkout has one.""" + # avocado/core/spawners/wheel_bootstrap.py -> repository root + repo = Path(__file__).resolve().parents[3] + if (repo / "setup.py").exists() and (repo / "VERSION").exists(): + return repo + return None + + +def effective_version(version=None): + """Installed package version, or VERSION file when running from git.""" + if version is None: + version = VERSION + if version != "unknown.unknown": + return version + src = source_root() + if src is not None: + return (src / "VERSION").read_text(encoding="utf-8").strip() + return version + + +def wheel_filename(version=None): + """Return the universal wheel filename for an Avocado version.""" + return f"avocado_framework-{effective_version(version)}-py3-none-any.whl" + + +def default_wheel_url(version=None): + """GitHub release URL for the universal wheel.""" + version = effective_version(version) + name = wheel_filename(version) + return ( + f"https://github.com/avocado-framework/avocado/releases/" + f"download/{version}/{name}" + ) + + +def _local_path_from_url(url): + parsed = urlparse(url) + if parsed.scheme in ("", "file"): + path = unquote(parsed.path) + if parsed.netloc and parsed.netloc != "localhost": + path = f"/{parsed.netloc}{path}" + return path + return None + + +def _extract_wheel(wheel_path, dest_dir): + dest = Path(dest_dir) + marker = dest / _EXTRACT_MARKER + wheel_path = os.path.abspath(wheel_path) + if marker.exists() and marker.read_text(encoding="utf-8").strip() == wheel_path: + return str(dest) + if dest.exists(): + shutil.rmtree(dest) + dest.mkdir(parents=True) + with zipfile.ZipFile(wheel_path) as archive: + archive.extractall(dest) + marker.write_text(wheel_path, encoding="utf-8") + return str(dest) + + +def build_wheel_from_source(source, dest_dir): + """Build a universal wheel from a source tree with pip. + + :param source: path to the Avocado repository + :param dest_dir: directory that will receive the ``.whl`` + :returns: path to the built wheel + """ + dest = Path(dest_dir) + dest.mkdir(parents=True, exist_ok=True) + LOG.info("Building Avocado wheel from %s", source) + result = subprocess.run( + [ + sys.executable, + "-m", + "pip", + "wheel", + "--no-deps", + "-w", + str(dest), + str(source), + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + if result.returncode != 0: + raise RuntimeError( + f"pip wheel failed with exit {result.returncode}:\n" + f"{result.stdout.decode(errors='replace')}" + ) + wheels = sorted(dest.glob("avocado_framework-*.whl")) + if not wheels: + raise RuntimeError(f"pip wheel produced no avocado_framework wheel in {dest}") + return str(wheels[-1]) + + +def _fetch_remote_wheel(url, cache_dirs): + asset = Asset(url, cache_dirs=cache_dirs) + return asset.fetch() + + +def resolve_wheel_file(url=None, cache_dirs=None, version=None): + """Return a local path to an Avocado wheel (or legacy egg). + + Resolution order: + + 1. Explicit URL (``file://`` or remote). + 2. Wheel built from the source tree this module lives in (develop). + 3. GitHub release asset for ``version``. + """ + if version is None: + version = effective_version() + if cache_dirs is None: + cache_dirs = [] + cache_root = Path(cache_dirs[0] if cache_dirs else Path.cwd()) / "wheel-bootstrap" + cache_root.mkdir(parents=True, exist_ok=True) + + if url: + local = _local_path_from_url(url) + if local: + if not os.path.exists(local): + raise FileNotFoundError(f"Bootstrap package not found: {local}") + return local + return _fetch_remote_wheel(url, cache_dirs) + + src = source_root() + if src is not None: + built = cache_root / wheel_filename(version) + if built.exists(): + return str(built) + return build_wheel_from_source(src, cache_root) + + return _fetch_remote_wheel(default_wheel_url(version), cache_dirs) + + +def prepare_bootstrap(url=None, cache_dirs=None, version=None): + """Prepare a host directory or file to mount into an isolated environment. + + Wheels are unpacked so ``import avocado`` works via PYTHONPATH. + Legacy ``.egg`` files are returned as-is (zipimport) with a warning. + """ + package = resolve_wheel_file(url=url, cache_dirs=cache_dirs, version=version) + if package.endswith(".egg"): + LOG.warning( + "Egg bootstrap is deprecated and fails on Fedora 39+/Python " + "3.12+. Pass a universal wheel instead (%s).", + wheel_filename(version), + ) + container = os.path.join(CONTAINER_SITE, os.path.basename(package)) + return BootstrapMount( + host_path=os.path.abspath(package), + container_path=container, + pythonpath=container, + ) + + if os.path.isdir(package): + host_dir = os.path.abspath(package) + else: + cache_root = ( + Path(cache_dirs[0] if cache_dirs else Path.cwd()) / "wheel-bootstrap" + ) + host_dir = _extract_wheel(package, cache_root / "site") + + return BootstrapMount( + host_path=host_dir, + container_path=CONTAINER_SITE, + pythonpath=CONTAINER_SITE, + ) From f6e4682e020694c964eba266c8c448bb6a90aac9 Mon Sep 17 00:00:00 2001 From: Harvey Lynden Date: Fri, 21 Aug 2026 15:08:39 +0200 Subject: [PATCH 02/11] avocado.plugins.spawners.podman: deploy from a universal wheel Stop copying per-CPython eggs plus setuptools 59.2 into every container. Unpack one wheel on the host and bind-mount it at /opt/avocado-wheel so nrunner can still start with python -m avocado.plugins.runners... task-run. --spawner-podman-avocado-wheel selects the package; --spawner-podman-avocado-egg is kept as a deprecated alias. Reference: https://github.com/avocado-framework/avocado/issues/6108 Reference: https://github.com/avocado-framework/avocado/issues/6115 Signed-off-by: Harvey Lynden --- avocado/plugins/spawners/podman.py | 106 ++++++++++++++--------------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/avocado/plugins/spawners/podman.py b/avocado/plugins/spawners/podman.py index 228f2af42f..01e187f2a7 100644 --- a/avocado/plugins/spawners/podman.py +++ b/avocado/plugins/spawners/podman.py @@ -12,10 +12,9 @@ from avocado.core.resolver import ReferenceResolutionAssetType from avocado.core.settings import settings from avocado.core.spawners.common import SpawnCapabilities, SpawnerMixin, SpawnMethod +from avocado.core.spawners.wheel_bootstrap import prepare_bootstrap from avocado.core.teststatus import STATUSES_NOT_OK -from avocado.core.version import VERSION from avocado.utils import distro -from avocado.utils.asset import Asset from avocado.utils.podman import AsyncPodman, PodmanException LOG = logging.getLogger(__name__) @@ -54,13 +53,24 @@ def initialize(self): ) help_msg = ( - "Avocado egg path to be used during initial bootstrap " - "of avocado inside the isolated environment. By default, " - "Avocado will try to download (or get from cache) an " - "egg from its repository. Please use a valid URL, including " - 'the protocol (for local files, use the "file:///" prefix).' + "Avocado wheel (or unpacked wheel directory) used to bootstrap " + "Avocado inside the isolated environment. By default Avocado " + "builds a universal py3-none-any wheel from the running source " + "tree, or fetches the matching GitHub release asset. Use a " + 'valid URL, including the protocol (for local files, "file:///").' + ) + settings.register_option( + section=section, + key="avocado_spawner_wheel", + help_msg=help_msg, + default=None, ) + help_msg = ( + "Deprecated alias of spawner.podman.avocado_spawner_wheel. " + "Eggs are a discontinued format and fail on Fedora 39+/Python " + "3.12+. Prefer a universal wheel." + ) settings.register_option( section=section, key="avocado_spawner_egg", help_msg=help_msg, default=None ) @@ -104,10 +114,17 @@ def configure(self, parser): metavar="CONTAINER_IMAGE", ) - namespace = "spawner.podman.avocado_spawner_egg" - long_arg = "--spawner-podman-avocado-egg" settings.add_argparser_to_option( - namespace=namespace, parser=parser, long_arg=long_arg, metavar="AVOCADO_EGG" + namespace="spawner.podman.avocado_spawner_wheel", + parser=parser, + long_arg="--spawner-podman-avocado-wheel", + metavar="AVOCADO_WHEEL", + ) + settings.add_argparser_to_option( + namespace="spawner.podman.avocado_spawner_egg", + parser=parser, + long_arg="--spawner-podman-avocado-egg", + metavar="AVOCADO_EGG", ) def run(self, config): @@ -220,36 +237,17 @@ def is_task_alive(self, runtime_task): # pylint: disable=W0221 return out == b"running\n" return False - def _fetch_asset(self, url): - cachedirs = self.config.get("datadir.paths.cache_dirs") - asset = Asset(url, cache_dirs=cachedirs) - return asset.fetch() - - def get_eggs_paths(self, py_major, py_minor): - """Return the basic eggs needed to bootstrap Avocado. - - This will return a tuple with the current location and where this - should be deployed. - """ - result = [] - # Setuptools - # For now let's pin to setuptools 59.2. - # TODO: Automatically get latest setuptools version. - eggs = [ - f"https://github.com/avocado-framework/setuptools/releases/download/v59.2.0/setuptools-59.2.0-py{py_major}.{py_minor}.egg" - ] - local_egg = self.config.get("spawner.podman.avocado_spawner_egg") - if local_egg: - eggs.append(local_egg) - else: - remote_egg = f"https://github.com/avocado-framework/avocado/releases/download/{VERSION}/avocado_framework-{VERSION}-py{py_major}.{py_minor}.egg" - eggs.append(remote_egg) + def _bootstrap_url(self): + return self.config.get( + "spawner.podman.avocado_spawner_wheel" + ) or self.config.get("spawner.podman.avocado_spawner_egg") - for url in eggs: - path = self._fetch_asset(url) - to = os.path.join("/tmp/", os.path.basename(path)) - result.append((path, to)) - return result + def get_bootstrap_mount(self): + """Return the wheel (or legacy egg) bind-mount for container spawn.""" + return prepare_bootstrap( + url=self._bootstrap_url(), + cache_dirs=self.config.get("datadir.paths.cache_dirs"), + ) @property async def python_version(self): @@ -266,16 +264,13 @@ async def python_version(self): async def deploy_artifacts(self): pass - async def deploy_avocado(self, where): - # Deploy all the eggs to container inside /tmp/ - major, minor, _ = await self.python_version - eggs = self.get_eggs_paths(major, minor) - - for egg, to in eggs: - await self.podman.copy_to_container(where, egg, to) + async def deploy_avocado(self, where): # pylint: disable=W0613 + # Avocado is bind-mounted from a host-unpacked universal wheel at + # container create time. Nothing to copy into the container. + pass async def _create_container_for_task( - self, runtime_task, env_args, test_output=None + self, runtime_task, env_args, test_output=None, bootstrap=None ): mount_status_server_socket = False mounted_status_server_socket = "/tmp/.status_server.sock" @@ -316,6 +311,13 @@ async def _create_container_for_task( "/tmp", runtime_task.task.runnable.uri ) + bootstrap_opts = () + if bootstrap is not None: + bootstrap_opts = ( + "-v", + f"{bootstrap.host_path}:{bootstrap.container_path}:ro", + ) + task = runtime_task.task entry_point_args.extend(task.get_command_args()) entry_point = json.dumps(entry_point_args) @@ -348,6 +350,7 @@ async def _create_container_for_task( *status_server_opts, *output_opts, *test_opts, + *bootstrap_opts, entry_point_arg, *envs, image, @@ -357,15 +360,12 @@ async def _create_container_for_task( async def spawn_task(self, runtime_task): self.create_task_output_dir(runtime_task) - major, minor, _ = await self.python_version - # Return only the "to" location - eggs = self.get_eggs_paths(major, minor) - destination_eggs = ":".join(map(lambda egg: str(egg[1]), eggs)) - env_args = {"PYTHONPATH": destination_eggs} + bootstrap = self.get_bootstrap_mount() + env_args = {"PYTHONPATH": bootstrap.pythonpath} output_dir_path = self.task_output_dir(runtime_task) try: container_id = await self._create_container_for_task( - runtime_task, env_args, output_dir_path + runtime_task, env_args, output_dir_path, bootstrap ) except PodmanException as ex: LOG.error("Could not create podman container: %s", ex) From 68ca4a515edf396e737a58a670a7d3ce6543fbff Mon Sep 17 00:00:00 2001 From: Harvey Lynden Date: Fri, 21 Aug 2026 15:08:44 +0200 Subject: [PATCH 03/11] selftests: cover wheel bootstrap and unstick Podman from fedora:38 Unit tests build a py3-none-any wheel, unpack it, and prove import avocado plus python -m avocado.plugins.runners.exec_test work from PYTHONPATH. Functional Podman jobs now target fedora:latest so they keep exercising current CPython. Reference: https://github.com/avocado-framework/avocado/issues/6115 Signed-off-by: Harvey Lynden --- selftests/check.py | 2 +- .../functional/plugin/spawners/podman.py | 12 +-- selftests/functional/serial/requirements.py | 2 +- selftests/unit/plugin/wheel_bootstrap.py | 94 +++++++++++++++++++ 4 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 selftests/unit/plugin/wheel_bootstrap.py diff --git a/selftests/check.py b/selftests/check.py index 9005765580..46205d29be 100755 --- a/selftests/check.py +++ b/selftests/check.py @@ -127,7 +127,7 @@ def _setup_isolated_environment(): "job-api-check-tmp-directory-exists": 1, "nrunner-interface": 90, "nrunner-requirement": 28, - "unit": 1028, + "unit": 1034, "jobs": 11, "functional-parallel": 368, "functional-serial": 7, diff --git a/selftests/functional/plugin/spawners/podman.py b/selftests/functional/plugin/spawners/podman.py index 77b5ee2b82..1101d33e76 100644 --- a/selftests/functional/plugin/spawners/podman.py +++ b/selftests/functional/plugin/spawners/podman.py @@ -30,7 +30,7 @@ def test(self): class PodmanSpawnerTest(Test): """ :avocado: dependency={"type": "package", "name": "podman", "action": "check"} - :avocado: dependency={"type": "podman-image", "uri": "registry.fedoraproject.org/fedora:38"} + :avocado: dependency={"type": "podman-image", "uri": "registry.fedoraproject.org/fedora:latest"} """ def test_avocado_instrumented(self): @@ -42,7 +42,7 @@ def test_avocado_instrumented(self): f"{AVOCADO} run " f"--job-results-dir {self.workdir} " f"--disable-sysinfo --spawner=podman " - f"--spawner-podman-image=fedora:38 -- " + f"--spawner-podman-image=fedora:latest -- " f"{test}", ignore_status=True, ) @@ -55,7 +55,7 @@ def test_exec(self): f"{AVOCADO} run " f"--job-results-dir {self.workdir} " f"--disable-sysinfo --spawner=podman " - f"--spawner-podman-image=fedora:38 -- " + f"--spawner-podman-image=fedora:latest -- " f"/bin/true", ignore_status=True, ) @@ -73,7 +73,7 @@ def test_sleep_longer_timeout_podman(self): "run.results_dir": self.workdir, "task.timeout.running": 2, "run.spawner": "podman", - "spawner.podman.image": "fedora:38", + "spawner.podman.image": "fedora:latest", } with Job.from_config(job_config=config) as job: @@ -93,7 +93,7 @@ def test_outputdir(self): ], "run.results_dir": self.workdir, "run.spawner": "podman", - "spawner.podman.image": "fedora:38", + "spawner.podman.image": "fedora:latest", } with Job.from_config(job_config=config) as job: @@ -110,7 +110,7 @@ def test_asset_files(self): f"{AVOCADO} run " f"--job-results-dir {self.workdir} " f"--disable-sysinfo --spawner=podman " - f"--spawner-podman-image=fedora:38 -- " + f"--spawner-podman-image=fedora:latest -- " f"{test}", ignore_status=True, ) diff --git a/selftests/functional/serial/requirements.py b/selftests/functional/serial/requirements.py index b6b03049d0..e8b66e979e 100644 --- a/selftests/functional/serial/requirements.py +++ b/selftests/functional/serial/requirements.py @@ -161,7 +161,7 @@ def get_command(self, path): spawner = self.params.get("spawner", default="process") spawner_command = "" if spawner == "podman": - spawner_command = "--spawner=podman --spawner-podman-image=fedora:38" + spawner_command = "--spawner=podman --spawner-podman-image=fedora:latest" return f"{AVOCADO} run {spawner_command} --job-results-dir {self.tmpdir.name} {path}" @skipUnless(os.getenv("CI"), skip_package_manager_message) diff --git a/selftests/unit/plugin/wheel_bootstrap.py b/selftests/unit/plugin/wheel_bootstrap.py new file mode 100644 index 0000000000..eb654b47ad --- /dev/null +++ b/selftests/unit/plugin/wheel_bootstrap.py @@ -0,0 +1,94 @@ +import os +import subprocess +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path + +from avocado.core.spawners import wheel_bootstrap +from avocado.core.spawners.wheel_bootstrap import ( + CONTAINER_SITE, + BootstrapMount, + prepare_bootstrap, + wheel_filename, +) +from selftests.utils import BASEDIR + + +class WheelBootstrap(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._wheels_dir = tempfile.TemporaryDirectory(prefix="avocado_wheel_bs_") + cls.wheel = wheel_bootstrap.build_wheel_from_source( + BASEDIR, cls._wheels_dir.name + ) + + @classmethod + def tearDownClass(cls): + cls._wheels_dir.cleanup() + + def test_wheel_is_universal(self): + self.assertTrue(self.wheel.endswith("-py3-none-any.whl")) + self.assertEqual(os.path.basename(self.wheel), wheel_filename()) + + def test_prepare_unpacked_wheel_is_importable(self): + with tempfile.TemporaryDirectory(prefix="avocado_wheel_cache_") as cache: + mount = prepare_bootstrap(url=f"file://{self.wheel}", cache_dirs=[cache]) + self.assertIsInstance(mount, BootstrapMount) + self.assertEqual(mount.container_path, CONTAINER_SITE) + self.assertEqual(mount.pythonpath, CONTAINER_SITE) + self.assertTrue(os.path.isdir(mount.host_path)) + self.assertTrue(os.path.isdir(os.path.join(mount.host_path, "avocado"))) + + env = os.environ.copy() + env["PYTHONPATH"] = mount.host_path + probe = ( + "import avocado; " + "from avocado.plugins.runners.exec_test import ExecTestRunner; " + "print(avocado.__file__)" + ) + result = subprocess.run( + [sys.executable, "-c", probe], + check=True, + capture_output=True, + text=True, + env=env, + ) + self.assertIn("avocado", result.stdout) + + def test_legacy_egg_keeps_zip_on_pythonpath(self): + with tempfile.TemporaryDirectory(prefix="avocado_egg_") as tmp: + egg = os.path.join(tmp, "avocado_framework-113.0-py3.12.egg") + with zipfile.ZipFile(egg, "w") as archive: + archive.writestr("dummy", "not-a-real-egg") + cache = os.path.join(tmp, "cache") + os.mkdir(cache) + mount = prepare_bootstrap(url=f"file://{egg}", cache_dirs=[cache]) + self.assertTrue(mount.host_path.endswith(".egg")) + self.assertTrue(mount.pythonpath.endswith(".egg")) + self.assertNotEqual(mount.container_path, CONTAINER_SITE) + + def test_explicit_unpacked_directory(self): + with tempfile.TemporaryDirectory(prefix="avocado_site_") as tmp: + site = Path(tmp) / "site" + site.mkdir() + (site / "avocado").mkdir() + mount = prepare_bootstrap(url=f"file://{site}", cache_dirs=[tmp]) + self.assertEqual(os.path.realpath(mount.host_path), os.path.realpath(site)) + self.assertEqual(mount.pythonpath, CONTAINER_SITE) + + def test_default_builds_from_source_tree(self): + with tempfile.TemporaryDirectory(prefix="avocado_wheel_src_") as cache: + mount = prepare_bootstrap(cache_dirs=[cache]) + self.assertTrue(os.path.isdir(os.path.join(mount.host_path, "avocado"))) + env = os.environ.copy() + env["PYTHONPATH"] = mount.host_path + result = subprocess.run( + [sys.executable, "-m", "avocado.plugins.runners.exec_test", "--help"], + check=False, + capture_output=True, + text=True, + env=env, + ) + self.assertIn("task-run", result.stdout + result.stderr) From c8e7bbf456865f08c51be717c1631fa69d78a12d Mon Sep 17 00:00:00 2001 From: Harvey Lynden Date: Fri, 21 Aug 2026 15:08:49 +0200 Subject: [PATCH 04/11] docs: document Podman bootstrap from a universal wheel Record the egg-to-wheel switch for the next release notes, the nrunner reference, and the man page. Point users at --spawner-podman-avocado-wheel. Reference: https://github.com/avocado-framework/avocado/issues/6108 Signed-off-by: Harvey Lynden --- .../guides/reference/chapters/runners.rst | 7 ++++++- docs/source/releases/next.rst | 10 +++++++++- man/avocado.rst | 17 +++++++++++------ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/source/guides/reference/chapters/runners.rst b/docs/source/guides/reference/chapters/runners.rst index 15f85f2c07..e5a790aa1e 100644 --- a/docs/source/guides/reference/chapters/runners.rst +++ b/docs/source/guides/reference/chapters/runners.rst @@ -177,7 +177,12 @@ following steps: 2. Creates the chosen :class:`Spawner `, with :class:`ProcessSpawner - ` being the default + ` being the default. + The Podman spawner does **not** copy Python eggs into the container. + It unpacks one universal ``py3-none-any`` wheel on the host and + bind-mounts it at ``/opt/avocado-wheel`` (``PYTHONPATH``). nrunner + still starts with ``python -m avocado.plugins.runners... task-run``. + Override the wheel with ``--spawner-podman-avocado-wheel``. 3. For each :class:`avocado.core.nrunner.runnable.Runnable` found by the resolver, turns it into a :class:`avocado.core.nrunner.Task`, which means giving it the following extra information: diff --git a/docs/source/releases/next.rst b/docs/source/releases/next.rst index cf935d0298..35c704f9d2 100644 --- a/docs/source/releases/next.rst +++ b/docs/source/releases/next.rst @@ -11,7 +11,15 @@ Release documentation: `Avocado 113.0 Users/Test Writers ================== -* +* The Podman spawner now bootstraps Avocado from a single universal + wheel (``avocado_framework-{version}-py3-none-any.whl``) instead of + per-CPython eggs plus setuptools 59.2. The wheel is unpacked on the + host and bind-mounted read-only at ``/opt/avocado-wheel``. This + unblocks Fedora 39+ / Python 3.12+ images (GitHub issues `#6108 + `_ and + `#6115 `_). + Use ``--spawner-podman-avocado-wheel``; ``--spawner-podman-avocado-egg`` + remains as a deprecated alias. Utility Modules =============== diff --git a/man/avocado.rst b/man/avocado.rst index e32b180aff..94af8aed6a 100644 --- a/man/avocado.rst +++ b/man/avocado.rst @@ -211,13 +211,18 @@ Options for subcommand `run` (`avocado run --help`):: first default choice is a container image matching the current OS. If unable to detect, default becomes the latest Fedora release. - --spawner-podman-avocado-egg AVOCADO_EGG - Avocado egg path to be used during initial bootstrap - of avocado inside the isolated environment. By - default, Avocado will try to download (or get from - cache) an egg from its repository. Please use a valid - URL, including the protocol (for local files, use the + --spawner-podman-avocado-wheel AVOCADO_WHEEL + Avocado wheel (or unpacked wheel directory) used to + bootstrap Avocado inside the isolated environment. + By default Avocado builds a universal py3-none-any + wheel from the running source tree, or fetches the + matching GitHub release asset. Use a valid URL, + including the protocol (for local files, use the "file:///" prefix). + --spawner-podman-avocado-egg AVOCADO_EGG + Deprecated alias of --spawner-podman-avocado-wheel. + Eggs are a discontinued format and fail on Fedora + 39+/Python 3.12+. Prefer a universal wheel. Options for subcommand `assets` (`avocado assets --help`):: From 15a6134c147a3ef8af1e68ae5166747c0c8d13c3 Mon Sep 17 00:00:00 2001 From: Harvey Lynden Date: Fri, 21 Aug 2026 15:08:57 +0200 Subject: [PATCH 05/11] ci: exercise the Podman spawner from a wheel, not an egg bdist_egg plus fedora:40 cannot prove the bootstrap still works on current images. Build a universal wheel and pass it with --spawner-podman-avocado-wheel against fedora:latest. Reference: https://github.com/avocado-framework/avocado/issues/6115 Signed-off-by: Harvey Lynden --- .github/workflows/ci.yml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27aa99eb42..4167c1ee3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -414,20 +414,21 @@ jobs: uses: actions/checkout@v7 - uses: ./.github/actions/version - podman_egg_task: + podman_wheel_task: - name: Podman Egg task + name: Podman wheel task runs-on: ubuntu-22.04 steps: - name: Check out repository code uses: actions/checkout@v7 - - name: Test running avocado from eggs under Podman spawner + - name: Test running avocado from a wheel under Podman spawner run: | - apt update && apt -y install python3 python3-setuptools - python3 setup.py bdist_egg - mv dist/avocado_framework-*egg /tmp/avocado_framework.egg - python3 setup.py clean --all - python3 -c 'import sys; sys.path.insert(0, "/tmp/avocado_framework.egg"); from avocado.core.main import main; sys.exit(main())' run --spawner=podman --spawner-podman-image=fedora:40 --spawner-podman-avocado-egg=file:///tmp/avocado_framework.egg -- /bin/true + sudo apt-get update + sudo apt-get install -y podman python3 python3-pip python3-setuptools + python3 -m pip wheel --no-deps -w /tmp . + WHL=$(ls /tmp/avocado_framework-*-py3-none-any.whl) + python3 -m pip install --user --no-deps "$WHL" + python3 -m avocado run --spawner=podman --spawner-podman-image=fedora:latest --spawner-podman-avocado-wheel="file://${WHL}" -- /bin/true podman_external_runner_task: @@ -438,7 +439,8 @@ jobs: uses: actions/checkout@v7 - name: Test running avocado from released eggs under Podman spawner with 3rd party plugins run: | - apt update && apt -y install python3 python3-setuptools + sudo apt-get update + sudo apt-get install -y podman python3 python3-setuptools python3 setup.py develop --user cd examples/plugins/tests/magic python3 setup.py develop --user From aba9ff1e380ec1edb12a2f95a28d41929fbd87cd Mon Sep 17 00:00:00 2001 From: Harvey Lynden Date: Fri, 21 Aug 2026 15:08:57 +0200 Subject: [PATCH 06/11] contrib: prefetch the universal wheel used by isolated spawners avocado-fetch-eggs.py pulled one egg per CPython minor plus setuptools 59.2. Add avocado-fetch-wheels.py for the single py3-none-any wheel, and warn on the old script. Reference: https://github.com/avocado-framework/avocado/issues/6108 Signed-off-by: Harvey Lynden --- contrib/scripts/avocado-fetch-eggs.py | 5 +++ contrib/scripts/avocado-fetch-wheels.py | 52 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100755 contrib/scripts/avocado-fetch-wheels.py diff --git a/contrib/scripts/avocado-fetch-eggs.py b/contrib/scripts/avocado-fetch-eggs.py index 059abced97..ca39015d65 100755 --- a/contrib/scripts/avocado-fetch-eggs.py +++ b/contrib/scripts/avocado-fetch-eggs.py @@ -54,6 +54,11 @@ def get_avocado_egg_url(avocado_version=None, python_version=None): def main(): configure_logging_settings() + LOG.warning( + "avocado-fetch-eggs.py is deprecated. Isolated spawners now use " + "a universal wheel; prefer contrib/scripts/avocado-fetch-wheels.py. " + "Eggs fail on Fedora 39+ / Python 3.12+." + ) for version in ["3.9", "3.10", "3.11", "3.12", "3.13"]: url = get_avocado_egg_url(python_version=version) try: diff --git a/contrib/scripts/avocado-fetch-wheels.py b/contrib/scripts/avocado-fetch-wheels.py new file mode 100755 index 0000000000..98f4528dc8 --- /dev/null +++ b/contrib/scripts/avocado-fetch-wheels.py @@ -0,0 +1,52 @@ +#!/bin/env python3 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; specifically version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# +# See LICENSE for more details. +# +# Copyright: 2026 Red Hat, Inc. + +"""Prefetch the universal Avocado wheel used by isolated spawners.""" + +import logging +import sys + +from avocado.core.settings import settings +from avocado.core.spawners.wheel_bootstrap import default_wheel_url +from avocado.core.version import VERSION +from avocado.utils.asset import Asset + +CACHE_DIRS = settings.as_dict().get("datadir.paths.cache_dirs") + +LOG = logging.getLogger("avocado.utils.asset") + + +def configure_logging_settings(): + LOG.setLevel(logging.INFO) + logger_handler = logging.StreamHandler() + LOG.addHandler(logger_handler) + formatter = logging.Formatter("%(levelname)s: %(message)s") + logger_handler.setFormatter(formatter) + + +def main(): + configure_logging_settings() + url = default_wheel_url(VERSION) + try: + asset = Asset(url, cache_dirs=CACHE_DIRS) + path = asset.fetch() + except OSError: + LOG.error("Failed to fetch Avocado wheel from %s", url) + return 1 + LOG.info("Cached Avocado wheel at %s", path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From b4ae382bd79c783e14cc9324850eb5b3495f6129 Mon Sep 17 00:00:00 2001 From: Harvey Lynden Date: Fri, 21 Aug 2026 15:31:09 +0200 Subject: [PATCH 07/11] selftests: pin Podman jobs to fedora:40 which still ships Python Gate 3 on a Fedora 44 VM showed fedora:41+ default container images no longer include python3, so nrunner cannot start. fedora:40 still has Python 3.12, the first interpreter where eggs already failed. Reference: https://github.com/avocado-framework/avocado/issues/6115 Signed-off-by: Harvey Lynden --- .github/workflows/ci.yml | 2 +- docs/source/releases/next.rst | 5 ++++- selftests/functional/plugin/spawners/podman.py | 12 ++++++------ selftests/functional/serial/requirements.py | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4167c1ee3a..03b3672b8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -428,7 +428,7 @@ jobs: python3 -m pip wheel --no-deps -w /tmp . WHL=$(ls /tmp/avocado_framework-*-py3-none-any.whl) python3 -m pip install --user --no-deps "$WHL" - python3 -m avocado run --spawner=podman --spawner-podman-image=fedora:latest --spawner-podman-avocado-wheel="file://${WHL}" -- /bin/true + python3 -m avocado run --spawner=podman --spawner-podman-image=fedora:40 --spawner-podman-avocado-wheel="file://${WHL}" -- /bin/true podman_external_runner_task: diff --git a/docs/source/releases/next.rst b/docs/source/releases/next.rst index 35c704f9d2..f259471db1 100644 --- a/docs/source/releases/next.rst +++ b/docs/source/releases/next.rst @@ -19,7 +19,10 @@ Users/Test Writers `_ and `#6115 `_). Use ``--spawner-podman-avocado-wheel``; ``--spawner-podman-avocado-egg`` - remains as a deprecated alias. + remains as a deprecated alias. The container image must provide + ``python3``; Fedora 41+ default container images no longer do, so + selftests use ``fedora:40``. ``fedora-toolbox`` images still include + a current interpreter. Utility Modules =============== diff --git a/selftests/functional/plugin/spawners/podman.py b/selftests/functional/plugin/spawners/podman.py index 1101d33e76..9da1d0a12f 100644 --- a/selftests/functional/plugin/spawners/podman.py +++ b/selftests/functional/plugin/spawners/podman.py @@ -30,7 +30,7 @@ def test(self): class PodmanSpawnerTest(Test): """ :avocado: dependency={"type": "package", "name": "podman", "action": "check"} - :avocado: dependency={"type": "podman-image", "uri": "registry.fedoraproject.org/fedora:latest"} + :avocado: dependency={"type": "podman-image", "uri": "registry.fedoraproject.org/fedora:40"} """ def test_avocado_instrumented(self): @@ -42,7 +42,7 @@ def test_avocado_instrumented(self): f"{AVOCADO} run " f"--job-results-dir {self.workdir} " f"--disable-sysinfo --spawner=podman " - f"--spawner-podman-image=fedora:latest -- " + f"--spawner-podman-image=fedora:40 -- " f"{test}", ignore_status=True, ) @@ -55,7 +55,7 @@ def test_exec(self): f"{AVOCADO} run " f"--job-results-dir {self.workdir} " f"--disable-sysinfo --spawner=podman " - f"--spawner-podman-image=fedora:latest -- " + f"--spawner-podman-image=fedora:40 -- " f"/bin/true", ignore_status=True, ) @@ -73,7 +73,7 @@ def test_sleep_longer_timeout_podman(self): "run.results_dir": self.workdir, "task.timeout.running": 2, "run.spawner": "podman", - "spawner.podman.image": "fedora:latest", + "spawner.podman.image": "fedora:40", } with Job.from_config(job_config=config) as job: @@ -93,7 +93,7 @@ def test_outputdir(self): ], "run.results_dir": self.workdir, "run.spawner": "podman", - "spawner.podman.image": "fedora:latest", + "spawner.podman.image": "fedora:40", } with Job.from_config(job_config=config) as job: @@ -110,7 +110,7 @@ def test_asset_files(self): f"{AVOCADO} run " f"--job-results-dir {self.workdir} " f"--disable-sysinfo --spawner=podman " - f"--spawner-podman-image=fedora:latest -- " + f"--spawner-podman-image=fedora:40 -- " f"{test}", ignore_status=True, ) diff --git a/selftests/functional/serial/requirements.py b/selftests/functional/serial/requirements.py index e8b66e979e..d4ddee79d3 100644 --- a/selftests/functional/serial/requirements.py +++ b/selftests/functional/serial/requirements.py @@ -161,7 +161,7 @@ def get_command(self, path): spawner = self.params.get("spawner", default="process") spawner_command = "" if spawner == "podman": - spawner_command = "--spawner=podman --spawner-podman-image=fedora:latest" + spawner_command = "--spawner=podman --spawner-podman-image=fedora:40" return f"{AVOCADO} run {spawner_command} --job-results-dir {self.tmpdir.name} {path}" @skipUnless(os.getenv("CI"), skip_package_manager_message) From cf1374e72472f1442971a3d364bc7adc0b0561e4 Mon Sep 17 00:00:00 2001 From: Harvey Lynden Date: Fri, 21 Aug 2026 15:49:41 +0200 Subject: [PATCH 08/11] avocado.core.spawners: fetch wheels from PyPI when GitHub has none Avocado 113.0 GitHub releases only attached eggs. pip-installed Avocado therefore could not bootstrap Podman until we also try PyPI. Snapshot the source tree before pip wheel so read-only mounts and parallel selftests still build. Copy README.rst as a regular file because it is a symlink into docs/. Reference: https://github.com/avocado-framework/avocado/issues/6108 Signed-off-by: Harvey Lynden --- avocado/core/spawners/wheel_bootstrap.py | 155 +++++++++++++++++++++-- selftests/unit/plugin/wheel_bootstrap.py | 16 +++ 2 files changed, 160 insertions(+), 11 deletions(-) diff --git a/avocado/core/spawners/wheel_bootstrap.py b/avocado/core/spawners/wheel_bootstrap.py index 641abd248c..4a3142b36b 100644 --- a/avocado/core/spawners/wheel_bootstrap.py +++ b/avocado/core/spawners/wheel_bootstrap.py @@ -30,6 +30,7 @@ import shutil import subprocess import sys +import tempfile import zipfile from dataclasses import dataclass from pathlib import Path @@ -116,39 +117,161 @@ def _extract_wheel(wheel_path, dest_dir): return str(dest) +def _pip_output(result, what): + if result.returncode != 0: + raise RuntimeError( + f"{what} failed with exit {result.returncode}:\n" + f"{result.stdout.decode(errors='replace')}" + ) + + +_COPYTREE_IGNORE = shutil.ignore_patterns( + ".git", + ".github", + "docs", + "selftests", + "PYPI_UPLOAD", + "EGG_UPLOAD", + "build", + "dist", + "*.egg-info", + "__pycache__", +) + + +def _copy_regular(source, work, name): + """Copy ``name`` as a real file. + + ``README.rst`` is a symlink into ``docs/``. ``git archive`` keeps + that symlink, and the copytree fallback omits ``docs/``, so + ``setup.py`` would otherwise fail to open it. + """ + src = Path(source) / name + dest = Path(work) / name + if not src.exists(): + return + if dest.exists() or dest.is_symlink(): + dest.unlink() + shutil.copyfile(src, dest) + + +def _snapshot_source(source): + """Return a writable copy of ``source`` that pip wheel can mutate. + + Prefer ``git archive`` so a live checkout being used by parallel + selftests is not read mid-write. Fall back to copytree. + """ + source = Path(source) + git_src = str(source.resolve()) + tmp = Path(tempfile.mkdtemp(prefix="avocado-wheel-src-")) + work = tmp / "src" + work.mkdir() + archived = False + if (source / ".git").exists(): + try: + archive = subprocess.run( + [ + "git", + "-C", + git_src, + "-c", + f"safe.directory={git_src}", + "archive", + "--format=tar", + "HEAD", + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + subprocess.run( + ["tar", "-x", "-C", str(work)], + check=True, + input=archive.stdout, + ) + archived = True + except (OSError, subprocess.CalledProcessError) as exc: + LOG.debug("git archive of %s failed (%s); copying the tree", source, exc) + shutil.rmtree(work) + if not archived: + shutil.copytree( + source, + work, + symlinks=True, + ignore=_COPYTREE_IGNORE, + dirs_exist_ok=True, + ) + _copy_regular(source, work, "README.rst") + return tmp, work + + def build_wheel_from_source(source, dest_dir): """Build a universal wheel from a source tree with pip. + The tree is snapshotted first. setuptools writes ``*.egg-info`` + during the build, and parallel selftests can mutate a live checkout. + :param source: path to the Avocado repository :param dest_dir: directory that will receive the ``.whl`` :returns: path to the built wheel """ dest = Path(dest_dir) dest.mkdir(parents=True, exist_ok=True) - LOG.info("Building Avocado wheel from %s", source) + tmp, work = _snapshot_source(source) + try: + LOG.info("Building Avocado wheel from %s", work) + result = subprocess.run( + [ + sys.executable, + "-m", + "pip", + "wheel", + "--no-deps", + "-w", + str(dest), + str(work), + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + _pip_output(result, "pip wheel") + finally: + shutil.rmtree(tmp, ignore_errors=True) + wheels = sorted(dest.glob("avocado_framework-*.whl")) + if not wheels: + raise RuntimeError(f"pip wheel produced no avocado_framework wheel in {dest}") + return str(wheels[-1]) + + +def download_pypi_wheel(version, dest_dir): + """Download the universal Avocado wheel from PyPI.""" + dest = Path(dest_dir) + dest.mkdir(parents=True, exist_ok=True) + requirement = f"avocado-framework=={effective_version(version)}" + LOG.info("Downloading %s from PyPI", requirement) result = subprocess.run( [ sys.executable, "-m", "pip", - "wheel", + "download", "--no-deps", - "-w", + "--only-binary=:all:", + "-d", str(dest), - str(source), + requirement, ], check=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) - if result.returncode != 0: - raise RuntimeError( - f"pip wheel failed with exit {result.returncode}:\n" - f"{result.stdout.decode(errors='replace')}" - ) + _pip_output(result, f"pip download {requirement}") wheels = sorted(dest.glob("avocado_framework-*.whl")) if not wheels: - raise RuntimeError(f"pip wheel produced no avocado_framework wheel in {dest}") + raise RuntimeError( + f"pip download produced no avocado_framework wheel in {dest}" + ) return str(wheels[-1]) @@ -165,6 +288,8 @@ def resolve_wheel_file(url=None, cache_dirs=None, version=None): 1. Explicit URL (``file://`` or remote). 2. Wheel built from the source tree this module lives in (develop). 3. GitHub release asset for ``version``. + 4. PyPI ``avocado-framework==version`` (covers releases that still + only uploaded eggs to GitHub). """ if version is None: version = effective_version() @@ -188,7 +313,15 @@ def resolve_wheel_file(url=None, cache_dirs=None, version=None): return str(built) return build_wheel_from_source(src, cache_root) - return _fetch_remote_wheel(default_wheel_url(version), cache_dirs) + try: + return _fetch_remote_wheel(default_wheel_url(version), cache_dirs) + except OSError as exc: + LOG.warning( + "GitHub wheel for Avocado %s is not available (%s); trying PyPI", + version, + exc, + ) + return download_pypi_wheel(version, cache_root) def prepare_bootstrap(url=None, cache_dirs=None, version=None): diff --git a/selftests/unit/plugin/wheel_bootstrap.py b/selftests/unit/plugin/wheel_bootstrap.py index eb654b47ad..2fe1b6439b 100644 --- a/selftests/unit/plugin/wheel_bootstrap.py +++ b/selftests/unit/plugin/wheel_bootstrap.py @@ -5,6 +5,7 @@ import unittest import zipfile from pathlib import Path +from unittest import mock from avocado.core.spawners import wheel_bootstrap from avocado.core.spawners.wheel_bootstrap import ( @@ -92,3 +93,18 @@ def test_default_builds_from_source_tree(self): env=env, ) self.assertIn("task-run", result.stdout + result.stderr) + + def test_github_miss_falls_back_to_pypi(self): + with tempfile.TemporaryDirectory(prefix="avocado_wheel_pypi_") as cache: + with mock.patch.object(wheel_bootstrap, "source_root", return_value=None): + with mock.patch.object( + wheel_bootstrap, "_fetch_remote_wheel", side_effect=OSError("404") + ): + with mock.patch.object( + wheel_bootstrap, + "download_pypi_wheel", + return_value=self.wheel, + ) as pypi: + path = wheel_bootstrap.resolve_wheel_file(cache_dirs=[cache]) + pypi.assert_called_once() + self.assertEqual(path, self.wheel) From 3e4dd20cb73fcd3eebe5257563ee38a9cb7e02bf Mon Sep 17 00:00:00 2001 From: Harvey Lynden Date: Fri, 21 Aug 2026 15:49:47 +0200 Subject: [PATCH 09/11] ci: stop building eggs and publish wheels on GitHub releases Drop the per-interpreter egg-build matrix and Makefile.gh bdist_egg. The release pipeline uploads py3-none-any wheels to the GitHub release instead of eggs. Reference: https://github.com/avocado-framework/avocado/issues/6108 Signed-off-by: Harvey Lynden --- .github/actions/egg/action.yml | 14 ------------- .github/actions/wheel/action.yml | 14 +++++++++++++ .github/workflows/ci.yml | 29 +------------------------- .github/workflows/release.yml | 33 ++++++++---------------------- Makefile.gh | 11 ---------- avocado/plugins/spawners/podman.py | 12 +++++++++-- 6 files changed, 34 insertions(+), 79 deletions(-) delete mode 100644 .github/actions/egg/action.yml create mode 100644 .github/actions/wheel/action.yml diff --git a/.github/actions/egg/action.yml b/.github/actions/egg/action.yml deleted file mode 100644 index ff11b2c9ce..0000000000 --- a/.github/actions/egg/action.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Egg -description: Test running avocado from eggs -runs: - using: composite - steps: - - name: Test running avocado from eggs - shell: sh - run: | - python3 setup.py bdist_egg - mv dist/avocado_framework-*egg /tmp - python3 setup.py clean --all - python3 -c 'import sys; import glob; sys.path.insert(0, glob.glob("/tmp/avocado_framework-*.egg")[0]); from avocado.core.main import main; sys.exit(main())' run /bin/true - cd /tmp - python3 -c 'import sys; from pkg_resources import require; require("avocado-framework"); from avocado.core.main import main; sys.exit(main())' run /bin/true diff --git a/.github/actions/wheel/action.yml b/.github/actions/wheel/action.yml new file mode 100644 index 0000000000..41fc639f9b --- /dev/null +++ b/.github/actions/wheel/action.yml @@ -0,0 +1,14 @@ +name: Wheel +description: Test running avocado from a universal wheel +runs: + using: composite + steps: + - name: Test running avocado from a wheel + shell: sh + run: | + python3 -m pip install --user pip setuptools wheel + python3 -m pip wheel --no-deps -w /tmp . + WHL=$(ls /tmp/avocado_framework-*-py3-none-any.whl) + DEST=/tmp/avocado-wheel + python3 -c "import zipfile, sys; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])" "$WHL" "$DEST" + python3 -c "import sys; sys.path.insert(0, sys.argv[1]); from avocado.core.main import main; sys.exit(main())" "$DEST" run /bin/true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03b3672b8e..5fda86deb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -220,33 +220,6 @@ jobs: retention-days: 1 - run: echo "🥑 This job's status is ${{ job.status }}." - egg-build: - name: Build Egg for Python ${{ matrix.python-version }} - runs-on: ubuntu-22.04 - - strategy: - matrix: - python-version: ['3.10', 3.11, 3.12, 3.13, 3.14] - fail-fast: false - - steps: - - uses: actions/checkout@v7 - - name: Set up Python - uses: actions/setup-python@v7 - with: - python-version: ${{ matrix.python-version }} - - name: Install setuptools on Python >= 3.12 - run: python3 -c 'import setuptools' || python3 -m pip install "setuptools<80" - - name: Build eggs - run: make -f Makefile.gh build-egg - - name: Save eggs as artifacts - uses: actions/upload-artifact@v7 - with: - name: eggs-${{ matrix.python-version }} - path: ${{github.workspace}}/EGG_UPLOAD/ - retention-days: 1 - - run: echo "🥑 This job's status is ${{ job.status }}." - vt-integration-check: name: Integration with most major VT plugin runs-on: ubuntu-latest @@ -437,7 +410,7 @@ jobs: steps: - name: Check out repository code uses: actions/checkout@v7 - - name: Test running avocado from released eggs under Podman spawner with 3rd party plugins + - name: Test running avocado from a wheel under Podman spawner with 3rd party plugins run: | sudo apt-get update sudo apt-get install -y podman python3 python3-setuptools diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56e8ca9fea..5806a0e0d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,15 +103,10 @@ jobs: - name: Publish avocado to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - build-and-publish-eggs: - name: Build eggs and publish them - runs-on: ubuntu-22.04 + publish-wheels-to-github: + name: Publish wheels to GitHub release + runs-on: ubuntu-latest needs: release - strategy: - matrix: - python-version: [3.9, '3.10', 3.11, 3.12, 3.13] - fail-fast: false - steps: - name: Generate token id: generate_token @@ -120,26 +115,16 @@ jobs: app_id: ${{ secrets.MR_AVOCADO_ID }} installation_id: ${{ secrets.MR_AVOCADO_INSTALLATION_ID }} private_key: ${{ secrets.MR_AVOCADO_PRIVATE_KEY }} - - uses: actions/checkout@v7 - with: - persist-credentials: false - fetch-depth: 0 - ref: ${{ github.event.inputs.version }} - - name: Set up Python - uses: actions/setup-python@v7 + - name: Download wheels + uses: actions/download-artifact@v8 with: - python-version: ${{ matrix.python-version }} - - name: Build eggs - run: | - if python -c 'import sys; exit(0) if sys.version_info.minor > 11 else exit(1)' ; then - pip install "setuptools<80" - fi - make -f Makefile.gh build-egg - - name: Upload binaries to release + name: wheel + path: dist/ + - name: Upload wheels to the GitHub release uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ steps.generate_token.outputs.token }} - file: ${{ github.workspace }}/EGG_UPLOAD/avocado_framework*egg + file: dist/*.whl tag: ${{ github.event.inputs.version }} overwrite: true file_glob: true diff --git a/Makefile.gh b/Makefile.gh index f7cccc89d0..aea6873cdf 100644 --- a/Makefile.gh +++ b/Makefile.gh @@ -70,17 +70,6 @@ check-wheel: build-wheel $(PYTHON) -m pip install twine==6.1.0 packaging==24.2 twine check --strict ./PYPI_UPLOAD/* -build-egg: - if test ! -d EGG_UPLOAD; then mkdir EGG_UPLOAD; fi - $(PYTHON) setup.py bdist_egg -d EGG_UPLOAD - for PLUGIN in $(AVOCADO_OPTIONAL_PLUGINS); do\ - if test -f $$PLUGIN/setup.py; then\ - cd $$PLUGIN;\ - $(PYTHON) setup.py bdist_egg -d ../../EGG_UPLOAD;\ - cd -;\ - fi;\ - done - update-pypi: ifndef TWINE_USERNAME $(error TWINE_USERNAME is undefined) diff --git a/avocado/plugins/spawners/podman.py b/avocado/plugins/spawners/podman.py index 01e187f2a7..ea623e3b76 100644 --- a/avocado/plugins/spawners/podman.py +++ b/avocado/plugins/spawners/podman.py @@ -257,7 +257,15 @@ async def python_version(self): msg = "Cannot get Python version: self.podman not defined." LOG.debug(msg) return None, None, None - result = await self.podman.get_python_version(image) + try: + result = await self.podman.get_python_version(image) + except PodmanException as ex: + raise PodmanSpawnerException( + f"Image {image!r} has no usable python3. nrunner starts " + "with 'python3 -m avocado.plugins.runners...' inside the " + "container. Fedora 41+ default images dropped python3; " + "use fedora:40, fedora-toolbox, or python:*-slim." + ) from ex self._PYTHON_VERSIONS_CACHE[image] = result return self._PYTHON_VERSIONS_CACHE[image] @@ -315,7 +323,7 @@ async def _create_container_for_task( if bootstrap is not None: bootstrap_opts = ( "-v", - f"{bootstrap.host_path}:{bootstrap.container_path}:ro", + f"{bootstrap.host_path}:{bootstrap.container_path}:ro,z", ) task = runtime_task.task From ee05b78562cc6602a114e99485ed90dbb5812f64 Mon Sep 17 00:00:00 2001 From: Harvey Lynden Date: Fri, 21 Aug 2026 15:50:06 +0200 Subject: [PATCH 10/11] docs: drop the setuptools<82 pin now that eggs are gone Avocado core no longer imports pkg_resources. Document that releases publish wheels, not eggs, and that docs can use current setuptools. Reference: https://github.com/avocado-framework/avocado/issues/6108 Signed-off-by: Harvey Lynden --- docs/source/quickstart/index.rst | 12 ------------ docs/source/releases/next.rst | 10 +++++++++- requirements-doc.txt | 3 +-- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/docs/source/quickstart/index.rst b/docs/source/quickstart/index.rst index 50d0bada14..cd888140d0 100644 --- a/docs/source/quickstart/index.rst +++ b/docs/source/quickstart/index.rst @@ -235,18 +235,6 @@ It is super easy, just run the follow command:: This will install the avocado command in your home directory. -.. warning:: **Python 3.11+ and setuptools compatibility** - - If you encounter installation or import errors on Python 3.11 or newer - (e.g. ``ModuleNotFoundError: No module named 'pkg_resources'``), Avocado - still depends on ``pkg_resources``, which was removed in setuptools 82+. - Downgrade setuptools before installing:: - - $ pip3 install "setuptools<82" - $ pip3 install --user avocado-framework - - This is a temporary workaround until Avocado migrates. - .. note:: For more details and alternative methods, please visit the `Installing section on Avocado User’s Guide`_ diff --git a/docs/source/releases/next.rst b/docs/source/releases/next.rst index f259471db1..32b9d33bbe 100644 --- a/docs/source/releases/next.rst +++ b/docs/source/releases/next.rst @@ -24,6 +24,13 @@ Users/Test Writers selftests use ``fedora:40``. ``fedora-toolbox`` images still include a current interpreter. +* Avocado no longer builds or publishes Python eggs. Releases upload + ``py3-none-any`` wheels to GitHub (and PyPI, as before). Isolated + spawners fetch that wheel from GitHub, then fall back to PyPI if the + GitHub asset is missing (Avocado 113.0 and earlier only attached + eggs). The ``setuptools<82`` install workaround is no longer + required for Avocado itself. + Utility Modules =============== @@ -37,7 +44,8 @@ Bug Fixes Internal changes ================ -* +* CI dropped the per-interpreter ``egg-build`` matrix. The release + pipeline publishes wheels to GitHub instead of eggs. Additional information ====================== diff --git a/requirements-doc.txt b/requirements-doc.txt index ddc3383d2b..ed4831e494 100644 --- a/requirements-doc.txt +++ b/requirements-doc.txt @@ -1,3 +1,2 @@ sphinx-rtd-theme==2.0.0 -# Pin <82: setuptools 82.0.0+ removed pkg_resources (avocado.core still uses it at doc build time) -setuptools>=45.0.0,<82 +setuptools>=45.0.0 From fc67554385973c3a9fb540aafca7ee8d43b4322d Mon Sep 17 00:00:00 2001 From: Harvey Lynden Date: Fri, 21 Aug 2026 15:50:06 +0200 Subject: [PATCH 11/11] contrib: fetch-eggs now prefetches the universal wheel Stop downloading per-CPython eggs and setuptools 59.2. The old script warns and uses the same resolver as avocado-fetch-wheels. Reference: https://github.com/avocado-framework/avocado/issues/6108 Signed-off-by: Harvey Lynden --- contrib/scripts/avocado-fetch-eggs.py | 51 ++++++------------------- contrib/scripts/avocado-fetch-wheels.py | 12 ++---- 2 files changed, 15 insertions(+), 48 deletions(-) diff --git a/contrib/scripts/avocado-fetch-eggs.py b/contrib/scripts/avocado-fetch-eggs.py index ca39015d65..73c37ef31c 100755 --- a/contrib/scripts/avocado-fetch-eggs.py +++ b/contrib/scripts/avocado-fetch-eggs.py @@ -13,16 +13,15 @@ # Copyright: 2021 Red Hat, Inc. # Author: Beraldo Leal +"""Deprecated: prefetch the universal Avocado wheel (eggs are no longer used).""" + import logging import sys from avocado.core.settings import settings -from avocado.core.version import VERSION -from avocado.utils.asset import Asset +from avocado.core.spawners.wheel_bootstrap import resolve_wheel_file CACHE_DIRS = settings.as_dict().get("datadir.paths.cache_dirs") - -# Avocado asset lib already has its logger. Let's use it. LOG = logging.getLogger("avocado.utils.asset") @@ -34,46 +33,18 @@ def configure_logging_settings(): logger_handler.setFormatter(formatter) -def get_setuptools_egg_url(python_version=None): - if python_version is None: - version = sys.version_info - python_version = f"{version.major}.{version.minor}" - return f"https://github.com/avocado-framework/setuptools/releases/download/v59.2.0/setuptools-59.2.0-py{python_version}.egg" - - -def get_avocado_egg_url(avocado_version=None, python_version=None): - if avocado_version is None: - avocado_version = VERSION - if python_version is None: - version = sys.version_info - python_version = f"{version.major}.{version.minor}" - - asset = f"avocado_framework-{avocado_version}-py{python_version}.egg" - return f"https://github.com/avocado-framework/avocado/releases/download/{avocado_version}/{asset}" - - def main(): configure_logging_settings() LOG.warning( - "avocado-fetch-eggs.py is deprecated. Isolated spawners now use " - "a universal wheel; prefer contrib/scripts/avocado-fetch-wheels.py. " - "Eggs fail on Fedora 39+ / Python 3.12+." + "avocado-fetch-eggs.py is deprecated and now fetches the universal " + "wheel. Use contrib/scripts/avocado-fetch-wheels.py." ) - for version in ["3.9", "3.10", "3.11", "3.12", "3.13"]: - url = get_avocado_egg_url(python_version=version) - try: - asset = Asset(url, cache_dirs=CACHE_DIRS) - asset.fetch() - except OSError: - LOG.error("Failed to fetch Avocado egg for Python version %s", version) - return 1 - url = get_setuptools_egg_url(python_version=version) - try: - asset = Asset(url, cache_dirs=CACHE_DIRS) - asset.fetch() - except OSError: - LOG.error("Failed to fetch setuptools egg for Python version %s", version) - return 1 + try: + path = resolve_wheel_file(cache_dirs=CACHE_DIRS) + except (OSError, RuntimeError) as exc: + LOG.error("Failed to fetch Avocado wheel: %s", exc) + return 1 + LOG.info("Cached Avocado wheel at %s", path) return 0 diff --git a/contrib/scripts/avocado-fetch-wheels.py b/contrib/scripts/avocado-fetch-wheels.py index 98f4528dc8..ed02af5c19 100755 --- a/contrib/scripts/avocado-fetch-wheels.py +++ b/contrib/scripts/avocado-fetch-wheels.py @@ -18,9 +18,7 @@ import sys from avocado.core.settings import settings -from avocado.core.spawners.wheel_bootstrap import default_wheel_url -from avocado.core.version import VERSION -from avocado.utils.asset import Asset +from avocado.core.spawners.wheel_bootstrap import resolve_wheel_file CACHE_DIRS = settings.as_dict().get("datadir.paths.cache_dirs") @@ -37,12 +35,10 @@ def configure_logging_settings(): def main(): configure_logging_settings() - url = default_wheel_url(VERSION) try: - asset = Asset(url, cache_dirs=CACHE_DIRS) - path = asset.fetch() - except OSError: - LOG.error("Failed to fetch Avocado wheel from %s", url) + path = resolve_wheel_file(cache_dirs=CACHE_DIRS) + except (OSError, RuntimeError) as exc: + LOG.error("Failed to fetch Avocado wheel: %s", exc) return 1 LOG.info("Cached Avocado wheel at %s", path) return 0