diff options
| author | Stefano Rivera <stefanor@debian.org> | 2026-04-24 17:39:44 -0400 |
|---|---|---|
| committer | git-ubuntu importer <ubuntu-devel-discuss@lists.ubuntu.com> | 2026-04-25 04:39:46 +0000 |
| commit | f5524fcfe183c63eddbbe1d0b59fe793b90555ef (patch) | |
| tree | e8596e2fcaca396196f452f08e41d64ec1c32171 | |
| parent | a4a886e4174818af8c09db089dfc983573718f34 (diff) | |
21.2.4+ds-1 (patches unapplied)import/21.2.4+ds-1
Imported using git-ubuntu import.
Notes
Notes:
* New upstream release.
* Refresh patches.
* Bump Standards-Version 4.7.4, no changes needed.
* Patch BUNDLE_SHA256 to include Debian's wheels.
33 files changed, 795 insertions, 229 deletions
@@ -1,12 +1,12 @@ Metadata-Version: 2.4 Name: virtualenv -Version: 21.2.0 +Version: 21.2.4 Summary: Virtual Python Environment builder Project-URL: Documentation, https://virtualenv.pypa.io Project-URL: Homepage, https://github.com/pypa/virtualenv Project-URL: Source, https://github.com/pypa/virtualenv Project-URL: Tracker, https://github.com/pypa/virtualenv/issues -Maintainer-email: Bernat Gabor <gaborjbernat@gmail.com> +Maintainer-email: Bernat Gabor <gaborjbernat@gmail.com>, Rahul Devikar <rahuldevikar5512@gmail.com> License-Expression: MIT License-File: LICENSE Keywords: environments,isolated,virtual @@ -35,7 +35,7 @@ Requires-Dist: filelock<4,>=3.24.2; python_version >= '3.10' Requires-Dist: filelock<=3.19.1,>=3.16.1; python_version < '3.10' Requires-Dist: importlib-metadata>=6.6; python_version < '3.8' Requires-Dist: platformdirs<5,>=3.9.1 -Requires-Dist: python-discovery>=1 +Requires-Dist: python-discovery>=1.2.2 Requires-Dist: typing-extensions>=4.13.2; python_version < '3.11' Description-Content-Type: text/markdown @@ -52,7 +52,7 @@ Description-Content-Type: text/markdown A tool for creating isolated `virtual` python environments. -- [Installation](https://virtualenv.pypa.io/en/latest/installation.html) +- [Installation](https://virtualenv.pypa.io/en/latest/how-to/install.html) - [Documentation](https://virtualenv.pypa.io) - [Changelog](https://virtualenv.pypa.io/en/latest/changelog.html) - [Issues](https://github.com/pypa/virtualenv/issues) @@ -11,7 +11,7 @@ A tool for creating isolated `virtual` python environments. -- [Installation](https://virtualenv.pypa.io/en/latest/installation.html) +- [Installation](https://virtualenv.pypa.io/en/latest/how-to/install.html) - [Documentation](https://virtualenv.pypa.io) - [Changelog](https://virtualenv.pypa.io/en/latest/changelog.html) - [Issues](https://github.com/pypa/virtualenv/issues) diff --git a/debian/changelog b/debian/changelog index 31ed49c..dcc8de0 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,12 @@ +python-virtualenv (21.2.4+ds-1) unstable; urgency=medium + + * New upstream release. + * Refresh patches. + * Bump Standards-Version 4.7.4, no changes needed. + * Patch BUNDLE_SHA256 to include Debian's wheels. + + -- Stefano Rivera <stefanor@debian.org> Fri, 24 Apr 2026 17:39:44 -0400 + python-virtualenv (21.2.0+ds-1) unstable; urgency=medium * New upstream release. diff --git a/debian/control b/debian/control index c5a1c44..2785654 100644 --- a/debian/control +++ b/debian/control @@ -20,7 +20,7 @@ Build-Depends-Indep: python3-discovery <!nocheck>, python3-hatchling (>= 1.11.1), python3-hatch-vcs (>= 0.3), python3-setuptools-whl <!nocheck>, -Standards-Version: 4.7.3 +Standards-Version: 4.7.4 Package: python3-virtualenv Architecture: all diff --git a/debian/patches/debian_update_for_available_wheels.patch b/debian/patches/debian_update_for_available_wheels.patch index f205904..bf0733d 100644 --- a/debian/patches/debian_update_for_available_wheels.patch +++ b/debian/patches/debian_update_for_available_wheels.patch @@ -13,11 +13,11 @@ Last-Update: 2020-07-15 1 file changed, 21 insertions(+) diff --git a/src/virtualenv/seed/wheels/embed/__init__.py b/src/virtualenv/seed/wheels/embed/__init__.py -index 767e742..2da2a24 100644 +index 31b12b9..04027e2 100644 --- a/src/virtualenv/seed/wheels/embed/__init__.py +++ b/src/virtualenv/seed/wheels/embed/__init__.py -@@ -43,6 +43,27 @@ BUNDLE_SUPPORT = { - MAX = "3.8" +@@ -57,6 +57,27 @@ BUNDLE_SHA256 = { + _VERIFIED_WHEELS: set[str] = set() +# Debian specific: Update BUNDLE_SUPPORT to match pip wheels shipped in @@ -42,5 +42,5 @@ index 767e742..2da2a24 100644 + + def get_embed_wheel(distribution: str, for_py_version: str) -> Wheel | None: - mapping = BUNDLE_SUPPORT.get(for_py_version, {}) or BUNDLE_SUPPORT[MAX] - wheel_file = mapping.get(distribution) + """Return the bundled wheel that ships with virtualenv for a given distribution and Python version. + diff --git a/debian/patches/debian_wheel_location.patch b/debian/patches/debian_wheel_location.patch index b4a4d71..27248fe 100644 --- a/debian/patches/debian_wheel_location.patch +++ b/debian/patches/debian_wheel_location.patch @@ -3,21 +3,21 @@ Date: Sat, 21 Mar 2020 03:16:18 -0400 Subject: Acquire wheels from Debian specific wheels location (from pip wheels) -See: https://virtualenv.pypa.io/en/latest/user_guide.html#embed-wheels-for-distributions +See: https://virtualenv.pypa.io/en/latest/explanation.html#distribution-maintainer-patching Origin: vendor Forwarded: not-needed Last-Update: 2020-07-15 --- - src/virtualenv/seed/wheels/embed/__init__.py | 2 +- - 1 file changed, 1 insertion(+), 1 deletion(-) + src/virtualenv/seed/wheels/embed/__init__.py | 11 +++++------ + 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/virtualenv/seed/wheels/embed/__init__.py b/src/virtualenv/seed/wheels/embed/__init__.py -index f9947a1..767e742 100644 +index df58d6d..31b12b9 100644 --- a/src/virtualenv/seed/wheels/embed/__init__.py +++ b/src/virtualenv/seed/wheels/embed/__init__.py -@@ -4,7 +4,7 @@ from pathlib import Path - +@@ -7,7 +7,7 @@ from pathlib import Path + from virtualenv.info import IS_ZIPAPP, ROOT from virtualenv.seed.wheels.util import Wheel -BUNDLE_FOLDER = Path(__file__).absolute().parent @@ -25,3 +25,19 @@ index f9947a1..767e742 100644 BUNDLE_SUPPORT = { "3.8": { "pip": "pip-25.0.1-py3-none-any.whl", +@@ -48,11 +48,10 @@ MAX = "3.8" + # SHA-256 of every bundled wheel. Verified on load so a corrupted or tampered wheel on disk fails loud instead of + # being handed to pip. Generated together with ``BUNDLE_SUPPORT`` by ``tasks/upgrade_wheels.py``. + BUNDLE_SHA256 = { +- "pip-25.0.1-py3-none-any.whl": "c46efd13b6aa8279f33f2864459c8ce587ea6a1a59ee20de055868d8f7688f7f", +- "pip-26.0.1-py3-none-any.whl": "bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", +- "setuptools-75.3.4-py3-none-any.whl": "2dd50a7f42dddfa1d02a36f275dbe716f38ed250224f609d35fb60a09593d93e", +- "setuptools-82.0.1-py3-none-any.whl": "a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", +- "wheel-0.45.1-py3-none-any.whl": "708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", ++ "packaging-26.0-py3-none-any.whl": "4ba0a44310632f46c59a2de6bb0d5eecd6a08a9154dde41b25bfebd587cb901f", ++ "pip-26.0.1-py3-none-any.whl": "a96182593a734579eb84679c4e973e8af6e0b1f4e69728b755cc100e6442f975", ++ "setuptools-78.1.1-py3-none-any.whl": "a75fa14b8b1d2f2705a9b761e9113010b535aadfd8c5ac12f6f41615b40c87a2", ++ "wheel-0.46.3-py3-none-any.whl": "a61bc3428c784c8fdc374160d3f7870ef72c7ef128b0cce5b51c70b66be36cdb", + } + + _VERIFIED_WHEELS: set[str] = set() diff --git a/debian/patches/wheel-package-error b/debian/patches/wheel-package-error index 0ac07a1..3eb1046 100644 --- a/debian/patches/wheel-package-error +++ b/debian/patches/wheel-package-error @@ -8,13 +8,13 @@ Forwarded: not-needed 1 file changed, 14 insertions(+) diff --git a/src/virtualenv/seed/wheels/embed/__init__.py b/src/virtualenv/seed/wheels/embed/__init__.py -index 2da2a24..4092387 100644 +index 04027e2..d090ec7 100644 --- a/src/virtualenv/seed/wheels/embed/__init__.py +++ b/src/virtualenv/seed/wheels/embed/__init__.py -@@ -65,6 +65,20 @@ BUNDLE_SUPPORT = list_available_wheels(BUNDLE_SUPPORT.keys()) +@@ -90,6 +90,20 @@ def get_embed_wheel(distribution: str, for_py_version: str) -> Wheel | None: + :raises RuntimeError: if the bundled wheel on disk fails SHA-256 verification. - - def get_embed_wheel(distribution: str, for_py_version: str) -> Wheel | None: + """ + # Debian specific: Point at the appropriate wheel package + wheel = BUNDLE_SUPPORT.get(for_py_version, {}).get(distribution) + if wheel is None: diff --git a/pyproject.toml b/pyproject.toml index 2bce1b8..f0a97cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ keywords = [ license = "MIT" maintainers = [ { name = "Bernat Gabor", email = "gaborjbernat@gmail.com" }, + { name = "Rahul Devikar", email = "rahuldevikar5512@gmail.com" }, ] requires-python = ">=3.8" classifiers = [ @@ -49,7 +50,7 @@ dependencies = [ "filelock>=3.24.2,<4; python_version>='3.10'", "importlib-metadata>=6.6; python_version<'3.8'", "platformdirs>=3.9.1,<5", - "python-discovery>=1", + "python-discovery>=1.2.2", "typing-extensions>=4.13.2; python_version<'3.11'", ] urls.Documentation = "https://virtualenv.pypa.io" @@ -108,7 +109,7 @@ test = [ "time-machine>=2.10; platform_python_implementation=='CPython'", ] type = [ - "ty>=0.0.17", + "ty>=0.0.19", { include-group = "test" }, ] docs = [ @@ -209,6 +210,9 @@ count = true [tool.pyproject-fmt] max_supported_python = "3.14" +[tool.ty] +rules.unused-ignore-comment = "ignore" # some ignores are platform/version-specific (e.g., ctypes.windll on Linux) + [tool.pytest] ini_options.markers = [ "slow", @@ -254,6 +258,3 @@ title_format = false issue_format = ":issue:`{issue}`" template = "docs/changelog/template.jinja2" underlines = [ "*", "=", "-" ] - -[tool.ty] -rules.unused-ignore-comment = "ignore" # some ignores are platform/version-specific (e.g., ctypes.windll on Linux) diff --git a/src/virtualenv/activation/nushell/activate.nu b/src/virtualenv/activation/nushell/activate.nu index 7046c58..559872d 100644 --- a/src/virtualenv/activation/nushell/activate.nu +++ b/src/virtualenv/activation/nushell/activate.nu @@ -2,8 +2,9 @@ # - Activate with `overlay use activate.nu` # - Deactivate with `deactivate`, as usual # -# To customize the overlay name, you can call `overlay use activate.nu as foo`, but then simply `deactivate` won't work -# because it is just an alias to hide the "activate" overlay. You'd need to call `overlay hide foo` manually. +# To customize the overlay name, call `overlay use activate.nu as foo`, but then `deactivate` won't work — +# you'd need to run `overlay hide foo` manually. Do not activate with `use activate.nu *`; that does not +# create an overlay and deactivation will fail. module warning { export-env { @@ -86,4 +87,5 @@ export-env { } export alias pydoc = python -m pydoc +# If deactivate errors "not an active overlay": activate with `overlay use activate.nu` not `use activate.nu *`; for custom names use `overlay hide NAME` export alias deactivate = overlay hide activate diff --git a/src/virtualenv/config/cli/parser.py b/src/virtualenv/config/cli/parser.py index eeb58fc..4968d0a 100644 --- a/src/virtualenv/config/cli/parser.py +++ b/src/virtualenv/config/cli/parser.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import shutil from argparse import SUPPRESS, ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace from collections import OrderedDict from typing import TYPE_CHECKING, Any @@ -140,7 +141,7 @@ class VirtualEnvConfigParser(ArgumentParser): class HelpFormatter(ArgumentDefaultsHelpFormatter): def __init__(self, prog: str, **kwargs: Any) -> None: # noqa: ANN401 - super().__init__(prog, max_help_position=32, width=240, **kwargs) + super().__init__(prog, max_help_position=32, width=shutil.get_terminal_size().columns, **kwargs) def _get_help_string(self, action: Action) -> str | None: text = super()._get_help_string(action) diff --git a/src/virtualenv/create/creator.py b/src/virtualenv/create/creator.py index 85d1018..9fa60d3 100644 --- a/src/virtualenv/create/creator.py +++ b/src/virtualenv/create/creator.py @@ -21,6 +21,8 @@ if TYPE_CHECKING: from virtualenv.app_data.base import AppData from virtualenv.config.cli.parser import VirtualEnvOptions +from os.path import commonpath + from virtualenv.util.path import safe_delete from virtualenv.util.subprocess import LogCmd, run_cmd from virtualenv.version import __version__ @@ -91,7 +93,7 @@ class Creator(ABC): ] @classmethod - def can_create(cls, interpreter: PythonInfo) -> CreatorMeta | None: # noqa: ARG003 + def can_create(cls, interpreter: PythonInfo) -> CreatorMeta | bool | None: # noqa: ARG003 """Determine if we can create a virtual environment. :param interpreter: the interpreter in question @@ -100,7 +102,7 @@ class Creator(ABC): :meth:`add_parser_arguments` """ - return True # type: ignore[return-value] + return True @classmethod def add_parser_arguments( @@ -148,7 +150,7 @@ class Creator(ABC): """No path separator in the path, valid chars and must be write-able.""" def non_write_able(dest: Path, value: Path) -> NoReturn: - common = Path(*os.path.commonprefix([value.parts, dest.parts])) + common = Path(commonpath([str(value), str(dest)])) msg = f"the destination {dest.relative_to(common)} is not write-able at {common}" raise ArgumentTypeError(msg) diff --git a/src/virtualenv/create/debug.py b/src/virtualenv/create/debug.py index c56aab9..2590fe1 100644 --- a/src/virtualenv/create/debug.py +++ b/src/virtualenv/create/debug.py @@ -19,15 +19,16 @@ def encode_list_path(value: list[object]) -> list[str | None]: return [encode_path(i) for i in value] -def run() -> None: # noqa: C901 +def run() -> None: # noqa: C901,PLR0915 """Print debug data about the virtual environment.""" try: from collections import OrderedDict # noqa: PLC0415 - except ImportError: # pragma: no cover - # this is possible if the standard library cannot be accessed - OrderedDict = dict # type: ignore[misc] # pragma: no cover # noqa: N806 - result: dict = OrderedDict([("sys", OrderedDict())]) + DictType = OrderedDict # noqa: N806 + except ImportError: # pragma: no cover + DictType = dict # pragma: no cover # noqa: N806 + sys_info: dict[str, str | list[str | None] | None] = DictType() + result: dict[str, str | dict[str, str | list[str | None] | None] | None] = DictType([("sys", sys_info)]) path_keys = ( "executable", "_base_executable", @@ -42,9 +43,9 @@ def run() -> None: # noqa: C901 for key in path_keys: value = getattr(sys, key, None) value = encode_list_path(value) if isinstance(value, list) else encode_path(value) - result["sys"][key] = value - result["sys"]["fs_encoding"] = sys.getfilesystemencoding() - result["sys"]["io_encoding"] = getattr(sys.stdout, "encoding", None) + sys_info[key] = value + sys_info["fs_encoding"] = sys.getfilesystemencoding() + sys_info["io_encoding"] = getattr(sys.stdout, "encoding", None) result["version"] = sys.version try: diff --git a/src/virtualenv/create/via_global_ref/builtin/cpython/cpython3.py b/src/virtualenv/create/via_global_ref/builtin/cpython/cpython3.py index f66f284..bb68d88 100644 --- a/src/virtualenv/create/via_global_ref/builtin/cpython/cpython3.py +++ b/src/virtualenv/create/via_global_ref/builtin/cpython/cpython3.py @@ -2,7 +2,6 @@ from __future__ import annotations import abc import fnmatch -from itertools import chain from operator import methodcaller as method from pathlib import Path from textwrap import dedent @@ -100,15 +99,10 @@ class CPython3Windows(CPythonWindows, CPython3): @classmethod def sources(cls, interpreter: PythonInfo) -> Generator[PathRef]: # ty: ignore[invalid-method-override] - if cls.has_shim(interpreter): - refs = cls.executables(interpreter) - else: - refs = chain( - cls.executables(interpreter), # ty: ignore[invalid-argument-type] - cls.dll_and_pyd(interpreter), - cls.python_zip(interpreter), - ) - yield from refs + yield from cls.executables(interpreter) + if not cls.has_shim(interpreter): + yield from cls.dll_and_pyd(interpreter) + yield from cls.python_zip(interpreter) @classmethod def executables(cls, interpreter: PythonInfo) -> list[PathRef] | Generator[PathRef]: diff --git a/src/virtualenv/create/via_global_ref/builtin/graalpy/__init__.py b/src/virtualenv/create/via_global_ref/builtin/graalpy/__init__.py index d949a33..33bb22d 100644 --- a/src/virtualenv/create/via_global_ref/builtin/graalpy/__init__.py +++ b/src/virtualenv/create/via_global_ref/builtin/graalpy/__init__.py @@ -39,7 +39,7 @@ class GraalPy(ViaGlobalRefVirtualenvBuiltin, ABC): } @classmethod - def _executables(cls, interpreter: PythonInfo) -> Generator[tuple[Path, list[str], RefMust, RefWhen]]: # ty: ignore[invalid-method-override] + def _executables(cls, interpreter: PythonInfo) -> Generator[tuple[Path, list[str], RefMust, RefWhen], None, None]: # ty: ignore[invalid-method-override] host = Path(interpreter.system_executable) # ty: ignore[invalid-argument-type] targets = sorted(f"{name}{cls.suffix}" for name in cls.exe_names(interpreter)) yield host, targets, RefMust.NA, RefWhen.ANY diff --git a/src/virtualenv/create/via_global_ref/builtin/ref.py b/src/virtualenv/create/via_global_ref/builtin/ref.py index 767c2a2..f7ac090 100644 --- a/src/virtualenv/create/via_global_ref/builtin/ref.py +++ b/src/virtualenv/create/via_global_ref/builtin/ref.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import sys from abc import ABC, abstractmethod from collections import OrderedDict from stat import S_IXGRP, S_IXOTH, S_IXUSR @@ -15,14 +16,22 @@ if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path +if sys.version_info >= (3, 11): # pragma: no cover (py311+) + from enum import StrEnum +else: # pragma: no cover (py311+) + from enum import Enum -class RefMust: + class StrEnum(str, Enum): + pass + + +class RefMust(StrEnum): NA = "NA" COPY = "copy" SYMLINK = "symlink" -class RefWhen: +class RefWhen(StrEnum): ANY = "ANY" COPY = "copy" SYMLINK = "symlink" diff --git a/src/virtualenv/create/via_global_ref/builtin/rustpython/__init__.py b/src/virtualenv/create/via_global_ref/builtin/rustpython/__init__.py index 8995338..dbeb1c2 100644 --- a/src/virtualenv/create/via_global_ref/builtin/rustpython/__init__.py +++ b/src/virtualenv/create/via_global_ref/builtin/rustpython/__init__.py @@ -33,7 +33,7 @@ class RustPython(ViaGlobalRefVirtualenvBuiltin, Python3Supports, ABC): } @classmethod - def _executables(cls, interpreter: PythonInfo) -> Generator[tuple[Path, list[str], RefMust, RefWhen]]: # ty: ignore[invalid-method-override] + def _executables(cls, interpreter: PythonInfo) -> Generator[tuple[Path, list[str], RefMust, RefWhen], None, None]: # ty: ignore[invalid-method-override] host = Path(interpreter.system_executable) # ty: ignore[invalid-argument-type] targets = sorted(f"{name}{cls.suffix}" for name in cls.exe_names(interpreter)) yield host, targets, RefMust.NA, RefWhen.ANY diff --git a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py index 89348aa..2c56e4d 100644 --- a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +++ b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py @@ -1,7 +1,9 @@ from __future__ import annotations import logging +import ntpath import os +import posixpath import re import zipfile from abc import ABC, abstractmethod @@ -21,6 +23,24 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) +def _safe_extract_zip(zip_ref: zipfile.ZipFile, target_dir: Path) -> None: + # Guard against zip slip: a wheel is a zip and a tampered entry name (absolute path or one containing ``..``) + # could escape ``target_dir``. + base = target_dir.resolve() + for info in zip_ref.infolist(): + name = info.filename + if name.startswith(("/", "\\")) or ntpath.isabs(name) or posixpath.isabs(name): + msg = f"refusing to extract absolute path entry from wheel: {name!r}" + raise RuntimeError(msg) + candidate = (base / name).resolve() + try: + candidate.relative_to(base) + except ValueError as exc: + msg = f"refusing to extract entry escaping target directory: {name!r}" + raise RuntimeError(msg) from exc + zip_ref.extractall(str(target_dir)) + + class PipInstall(ABC): def __init__(self, wheel: Path, creator: Creator, image_folder: Path) -> None: self._wheel = wheel @@ -49,11 +69,19 @@ class PipInstall(ABC): LOGGER.debug("generated console scripts %s", " ".join(i.name for i in consoles)) def build_image(self) -> None: + """Extract the seed wheel into the image directory and fix up its RECORD file. + + Each archive entry is validated before extraction so a tampered wheel cannot escape the image directory via an + absolute path or ``..`` traversal. + + :raises RuntimeError: if the wheel contains an entry that would land outside the image directory. + + """ # 1. first extract the wheel LOGGER.debug("build install image for %s to %s", self._wheel.name, self._image_dir) with zipfile.ZipFile(str(self._wheel)) as zip_ref: self._shorten_path_if_needed(zip_ref) - zip_ref.extractall(str(self._image_dir)) + _safe_extract_zip(zip_ref, self._image_dir) self._extracted = True # 2. now add additional files not present in the distribution new_files = self._generate_new_files() diff --git a/src/virtualenv/seed/wheels/acquire.py b/src/virtualenv/seed/wheels/acquire.py index b6a6533..63f5376 100644 --- a/src/virtualenv/seed/wheels/acquire.py +++ b/src/virtualenv/seed/wheels/acquire.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import re import sys from operator import eq, lt from pathlib import Path @@ -18,6 +19,34 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) +# PEP 503 normalized distribution name. Anything outside this character set on the way to ``pip download`` means +# somebody is smuggling pip options or extras, so reject it before we build the command line. +_DISTRIBUTION_RE = re.compile( + r""" + ^ + (?P<name> + [A-Za-z0-9] # must start with an alnum + (?:[A-Za-z0-9._-]* # inner chars: alnum plus . _ - + [A-Za-z0-9])? # must also end with an alnum (unless length is 1) + ) + $ + """, + re.VERBOSE, +) + +# Version specifier that matches what ``Version.as_version_spec`` emits: either empty, ``==<ver>`` or ``<<ver>`` where +# ``<ver>`` is a subset of PEP 440 public versions. Kept deliberately strict so a crafted version cannot inject pip +# flags. +_VERSION_SPEC_RE = re.compile( + r""" + ^ + (?P<operator>==|<) # only the operators Version.as_version_spec can emit + (?P<version>[A-Za-z0-9._+!-]+) # PEP 440 public-version character set, no whitespace + $ + """, + re.VERBOSE, +) + def get_wheel( # noqa: PLR0913 distribution: str, @@ -63,6 +92,26 @@ def download_wheel( # noqa: PLR0913 to_folder: Path, env: dict[str, str], ) -> Wheel: + """Invoke ``pip download`` in a subprocess to fetch a seed wheel. + + :param distribution: PEP 503 normalized project name; rejected if it contains anything other than + ``[A-Za-z0-9._-]``. + :param version_spec: optional version specifier of the form ``==<ver>`` or ``<<ver>`` as emitted by + :func:`Version.as_version_spec`, or ``None``/empty for the latest compatible release. + :param for_py_version: major.minor Python version to pass through to ``pip --python-version``. + :param search_dirs: additional directories to treat as a local wheel index when bootstrapping pip. + :param app_data: application data store used to locate the embedded pip wheel. + :param to_folder: directory the downloaded wheel is written into. + :param env: environment mapping passed through to the subprocess. + + :returns: the downloaded :class:`Wheel`. + + :raises ValueError: if ``distribution`` or ``version_spec`` fail the strict allow-list check. + :raises CalledProcessError: if ``pip download`` exits with a non-zero status. + + """ + _check_distribution(distribution) + _check_version_spec(version_spec) to_download = f"{distribution}{version_spec or ''}" LOGGER.debug("download wheel %s %s to %s", to_download, for_py_version, to_folder) cmd = [ @@ -143,6 +192,20 @@ def pip_wheel_env_run(search_dirs: list[Path], app_data: AppData, env: dict[str, return env +def _check_distribution(distribution: str) -> None: + if not _DISTRIBUTION_RE.fullmatch(distribution): + msg = f"refusing to download wheel for suspicious distribution name: {distribution!r}" + raise ValueError(msg) + + +def _check_version_spec(version_spec: str | None) -> None: + if not version_spec: + return + if not _VERSION_SPEC_RE.fullmatch(version_spec): + msg = f"refusing to download wheel with suspicious version spec: {version_spec!r}" + raise ValueError(msg) + + __all__ = [ "download_wheel", "get_wheel", diff --git a/src/virtualenv/seed/wheels/embed/__init__.py b/src/virtualenv/seed/wheels/embed/__init__.py index f9947a1..df58d6d 100644 --- a/src/virtualenv/seed/wheels/embed/__init__.py +++ b/src/virtualenv/seed/wheels/embed/__init__.py @@ -1,7 +1,10 @@ from __future__ import annotations +import hashlib +import zipfile from pathlib import Path +from virtualenv.info import IS_ZIPAPP, ROOT from virtualenv.seed.wheels.util import Wheel BUNDLE_FOLDER = Path(__file__).absolute().parent @@ -13,47 +16,103 @@ BUNDLE_SUPPORT = { }, "3.9": { "pip": "pip-26.0.1-py3-none-any.whl", - "setuptools": "setuptools-82.0.0-py3-none-any.whl", + "setuptools": "setuptools-82.0.1-py3-none-any.whl", }, "3.10": { "pip": "pip-26.0.1-py3-none-any.whl", - "setuptools": "setuptools-82.0.0-py3-none-any.whl", + "setuptools": "setuptools-82.0.1-py3-none-any.whl", }, "3.11": { "pip": "pip-26.0.1-py3-none-any.whl", - "setuptools": "setuptools-82.0.0-py3-none-any.whl", + "setuptools": "setuptools-82.0.1-py3-none-any.whl", }, "3.12": { "pip": "pip-26.0.1-py3-none-any.whl", - "setuptools": "setuptools-82.0.0-py3-none-any.whl", + "setuptools": "setuptools-82.0.1-py3-none-any.whl", }, "3.13": { "pip": "pip-26.0.1-py3-none-any.whl", - "setuptools": "setuptools-82.0.0-py3-none-any.whl", + "setuptools": "setuptools-82.0.1-py3-none-any.whl", }, "3.14": { "pip": "pip-26.0.1-py3-none-any.whl", - "setuptools": "setuptools-82.0.0-py3-none-any.whl", + "setuptools": "setuptools-82.0.1-py3-none-any.whl", }, "3.15": { "pip": "pip-26.0.1-py3-none-any.whl", - "setuptools": "setuptools-82.0.0-py3-none-any.whl", + "setuptools": "setuptools-82.0.1-py3-none-any.whl", }, } MAX = "3.8" +# SHA-256 of every bundled wheel. Verified on load so a corrupted or tampered wheel on disk fails loud instead of +# being handed to pip. Generated together with ``BUNDLE_SUPPORT`` by ``tasks/upgrade_wheels.py``. +BUNDLE_SHA256 = { + "pip-25.0.1-py3-none-any.whl": "c46efd13b6aa8279f33f2864459c8ce587ea6a1a59ee20de055868d8f7688f7f", + "pip-26.0.1-py3-none-any.whl": "bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", + "setuptools-75.3.4-py3-none-any.whl": "2dd50a7f42dddfa1d02a36f275dbe716f38ed250224f609d35fb60a09593d93e", + "setuptools-82.0.1-py3-none-any.whl": "a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", + "wheel-0.45.1-py3-none-any.whl": "708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", +} + +_VERIFIED_WHEELS: set[str] = set() + def get_embed_wheel(distribution: str, for_py_version: str) -> Wheel | None: + """Return the bundled wheel that ships with virtualenv for a given distribution and Python version. + + :param distribution: project name of the seed package, for example ``pip`` or ``setuptools``. + :param for_py_version: major.minor Python version string the environment will be created for. + + :returns: a :class:`Wheel` pointing at the verified bundled file, or ``None`` when no wheel is bundled for the + requested combination. + + :raises RuntimeError: if the bundled wheel on disk fails SHA-256 verification. + + """ mapping = BUNDLE_SUPPORT.get(for_py_version, {}) or BUNDLE_SUPPORT[MAX] wheel_file = mapping.get(distribution) if wheel_file is None: return None path = BUNDLE_FOLDER / wheel_file + _verify_bundled_wheel(path) return Wheel.from_path(path) +def _verify_bundled_wheel(path: Path) -> None: + name = path.name + if name in _VERIFIED_WHEELS: + return + expected = BUNDLE_SHA256.get(name) + if expected is None: + msg = f"bundled wheel {name} has no recorded sha256 in BUNDLE_SHA256" + raise RuntimeError(msg) + actual = _hash_bundled_wheel(path) + if actual != expected: + msg = f"bundled wheel {name} sha256 mismatch: expected {expected}, got {actual}" + raise RuntimeError(msg) + _VERIFIED_WHEELS.add(name) + + +def _hash_bundled_wheel(path: Path) -> str: + # ``path`` is under the package directory; when virtualenv runs from a zipapp the wheel lives inside the + # archive and cannot be opened as a regular file, so read the bytes straight from the zipapp entry. + digest = hashlib.sha256() + if IS_ZIPAPP: + entry = path.resolve().relative_to(Path(ROOT).resolve()).as_posix() + with zipfile.ZipFile(ROOT, "r") as archive, archive.open(entry) as stream: + for chunk in iter(lambda: stream.read(1 << 20), b""): + digest.update(chunk) + else: + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + __all__ = [ "BUNDLE_FOLDER", + "BUNDLE_SHA256", "BUNDLE_SUPPORT", "MAX", "get_embed_wheel", diff --git a/src/virtualenv/seed/wheels/periodic_update.py b/src/virtualenv/seed/wheels/periodic_update.py index 57193ee..aa3a486 100644 --- a/src/virtualenv/seed/wheels/periodic_update.py +++ b/src/virtualenv/seed/wheels/periodic_update.py @@ -360,10 +360,20 @@ def release_date_for_wheel_path(dest: Path) -> datetime | None: return None +#: Opt-in escape hatch to restore the pre-2026 behavior of falling back to an unverified HTTPS context when the +#: verified request fails. Off by default: a failed TLS handshake on the PyPI metadata lookup now aborts the update +#: instead of silently downgrading, because the response drives which wheel version virtualenv thinks is up to date. +_INSECURE_FALLBACK_ENV = "VIRTUALENV_PERIODIC_UPDATE_INSECURE" + + def _request_context() -> Generator[ssl.SSLContext | None, None, None]: yield None - # fallback to non verified HTTPS (the information we request is not sensitive, so fallback) - yield ssl._create_unverified_context() # noqa: S323, SLF001 + if os.environ.get(_INSECURE_FALLBACK_ENV): + LOGGER.warning( + "falling back to unverified HTTPS for PyPI metadata because %s is set", + _INSECURE_FALLBACK_ENV, + ) + yield ssl._create_unverified_context() # noqa: S323, SLF001 _PYPI_CACHE = {} diff --git a/src/virtualenv/util/zipapp.py b/src/virtualenv/util/zipapp.py index 3e59134..c5f94ec 100644 --- a/src/virtualenv/util/zipapp.py +++ b/src/virtualenv/util/zipapp.py @@ -1,14 +1,10 @@ from __future__ import annotations import logging -import os import zipfile -from typing import TYPE_CHECKING +from pathlib import Path -from virtualenv.info import IS_WIN, ROOT - -if TYPE_CHECKING: - from pathlib import Path +from virtualenv.info import ROOT LOGGER = logging.getLogger(__name__) @@ -29,16 +25,18 @@ def extract(full_path: str | Path, dest: Path) -> None: def _get_path_within_zip(full_path: str | Path) -> str: - full_path = os.path.realpath(os.path.abspath(str(full_path))) - prefix = f"{ROOT}{os.sep}" - if not full_path.startswith(prefix): - msg = f"full_path={full_path} should start with prefix={prefix}." - raise RuntimeError(msg) - sub_file = full_path[len(prefix) :] - if IS_WIN: - # paths are always UNIX separators, even on Windows, though __file__ still follows platform default - sub_file = sub_file.replace(os.sep, "/") - return sub_file + # Use Path.relative_to so symlinks and ``..`` segments cannot slip through a string ``startswith`` check. The zipapp + # root is a real file we own so ``resolve`` is safe; anything that does not resolve under ROOT is a bug or an + # attempt to escape the archive and we refuse it. + resolved = Path(full_path).resolve() + root = Path(ROOT).resolve() + try: + relative = resolved.relative_to(root) + except ValueError as exc: + msg = f"full_path={resolved} should be within ROOT={root}" + raise RuntimeError(msg) from exc + # Zip entries always use forward slashes regardless of platform. + return relative.as_posix() __all__ = [ diff --git a/src/virtualenv/version.py b/src/virtualenv/version.py index a47ab51..d5a31ce 100644 --- a/src/virtualenv/version.py +++ b/src/virtualenv/version.py @@ -1,5 +1,6 @@ -# file generated by setuptools-scm +# file generated by vcs-versioning # don't change, don't track in version control +from __future__ import annotations __all__ = [ "__version__", @@ -10,25 +11,14 @@ __all__ = [ "commit_id", ] -TYPE_CHECKING = False -if TYPE_CHECKING: - from typing import Tuple - from typing import Union - - VERSION_TUPLE = Tuple[Union[int, str], ...] - COMMIT_ID = Union[str, None] -else: - VERSION_TUPLE = object - COMMIT_ID = object - version: str __version__: str -__version_tuple__: VERSION_TUPLE -version_tuple: VERSION_TUPLE -commit_id: COMMIT_ID -__commit_id__: COMMIT_ID +__version_tuple__: tuple[int | str, ...] +version_tuple: tuple[int | str, ...] +commit_id: str | None +__commit_id__: str | None -__version__ = version = '21.2.0' -__version_tuple__ = version_tuple = (21, 2, 0) +__version__ = version = '21.2.4' +__version_tuple__ = version_tuple = (21, 2, 4) __commit_id__ = commit_id = None diff --git a/tasks/upgrade_wheels.py b/tasks/upgrade_wheels.py index 881a18e..2fa890b 100644 --- a/tasks/upgrade_wheels.py +++ b/tasks/upgrade_wheels.py @@ -2,6 +2,8 @@ from __future__ import annotations +import ast +import hashlib import os import shutil import subprocess @@ -20,122 +22,207 @@ SUPPORT = [(3, i) for i in range(8, 16)] DEST = Path(__file__).resolve().parents[1] / "src" / "virtualenv" / "seed" / "wheels" / "embed" -def download(ver: str, dest: str, package: str) -> None: - subprocess.call( - [ - sys.executable, - "-W", - "ignore::EncodingWarning", - "-m", - "pip", - "--disable-pip-version-check", - "download", - "--no-cache-dir", - "--only-binary=:all:", - "--python-version", - ver, - "-d", - dest, - package, - ], - ) - - -def run() -> NoReturn: # noqa: C901, PLR0912 +def run() -> NoReturn: + if "--regen" in sys.argv[1:]: + render_init() + raise SystemExit(0) old_batch = {i.name for i in DEST.iterdir() if i.suffix == ".whl"} with TemporaryDirectory() as temp: - temp_path = Path(temp) - folders = {} - targets = [] - for support in SUPPORT: - support_ver = ".".join(str(i) for i in support) - into = temp_path / support_ver - into.mkdir() - folders[into] = support_ver - for package in BUNDLED: - if package == "wheel" and support >= (3, 9): - continue - thread = Thread(target=download, args=(support_ver, str(into), package)) - targets.append(thread) - thread.start() - for thread in targets: - thread.join() + folders = _download_all(Path(temp)) new_batch = {i.name: i for f in folders for i in Path(f).iterdir()} - new_packages = new_batch.keys() - old_batch remove_packages = old_batch - new_batch.keys() - - for package in remove_packages: - (DEST / package).unlink() - for package in new_packages: - shutil.copy2(str(new_batch[package]), DEST / package) - + _sync_dest(new_packages, remove_packages, new_batch) added = collect_package_versions(new_packages) removed = collect_package_versions(remove_packages) outcome = (1 if STRICT else 0) if (added or removed) else 0 print(f"Outcome {outcome} added {added} removed {removed}") # noqa: T201 - lines = ["Upgrade embedded wheels:", ""] - for key, versions in added.items(): - text = f"* {key} to {fmt_version(versions)}" - if key in removed: - rem = ", ".join(f"``{i}``" for i in removed[key]) - text += f" from {rem}" - del removed[key] - lines.append(text) - for key, versions in removed.items(): - lines.append(f"Removed {key} of {fmt_version(versions)}") - lines.append("") - changelog = "\n".join(lines) - print(changelog) # noqa: T201 - if len(lines) >= 4: # noqa: PLR2004 - (Path(__file__).parents[1] / "docs" / "changelog" / "u.bugfix.rst").write_text(changelog, encoding="utf-8") + _write_changelog(added, removed) + render_init(folders=folders) + raise SystemExit(outcome) + + +def _download_all(temp_path: Path) -> dict[Path, str]: + folders: dict[Path, str] = {} + targets: list[Thread] = [] + for support in SUPPORT: + support_ver = ".".join(str(i) for i in support) + into = temp_path / support_ver + into.mkdir() + folders[into] = support_ver + for package in BUNDLED: + if package == "wheel" and support >= (3, 9): + continue + thread = Thread(target=download, args=(support_ver, str(into), package)) + targets.append(thread) + thread.start() + for thread in targets: + thread.join() + return folders + + +def _sync_dest(new_packages: set[str], remove_packages: set[str], new_batch: dict[str, Path]) -> None: + for package in remove_packages: + (DEST / package).unlink() + for package in new_packages: + shutil.copy2(str(new_batch[package]), DEST / package) + + +def _write_changelog(added: dict[str, list[str]], removed: dict[str, list[str]]) -> None: + lines = ["Upgrade embedded wheels:", ""] + for key, versions in added.items(): + text = f"* {key} to {fmt_version(versions)}" + if key in removed: + rem = ", ".join(f"``{i}``" for i in removed[key]) + text += f" from {rem}" + del removed[key] + lines.append(text) + for key, versions in removed.items(): + lines.append(f"Removed {key} of {fmt_version(versions)}") + lines.append("") + changelog = "\n".join(lines) + print(changelog) # noqa: T201 + if len(lines) >= 4: # noqa: PLR2004 + (Path(__file__).parents[1] / "docs" / "changelog" / "u.bugfix.rst").write_text(changelog, encoding="utf-8") + + +def render_init(folders: dict[Path, str] | None = None) -> None: + """Write ``embed/__init__.py`` from the wheels currently in DEST. + + When called from ``run()`` after a download round, ``folders`` maps each per-python-version temp folder to its + version string, which is how support for a wheel is determined. When called with ``--regen`` there are no downloaded + folders — the existing ``BUNDLE_SUPPORT`` from the current ``__init__.py`` is used so regeneration is deterministic. + + """ + if folders is None: + support_table = _support_table_from_existing_init() + else: + present = {i.name: i for f in folders for i in Path(f).iterdir() if i.suffix == ".whl"} support_table = OrderedDict((".".join(str(j) for j in i), []) for i in SUPPORT) - for package in sorted(new_batch.keys()): + for package in sorted(present): for folder, version in sorted(folders.items()): if (folder / package).exists(): support_table[version].append(package) - support_table = {k: OrderedDict((i.split("-")[0], i) for i in v) for k, v in support_table.items()} - nl = "\n" - bundle = "".join( - f"\n {v!r}: {{{nl}{''.join(f' {p!r}: {f!r},{nl}' for p, f in line.items())} }}," - for v, line in support_table.items() - ) - msg = dedent( - f""" - from __future__ import annotations - - from pathlib import Path - - from virtualenv.seed.wheels.util import Wheel - - BUNDLE_FOLDER = Path(__file__).absolute().parent - BUNDLE_SUPPORT = {{ {bundle} }} - MAX = {next(iter(support_table.keys()))!r} - - - def get_embed_wheel(distribution: str, for_py_version: str) -> Wheel | None: - mapping = BUNDLE_SUPPORT.get(for_py_version, {{}}) or BUNDLE_SUPPORT[MAX] - wheel_file = mapping.get(distribution) - if wheel_file is None: - return None - path = BUNDLE_FOLDER / wheel_file - return Wheel.from_path(path) - - __all__ = [ - "BUNDLE_FOLDER", - "BUNDLE_SUPPORT", - "MAX", - "get_embed_wheel", - ] - - """, - ) - dest_target = DEST / "__init__.py" - dest_target.write_text(msg, encoding="utf-8") - subprocess.run([sys.executable, "-m", "ruff", "check", str(dest_target), "--fix", "--unsafe-fixes"]) - subprocess.run([sys.executable, "-m", "ruff", "format", str(dest_target), "--preview"]) + support_table = OrderedDict((k, OrderedDict((i.split("-")[0], i) for i in v)) for k, v in support_table.items()) + wheel_names = sorted({wheel for mapping in support_table.values() for wheel in mapping.values()}) + sha_table = OrderedDict((name, _sha256(DEST / name)) for name in wheel_names) + nl = "\n" + bundle = "".join( + f"\n {v!r}: {{{nl}{''.join(f' {p!r}: {f!r},{nl}' for p, f in line.items())} }}," + for v, line in support_table.items() + ) + sha_block = "".join(f"\n {name!r}: {digest!r}," for name, digest in sha_table.items()) + msg = dedent( + f""" + from __future__ import annotations - raise SystemExit(outcome) + import hashlib + import zipfile + from pathlib import Path + + from virtualenv.info import IS_ZIPAPP, ROOT + from virtualenv.seed.wheels.util import Wheel + + BUNDLE_FOLDER = Path(__file__).absolute().parent + BUNDLE_SUPPORT = {{ {bundle} }} + MAX = {next(iter(support_table.keys()))!r} + + # SHA-256 of every bundled wheel. Verified on load so a corrupted or tampered wheel on disk fails loud instead of + # being handed to pip. Generated together with ``BUNDLE_SUPPORT`` by ``tasks/upgrade_wheels.py``. + BUNDLE_SHA256 = {{ {sha_block} }} + + _VERIFIED_WHEELS: set[str] = set() + + + def get_embed_wheel(distribution: str, for_py_version: str) -> Wheel | None: + \"\"\"Return the bundled wheel that ships with virtualenv for a given distribution and Python version. + + :param distribution: project name of the seed package, for example ``pip`` or ``setuptools``. + :param for_py_version: major.minor Python version string the environment will be created for. + + :returns: a :class:`Wheel` pointing at the verified bundled file, or ``None`` when no wheel is bundled for the + requested combination. + + :raises RuntimeError: if the bundled wheel on disk fails SHA-256 verification. + + \"\"\" + mapping = BUNDLE_SUPPORT.get(for_py_version, {{}}) or BUNDLE_SUPPORT[MAX] + wheel_file = mapping.get(distribution) + if wheel_file is None: + return None + path = BUNDLE_FOLDER / wheel_file + _verify_bundled_wheel(path) + return Wheel.from_path(path) + + + def _verify_bundled_wheel(path: Path) -> None: + name = path.name + if name in _VERIFIED_WHEELS: + return + expected = BUNDLE_SHA256.get(name) + if expected is None: + msg = f"bundled wheel {{name}} has no recorded sha256 in BUNDLE_SHA256" + raise RuntimeError(msg) + actual = _hash_bundled_wheel(path) + if actual != expected: + msg = f"bundled wheel {{name}} sha256 mismatch: expected {{expected}}, got {{actual}}" + raise RuntimeError(msg) + _VERIFIED_WHEELS.add(name) + + + def _hash_bundled_wheel(path: Path) -> str: + # ``path`` is under the package directory; when virtualenv runs from a zipapp the wheel lives inside the + # archive and cannot be opened as a regular file, so read the bytes straight from the zipapp entry. + digest = hashlib.sha256() + if IS_ZIPAPP: + entry = path.resolve().relative_to(Path(ROOT).resolve()).as_posix() + with zipfile.ZipFile(ROOT, "r") as archive, archive.open(entry) as stream: + for chunk in iter(lambda: stream.read(1 << 20), b""): + digest.update(chunk) + else: + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + + __all__ = [ + "BUNDLE_FOLDER", + "BUNDLE_SHA256", + "BUNDLE_SUPPORT", + "MAX", + "get_embed_wheel", + ] + + """, + ) + dest_target = DEST / "__init__.py" + dest_target.write_text(msg, encoding="utf-8") + subprocess.run([sys.executable, "-m", "ruff", "check", str(dest_target), "--fix", "--unsafe-fixes"], check=False) + subprocess.run([sys.executable, "-m", "ruff", "format", str(dest_target), "--preview"], check=False) + + +def _support_table_from_existing_init() -> OrderedDict[str, OrderedDict[str, str]]: + source = (DEST / "__init__.py").read_text(encoding="utf-8") + tree = ast.parse(source) + for node in tree.body: + if isinstance(node, ast.Assign) and any( + isinstance(t, ast.Name) and t.id == "BUNDLE_SUPPORT" for t in node.targets + ): + bundle_support = ast.literal_eval(node.value) + return OrderedDict( + (version, OrderedDict(sorted(mapping.items()))) for version, mapping in bundle_support.items() + ) + msg = f"BUNDLE_SUPPORT not found in {DEST / '__init__.py'}" + raise RuntimeError(msg) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() def fmt_version(versions: list[str]) -> str: @@ -153,5 +240,26 @@ def collect_package_versions(new_packages: set[str]) -> dict[str, list[str]]: return result +def download(python_version: str, dest: str, package: str) -> None: + subprocess.call( + [ + sys.executable, + "-W", + "ignore::EncodingWarning", + "-m", + "pip", + "--disable-pip-version-check", + "download", + "--no-cache-dir", + "--only-binary=:all:", + "--python-version", + python_version, + "-d", + dest, + package, + ], + ) + + if __name__ == "__main__": run() diff --git a/tests/conftest.py b/tests/conftest.py index e3fd61f..3ec4147 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -153,7 +153,7 @@ def _check_os_environ_stable(): old = os.environ.copy() # ensure we don't inherit parent env variables to_clean = {k for k in os.environ if k.startswith(("VIRTUALENV_", "TOX_")) or "VIRTUAL_ENV" in k} - cleaned = {k: os.environ[k] for k, v in os.environ.items()} + cleaned = {k: os.environ[k] for k in os.environ} override = { "VIRTUALENV_NO_PERIODIC_UPDATE": "1", "VIRTUALENV_NO_DOWNLOAD": "1", diff --git a/tests/unit/activation/test_nushell.py b/tests/unit/activation/test_nushell.py index 284de52..e7cb630 100644 --- a/tests/unit/activation/test_nushell.py +++ b/tests/unit/activation/test_nushell.py @@ -1,10 +1,18 @@ from __future__ import annotations +import subprocess from argparse import Namespace from shutil import which +from typing import TYPE_CHECKING + +import pytest from virtualenv.activation import NushellActivator from virtualenv.info import IS_WIN +from virtualenv.run import cli_run + +if TYPE_CHECKING: + from pathlib import Path def test_nushell_tkinter_generation(tmp_path) -> None: @@ -47,6 +55,16 @@ def test_nushell_tkinter_generation(tmp_path) -> None: assert expected_tcl in content assert expected_tk in content + # overlay hide is a parser keyword: a def body would fail at parse time because the overlay doesn't exist yet + # when the def is compiled. The alias defers that check to call time, when the overlay is active. + assert "export alias deactivate = overlay hide activate" in content + # nushell shows one line of context before the error site, so placing the hint comment directly above the alias + # makes it appear in the error output users see when they activate via `use *` or a custom name (gh-3103). + lines = content.splitlines() + alias_idx = next(i for i, line in enumerate(lines) if "export alias deactivate" in line) + assert alias_idx > 0 + assert "overlay use activate.nu" in lines[alias_idx - 1] + def test_nushell(activation_tester_class, activation_tester) -> None: class Nushell(activation_tester_class): @@ -66,3 +84,33 @@ def test_nushell(activation_tester_class, activation_tester) -> None: return f"{cmd} {scr}".strip() activation_tester(Nushell) + + +def test_nushell_deactivate_errors(tmp_path: Path) -> None: + """Regression for gh-3103: both misuse patterns give actionable inline errors. + + `^nu | complete` captures stderr from child invocations without aborting the outer script, so both cases run in a + single subprocess call. + + """ + nu = which("nu") + if nu is None: + pytest.skip("nu not installed") + + activate_nu = cli_run(["--without-pip", str(tmp_path / "venv")]).creator.bin_dir / "activate.nu" + quoted = NushellActivator.quote(str(activate_nu)) + + # `to nuon` re-quotes the path so it remains valid nushell syntax after string interpolation. + script = f"""\ +let path = {quoted} +let r1 = (^nu --commands $"use ($path | to nuon) *; deactivate" | complete) +if ($r1.exit_code == 0) {{ error make {{ msg: "expected deactivate to fail for use-star" }} }} +if not ("not an active overlay" in $r1.stderr) {{ error make {{ msg: "overlay error missing" }} }} +if not ("overlay use activate.nu" in $r1.stderr) {{ error make {{ msg: "hint missing for use-star" }} }} +let r2 = (^nu --commands $"overlay use ($path | to nuon) as myenv; deactivate" | complete) +if ($r2.exit_code == 0) {{ error make {{ msg: "expected deactivate to fail for custom name" }} }} +if not ("not an active overlay" in $r2.stderr) {{ error make {{ msg: "overlay error missing" }} }} +if not ("overlay hide NAME" in $r2.stderr) {{ error make {{ msg: "hint missing for custom name" }} }} +""" + result = subprocess.run([nu, "--commands", script], capture_output=True, text=True, timeout=60, check=False) + assert result.returncode == 0, result.stderr diff --git a/tests/unit/config/cli/test_help_formatter.py b/tests/unit/config/cli/test_help_formatter.py new file mode 100644 index 0000000..96f2bad --- /dev/null +++ b/tests/unit/config/cli/test_help_formatter.py @@ -0,0 +1,27 @@ +"""Tests for HelpFormatter terminal width behavior.""" + +from __future__ import annotations + +import os +from unittest.mock import patch + +import pytest + +from virtualenv.config.cli.parser import HelpFormatter + + +@pytest.mark.parametrize("width", [80, 120, 200]) +def test_help_formatter_uses_terminal_width(width: int) -> None: + """HelpFormatter should use shutil.get_terminal_size() for width.""" + with patch("virtualenv.config.cli.parser.shutil.get_terminal_size") as mock_size: + mock_size.return_value = os.terminal_size((width, 24)) + formatter = HelpFormatter("test_prog") + assert formatter._width == width # noqa: SLF001 + + +def test_help_formatter_not_hardcoded_240() -> None: + """HelpFormatter width should not be hardcoded to 240.""" + with patch("virtualenv.config.cli.parser.shutil.get_terminal_size") as mock_size: + mock_size.return_value = os.terminal_size((80, 24)) + formatter = HelpFormatter("test_prog") + assert formatter._width != 240 # noqa: SLF001 diff --git a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py index 502f32a..99b6cef 100644 --- a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py +++ b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib import os import sys +import zipfile from stat import S_IWGRP, S_IWOTH, S_IWUSR from subprocess import Popen, check_call from threading import Thread @@ -14,6 +15,7 @@ from python_discovery import _cached_py_info as cached_py_info from virtualenv.info import fs_supports_symlink from virtualenv.run import cli_run +from virtualenv.seed.embed.via_app_data.pip_install.base import _safe_extract_zip from virtualenv.seed.wheels.embed import BUNDLE_FOLDER, BUNDLE_SUPPORT from virtualenv.util.path import safe_delete @@ -262,3 +264,52 @@ def _run_parallel_threads(tmp_path): for thread in threads: thread.join() return exceptions + + +def _write_zip_with_entry(zip_path: Path, entry_name: str) -> None: + with zipfile.ZipFile(str(zip_path), "w") as archive: + archive.writestr(entry_name, "payload") + + +def test_safe_extract_zip_allows_normal_entry(tmp_path: Path) -> None: + archive = tmp_path / "ok.zip" + _write_zip_with_entry(archive, "pkg/inner.txt") + target = tmp_path / "out" + target.mkdir() + + with zipfile.ZipFile(str(archive)) as zip_ref: + _safe_extract_zip(zip_ref, target) + + assert (target / "pkg" / "inner.txt").read_text() == "payload" + + +def test_safe_extract_zip_rejects_parent_traversal(tmp_path: Path) -> None: + archive = tmp_path / "evil.zip" + _write_zip_with_entry(archive, "../escape.txt") + target = tmp_path / "out" + target.mkdir() + + with zipfile.ZipFile(str(archive)) as zip_ref, pytest.raises(RuntimeError, match="escaping target directory"): + _safe_extract_zip(zip_ref, target) + + assert not (tmp_path / "escape.txt").exists() + + +def test_safe_extract_zip_rejects_absolute_posix_entry(tmp_path: Path) -> None: + archive = tmp_path / "abs.zip" + _write_zip_with_entry(archive, "/tmp/evil.txt") # noqa: S108 + target = tmp_path / "out" + target.mkdir() + + with zipfile.ZipFile(str(archive)) as zip_ref, pytest.raises(RuntimeError, match="absolute path"): + _safe_extract_zip(zip_ref, target) + + +def test_safe_extract_zip_rejects_absolute_windows_entry(tmp_path: Path) -> None: + archive = tmp_path / "win.zip" + _write_zip_with_entry(archive, "C:/Windows/System32/evil.txt") + target = tmp_path / "out" + target.mkdir() + + with zipfile.ZipFile(str(archive)) as zip_ref, pytest.raises(RuntimeError, match="absolute path"): + _safe_extract_zip(zip_ref, target) diff --git a/tests/unit/seed/wheels/test_acquire.py b/tests/unit/seed/wheels/test_acquire.py index 7754a8c..f0a2393 100644 --- a/tests/unit/seed/wheels/test_acquire.py +++ b/tests/unit/seed/wheels/test_acquire.py @@ -171,3 +171,36 @@ def test_get_wheel_download_cached( }, ], } + + +@pytest.mark.parametrize( + "distribution", + [ + "pip space", + "pip[extra]", + "pip;os.system('x')", + "--index-url=http://evil", + "-r requirements.txt", + "", + ".pip", + "pip-", + ], +) +def test_download_wheel_rejects_bad_distribution(distribution: str, session_app_data) -> None: + with pytest.raises(ValueError, match="suspicious distribution name"): + download_wheel(distribution, None, "3.14", [], session_app_data, "folder", os.environ) + + +@pytest.mark.parametrize( + "version_spec", + [ + "==1.0 --index-url=http://evil", + ">=1.0", + "== 1.0", + "==1.0;echo", + ], +) +def test_download_wheel_rejects_bad_version_spec(version_spec: str, session_app_data, mocker: MockerFixture) -> None: + mocker.patch("virtualenv.seed.wheels.acquire.Popen") + with pytest.raises(ValueError, match="suspicious version spec"): + download_wheel("pip", version_spec, "3.14", [], session_app_data, "folder", os.environ) diff --git a/tests/unit/seed/wheels/test_bundle.py b/tests/unit/seed/wheels/test_bundle.py index 4cb3295..487d827 100644 --- a/tests/unit/seed/wheels/test_bundle.py +++ b/tests/unit/seed/wheels/test_bundle.py @@ -1,14 +1,23 @@ from __future__ import annotations +import hashlib import os +import zipfile from datetime import datetime, timezone from pathlib import Path import pytest from virtualenv.app_data import AppDataDiskFolder +from virtualenv.seed.wheels import embed from virtualenv.seed.wheels.bundle import from_bundle -from virtualenv.seed.wheels.embed import get_embed_wheel +from virtualenv.seed.wheels.embed import ( + BUNDLE_FOLDER, + BUNDLE_SHA256, + BUNDLE_SUPPORT, + _verify_bundled_wheel, + get_embed_wheel, +) from virtualenv.seed.wheels.periodic_update import dump_datetime from virtualenv.seed.wheels.util import Version, Wheel @@ -73,3 +82,57 @@ def test_version_pinned_in_app_data(app_data, for_py_version, next_pip_wheel) -> wheel = from_bundle("pip", next_pip_wheel.version, for_py_version, [], app_data, False, os.environ) assert wheel is not None assert wheel.name == next_pip_wheel.name + + +def test_every_bundled_wheel_has_sha256() -> None: + referenced = {wheel for mapping in BUNDLE_SUPPORT.values() for wheel in mapping.values()} + missing = referenced - BUNDLE_SHA256.keys() + assert not missing, f"bundled wheels missing from BUNDLE_SHA256: {sorted(missing)}" + + +def test_every_wheel_on_disk_has_sha256() -> None: + on_disk = {entry.name for entry in BUNDLE_FOLDER.iterdir() if entry.suffix == ".whl"} + assert on_disk == BUNDLE_SHA256.keys() + + +def test_get_embed_wheel_verifies_pip(for_py_version: str) -> None: + wheel = get_embed_wheel("pip", for_py_version) + assert wheel is not None + + +def test_verify_bundled_wheel_rejects_tamper(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + fake_name = "fake-0.0.1-py3-none-any.whl" + fake = tmp_path / fake_name + fake.write_bytes(b"not the real bytes") + monkeypatch.setitem(BUNDLE_SHA256, fake_name, "0" * 64) + monkeypatch.setattr("virtualenv.seed.wheels.embed._VERIFIED_WHEELS", set()) + + with pytest.raises(RuntimeError, match="sha256 mismatch"): + _verify_bundled_wheel(fake) + + +def test_verify_bundled_wheel_rejects_unknown(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + stray = tmp_path / "stray-0.0.1-py3-none-any.whl" + stray.write_bytes(b"payload") + monkeypatch.setattr("virtualenv.seed.wheels.embed._VERIFIED_WHEELS", set()) + + with pytest.raises(RuntimeError, match="no recorded sha256"): + _verify_bundled_wheel(stray) + + +def test_verify_bundled_wheel_reads_from_zipapp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Simulate the layout we get when virtualenv runs out of a ``.pyz``: the wheel lives inside the zipapp rather + # than on disk, so the hash check has to read it through ``zipfile`` rather than ``path.open``. + wheel_name = "fakepkg-0.0.1-py3-none-any.whl" + wheel_payload = b"pretend-wheel-bytes" + fake_root = tmp_path / "virtualenv.pyz" + entry = f"virtualenv/seed/wheels/embed/{wheel_name}" + with zipfile.ZipFile(str(fake_root), "w") as archive: + archive.writestr(entry, wheel_payload) + + monkeypatch.setattr(embed, "IS_ZIPAPP", True) + monkeypatch.setattr(embed, "ROOT", str(fake_root)) + monkeypatch.setitem(BUNDLE_SHA256, wheel_name, hashlib.sha256(wheel_payload).hexdigest()) + monkeypatch.setattr(embed, "_VERIFIED_WHEELS", set()) + + _verify_bundled_wheel(fake_root / entry) diff --git a/tests/unit/seed/wheels/test_periodic_update.py b/tests/unit/seed/wheels/test_periodic_update.py index e683d6c..33524ce 100644 --- a/tests/unit/seed/wheels/test_periodic_update.py +++ b/tests/unit/seed/wheels/test_periodic_update.py @@ -501,7 +501,9 @@ def test_new_version_ne() -> None: ) -def test_get_release_unsecure(mocker, caplog) -> None: +def test_get_release_unsecure(mocker, caplog, monkeypatch) -> None: + monkeypatch.setenv("VIRTUALENV_PERIODIC_UPDATE_INSECURE", "1") + @contextmanager def _release(of, context): assert of == "https://pypi.org/pypi/pip/json" @@ -521,6 +523,17 @@ def test_get_release_unsecure(mocker, caplog) -> None: assert " failed " in caplog.text +def test_get_release_verified_failure_does_not_fallback(mocker, monkeypatch) -> None: + monkeypatch.delenv("VIRTUALENV_PERIODIC_UPDATE_INSECURE", raising=False) + url_o = mocker.patch("virtualenv.seed.wheels.periodic_update.urlopen", side_effect=URLError("insecure")) + + result = release_date_for_wheel_path(Path("pip-20.1.whl")) + + assert result is None + assert url_o.call_count == 1 + assert url_o.call_args.kwargs["context"] is None + + def test_get_release_fails(mocker, caplog) -> None: exc = RuntimeError("oh no") url_o = mocker.patch("virtualenv.seed.wheels.periodic_update.urlopen", side_effect=exc) @@ -564,7 +577,7 @@ def test_download_stop_with_embed(tmp_path, mocker, time_freeze) -> None: do_update("pip", "3.9", str(wheel.path), str(app_data_outer), [], True) assert download_wheel.call_count == 3 - assert url_o.call_count == 2 + assert url_o.call_count == 1 assert read_dict.call_count == 1 assert write.call_count == 1 @@ -587,7 +600,7 @@ def test_download_manual_stop_after_one_download(tmp_path, mocker, time_freeze) do_update("pip", "3.9", str(wheel.path), str(app_data_outer), [], False) assert download_wheel.call_count == 1 - assert url_o.call_count == 2 + assert url_o.call_count == 1 assert read_dict.call_count == 1 assert write.call_count == 1 @@ -610,7 +623,7 @@ def test_download_manual_ignores_pre_release(tmp_path, mocker, time_freeze) -> N do_update("pip", "3.9", str(wheel.path), str(app_data_outer), [], False) assert download_wheel.call_count == 1 - assert url_o.call_count == 2 + assert url_o.call_count == 1 assert read_dict.call_count == 1 assert write.call_count == 1 wrote_json = write.call_args[0][0] diff --git a/tests/unit/test_run.py b/tests/unit/test_run.py index f223ee1..8879698 100644 --- a/tests/unit/test_run.py +++ b/tests/unit/test_run.py @@ -29,7 +29,7 @@ def test_version(capsys) -> None: assert __version__ in content import virtualenv # noqa: PLC0415 - assert virtualenv.__file__ in content + assert virtualenv.__file__ in content.replace("\n", "") @pytest.mark.parametrize("on", [True, False]) diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py index 2cdc038..09ab81f 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py @@ -3,11 +3,13 @@ from __future__ import annotations import concurrent.futures import os import traceback +import zipfile from typing import TYPE_CHECKING import pytest from virtualenv.app_data import _cache_dir_with_migration, _default_app_data_dir +from virtualenv.util import zipapp from virtualenv.util.lock import ReentrantFileLock from virtualenv.util.subprocess import run_cmd @@ -148,3 +150,29 @@ class TestCacheDirMigration: result = _cache_dir_with_migration() assert result == new_dir assert (tmp_path / "new-cache" / "wheel" / "3.12" / "image" / "pip" / "pip.dist-info" / "METADATA").exists() + + +@pytest.fixture +def fake_zipapp_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + fake_root = tmp_path / "virtualenv.pyz" + with zipfile.ZipFile(str(fake_root), "w") as zip_file: + zip_file.writestr("virtualenv/payload.txt", "hello zipapp") + monkeypatch.setattr(zipapp, "ROOT", str(fake_root)) + return fake_root + + +def test_zipapp_read_returns_payload_from_entry_inside_root(fake_zipapp_root: Path) -> None: + entry = fake_zipapp_root / "virtualenv" / "payload.txt" + assert zipapp.read(entry) == "hello zipapp" + + +def test_zipapp_read_rejects_path_escaping_via_parent(fake_zipapp_root: Path) -> None: + escape = fake_zipapp_root / ".." / "escape.txt" + with pytest.raises(RuntimeError, match="should be within ROOT"): + zipapp.read(escape) + + +def test_zipapp_read_rejects_unrelated_absolute_path(fake_zipapp_root: Path, tmp_path: Path) -> None: # noqa: ARG001 + unrelated = tmp_path / "other" / "file.txt" + with pytest.raises(RuntimeError, match="should be within ROOT"): + zipapp.read(unrelated) @@ -37,7 +37,7 @@ commands = [ "run", "-m", "pytest", - { replace = "posargs", extend = true, default = [ + { replace = "posargs", default = [ "--junitxml", "{work_dir}{/}junit.{env_name}.xml", "tests", @@ -46,7 +46,7 @@ commands = [ "auto", "--dist", "loadfile", - ] }, + ], extend = true }, ], [ "coverage", "combine" ], [ "coverage", "report", "--skip-covered", "--show-missing" ], @@ -101,12 +101,12 @@ commands = [ [ "pre-commit", "run", "--all-files", "--show-diff-on-failure" ] ] commands = [ [ "pytest", - { replace = "posargs", extend = true, default = [ + { replace = "posargs", default = [ "--junitxml", "{work_dir}{/}junit.{env_name}.xml", "tests", "--skip-slow", - ] }, + ], extend = true }, ], ] @@ -124,34 +124,31 @@ commands = [ commands = [ [ "pytest", - { replace = "posargs", extend = true, default = [ + { replace = "posargs", default = [ "--junitxml", "{work_dir}{/}junit.{env_name}.xml", "tests", "--skip-slow", - ] }, + ], extend = true }, ], ] [env.type] description = "run type checker (ty) against Python 3.14" dependency_groups = [ "type" ] -commands = [ [ "python", "-m", "ty", "check", "src/virtualenv", "--python-version", "3.14" ] ] - -[env.upgrade] -description = "upgrade pip/wheels/setuptools to latest" -skip_install = true -deps = [ "ruff>=0.12.4" ] -pass_env = [ "UPGRADE_ADVISORY" ] -change_dir = "{tox_root}{/}tasks" -commands = [ [ "python", "upgrade_wheels.py" ] ] -uv_seed = true - -[env.release] -description = "do a release, required posarg of the version number" -deps = [ "gitpython>=3.1.44", "packaging>=25", "pre-commit-uv>=4.1.4", "towncrier>=24.8" ] -change_dir = "{tox_root}{/}tasks" -commands = [ [ "python", "release.py", { replace = "posargs", extend = true } ] ] +commands = [ + [ + "python", + "-m", + "ty", + "check", + "src/virtualenv", + "--python-version", + "3.14", + "--output-format", + "concise" + ] +] [env.dev] description = "generate a DEV environment" @@ -162,6 +159,21 @@ commands = [ [ "python", "-c", "import sys; print(sys.executable)" ], ] +[env.release] +description = "do a release, required posarg of the version number" +deps = [ "gitpython>=3.1.44", "packaging>=25", "pre-commit-uv>=4.1.4", "towncrier>=24.8" ] +change_dir = "{tox_root}{/}tasks" +commands = [ [ "python", "release.py", { replace = "posargs", extend = true } ] ] + +[env.upgrade] +description = "upgrade pip/wheels/setuptools to latest" +skip_install = true +deps = [ "ruff>=0.12.4" ] +pass_env = [ "UPGRADE_ADVISORY" ] +change_dir = "{tox_root}{/}tasks" +commands = [ [ "python", "upgrade_wheels.py" ] ] +uv_seed = true + [env.zipapp] description = "generate a zipapp" skip_install = true |
