summaryrefslogtreecommitdiff
diff options
authorStefano Rivera <stefanor@debian.org>2026-04-24 17:39:44 -0400
committergit-ubuntu importer <ubuntu-devel-discuss@lists.ubuntu.com>2026-04-25 04:39:46 +0000
commitf5524fcfe183c63eddbbe1d0b59fe793b90555ef (patch)
treee8596e2fcaca396196f452f08e41d64ec1c32171
parenta4a886e4174818af8c09db089dfc983573718f34 (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.
-rw-r--r--PKG-INFO8
-rw-r--r--README.md2
-rw-r--r--debian/changelog9
-rw-r--r--debian/control2
-rw-r--r--debian/patches/debian_update_for_available_wheels.patch10
-rw-r--r--debian/patches/debian_wheel_location.patch28
-rw-r--r--debian/patches/wheel-package-error8
-rw-r--r--pyproject.toml11
-rw-r--r--src/virtualenv/activation/nushell/activate.nu6
-rw-r--r--src/virtualenv/config/cli/parser.py3
-rw-r--r--src/virtualenv/create/creator.py8
-rw-r--r--src/virtualenv/create/debug.py17
-rw-r--r--src/virtualenv/create/via_global_ref/builtin/cpython/cpython3.py14
-rw-r--r--src/virtualenv/create/via_global_ref/builtin/graalpy/__init__.py2
-rw-r--r--src/virtualenv/create/via_global_ref/builtin/ref.py13
-rw-r--r--src/virtualenv/create/via_global_ref/builtin/rustpython/__init__.py2
-rw-r--r--src/virtualenv/seed/embed/via_app_data/pip_install/base.py30
-rw-r--r--src/virtualenv/seed/wheels/acquire.py63
-rw-r--r--src/virtualenv/seed/wheels/embed/__init__.py73
-rw-r--r--src/virtualenv/seed/wheels/periodic_update.py14
-rw-r--r--src/virtualenv/util/zipapp.py30
-rw-r--r--src/virtualenv/version.py26
-rw-r--r--tasks/upgrade_wheels.py312
-rw-r--r--tests/conftest.py2
-rw-r--r--tests/unit/activation/test_nushell.py48
-rw-r--r--tests/unit/config/cli/test_help_formatter.py27
-rw-r--r--tests/unit/seed/embed/test_bootstrap_link_via_app_data.py51
-rw-r--r--tests/unit/seed/wheels/test_acquire.py33
-rw-r--r--tests/unit/seed/wheels/test_bundle.py65
-rw-r--r--tests/unit/seed/wheels/test_periodic_update.py21
-rw-r--r--tests/unit/test_run.py2
-rw-r--r--tests/unit/test_util.py28
-rw-r--r--tox.toml56
33 files changed, 795 insertions, 229 deletions
diff --git a/PKG-INFO b/PKG-INFO
index 71d9959..f68ac8b 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -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)
diff --git a/README.md b/README.md
index 87f45e0..f85561e 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/tox.toml b/tox.toml
index bc54b8a..a0158f5 100644
--- a/tox.toml
+++ b/tox.toml
@@ -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