From 42e0698087f061d1d7db6fcb9469302bda5d44ca Mon Sep 17 00:00:00 2001
From: chrysle <fritzihab@posteo.de>
Date: Fri, 28 Apr 2023 01:36:03 +0200
Subject: [PATCH] 3.12 support and no setuptools/wheel on 3.12+ (#2558)

Cherry-picked from fd93dd79be89b21e6e9d43ca2dd1b02b811f6d6f
---
 docs/changelog/2487.feature.rst               |  6 +++++
 docs/changelog/2558.feature.rst               |  1 +
 docs/render_cli.py                            | 10 +------
 docs/user_guide.rst                           |  5 ++--
 src/virtualenv/activation/python/__init__.py  |  3 ++-
 src/virtualenv/seed/embed/base_embed.py       | 11 +++++---
 src/virtualenv/util/path/_sync.py             |  4 ++-
 tests/unit/config/test___main__.py            |  2 +-
 tests/unit/create/test_creator.py             | 26 ++++++++++++++++---
 tests/unit/discovery/py_info/test_py_info.py  |  2 +-
 tests/unit/discovery/windows/conftest.py      |  2 +-
 tests/unit/seed/embed/test_base_embed.py      | 12 +++++++++
 .../embed/test_bootstrap_link_via_app_data.py |  4 +--
 .../unit/seed/wheels/test_periodic_update.py  | 17 ++++++++++--
 14 files changed, 79 insertions(+), 26 deletions(-)
 create mode 100644 docs/changelog/2487.feature.rst
 create mode 100644 docs/changelog/2558.feature.rst

diff --git a/docs/changelog/2487.feature.rst b/docs/changelog/2487.feature.rst
new file mode 100644
index 0000000..12cc896
--- /dev/null
+++ b/docs/changelog/2487.feature.rst
@@ -0,0 +1,6 @@
+Do not install ``wheel`` and ``setuptools`` seed packages for Python 3.12+. To restore the old behaviour use:
+
+- for ``wheel`` use ``VIRTUALENV_WHEEL=bundle`` environment variable or ``--wheel=bundle`` CLI flag,
+- for ``setuptools`` use ``VIRTUALENV_SETUPTOOLS=bundle`` environment variable or ``--setuptools=bundle`` CLI flag.
+
+By :user:`chrysle`.
diff --git a/docs/changelog/2558.feature.rst b/docs/changelog/2558.feature.rst
new file mode 100644
index 0000000..58b627a
--- /dev/null
+++ b/docs/changelog/2558.feature.rst
@@ -0,0 +1 @@
+3.12 support - by :user:`gaborbernat`.
diff --git a/src/virtualenv/activation/python/__init__.py b/src/virtualenv/activation/python/__init__.py
index eb83504..a49444b 100644
--- a/src/virtualenv/activation/python/__init__.py
+++ b/src/virtualenv/activation/python/__init__.py
@@ -12,10 +12,11 @@ class PythonActivator(ViaTemplateActivator):
     def replacements(self, creator, dest_folder):
         replacements = super().replacements(creator, dest_folder)
         lib_folders = OrderedDict((os.path.relpath(str(i), str(dest_folder)), None) for i in creator.libs)
+        lib_folders = os.pathsep.join(lib_folders.keys()).replace("\\", "\\\\")  # escape Windows path characters
         win_py2 = creator.interpreter.platform == "win32" and creator.interpreter.version_info.major == 2
         replacements.update(
             {
-                "__LIB_FOLDERS__": os.pathsep.join(lib_folders.keys()),
+                "__LIB_FOLDERS__": lib_folders,
                 "__DECODE_PATH__": ("yes" if win_py2 else ""),
             },
         )
diff --git a/src/virtualenv/seed/embed/base_embed.py b/src/virtualenv/seed/embed/base_embed.py
index f29110b..6782d6f 100644
--- a/src/virtualenv/seed/embed/base_embed.py
+++ b/src/virtualenv/seed/embed/base_embed.py
@@ -39,7 +39,7 @@ class BaseEmbed(Seeder, metaclass=ABCMeta):
         return {
             distribution: getattr(self, f"{distribution}_version")
             for distribution in self.distributions()
-            if getattr(self, f"no_{distribution}") is False
+            if getattr(self, f"no_{distribution}") is False and getattr(self, f"{distribution}_version") != "none"
         }
 
     @classmethod
@@ -69,11 +69,13 @@ class BaseEmbed(Seeder, metaclass=ABCMeta):
             default=[],
         )
         for distribution, default in cls.distributions().items():
+            if interpreter.version_info[:2] >= (3, 12) and distribution in {"wheel", "setuptools"}:
+                default = "none"
             parser.add_argument(
                 f"--{distribution}",
                 dest=distribution,
                 metavar="version",
-                help=f"version of {distribution} to install as seed: embed, bundle or exact version",
+                help=f"version of {distribution} to install as seed: embed, bundle, none or exact version",
                 default=default,
             )
         for distribution in cls.distributions():
@@ -101,7 +103,10 @@ class BaseEmbed(Seeder, metaclass=ABCMeta):
         for distribution in self.distributions():
             if getattr(self, f"no_{distribution}"):
                 continue
-            ver = f"={getattr(self, f'{distribution}_version', None) or 'latest'}"
+            version = getattr(self, f"{distribution}_version", None)
+            if version == "none":
+                continue
+            ver = f"={version or 'latest'}"
             result += f" {distribution}{ver},"
         return result[:-1] + ")"
 
diff --git a/src/virtualenv/util/path/_sync.py b/src/virtualenv/util/path/_sync.py
index 604379d..b0af1eb 100644
--- a/src/virtualenv/util/path/_sync.py
+++ b/src/virtualenv/util/path/_sync.py
@@ -1,6 +1,7 @@
 import logging
 import os
 import shutil
+import sys
 from stat import S_IWUSR
 
 
@@ -56,7 +57,8 @@ def safe_delete(dest):
         else:
             raise
 
-    shutil.rmtree(str(dest), ignore_errors=True, onerror=onerror)
+    kwargs = {"onexc" if sys.version_info >= (3, 12) else "onerror": onerror}
+    shutil.rmtree(str(dest), ignore_errors=True, **kwargs)
 
 
 class _Debug:
diff --git a/tests/unit/config/test___main__.py b/tests/unit/config/test___main__.py
index 62228c9..d22ef7e 100644
--- a/tests/unit/config/test___main__.py
+++ b/tests/unit/config/test___main__.py
@@ -58,7 +58,7 @@ def test_fail_with_traceback(raise_on_session_done, tmp_path, capsys):
 
 @pytest.mark.usefixtures("session_app_data")
 def test_session_report_full(tmp_path, capsys):
-    run_with_catch([str(tmp_path)])
+    run_with_catch([str(tmp_path), "--setuptools", "bundle", "--wheel", "bundle"])
     out, err = capsys.readouterr()
     assert err == ""
     lines = out.splitlines()
diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py
index 0ec6d62..8b9d688 100644
--- a/tests/unit/create/test_creator.py
+++ b/tests/unit/create/test_creator.py
@@ -412,7 +412,19 @@ def test_create_long_path(tmp_path):
 @pytest.mark.parametrize("creator", sorted(set(PythonInfo.current_system().creators().key_to_class) - {"builtin"}))
 @pytest.mark.usefixtures("session_app_data")
 def test_create_distutils_cfg(creator, tmp_path, monkeypatch):
-    result = cli_run([str(tmp_path / "venv"), "--activators", "", "--creator", creator])
+    result = cli_run(
+        [
+            str(tmp_path / "venv"),
+            "--activators",
+            "",
+            "--creator",
+            creator,
+            "--setuptools",
+            "bundle",
+            "--wheel",
+            "bundle",
+        ],
+    )
 
     app = Path(__file__).parent / "console_app"
     dest = tmp_path / "console_app"
@@ -465,7 +477,9 @@ def list_files(path):
 
 def test_zip_importer_can_import_setuptools(tmp_path):
     """We're patching the loaders so might fail on r/o loaders, such as zipimporter on CPython<3.8"""
-    result = cli_run([str(tmp_path / "venv"), "--activators", "", "--no-pip", "--no-wheel", "--copies"])
+    result = cli_run(
+        [str(tmp_path / "venv"), "--activators", "", "--no-pip", "--no-wheel", "--copies", "--setuptools", "bundle"],
+    )
     zip_path = tmp_path / "site-packages.zip"
     with zipfile.ZipFile(str(zip_path), "w", zipfile.ZIP_DEFLATED) as zip_handler:
         lib = str(result.creator.purelib)
@@ -499,6 +513,7 @@ def test_no_preimport_threading(tmp_path):
     out = subprocess.check_output(
         [str(session.creator.exe), "-c", r"import sys; print('\n'.join(sorted(sys.modules)))"],
         text=True,
+        encoding="utf-8",
     )
     imported = set(out.splitlines())
     assert "threading" not in imported
@@ -515,6 +530,7 @@ def test_pth_in_site_vs_python_path(tmp_path):
     out = subprocess.check_output(
         [str(session.creator.exe), "-c", r"import sys; print(sys.testpth)"],
         text=True,
+        encoding="utf-8",
     )
     assert out == "ok\n"
     # same with $PYTHONPATH pointing to site_packages
@@ -527,6 +543,7 @@ def test_pth_in_site_vs_python_path(tmp_path):
         [str(session.creator.exe), "-c", r"import sys; print(sys.testpth)"],
         text=True,
         env=env,
+        encoding="utf-8",
     )
     assert out == "ok\n"
 
@@ -540,6 +557,7 @@ def test_getsitepackages_system_site(tmp_path):
     out = subprocess.check_output(
         [str(session.creator.exe), "-c", r"import site; print(site.getsitepackages())"],
         text=True,
+        encoding="utf-8",
     )
     site_packages = ast.literal_eval(out)
 
@@ -554,6 +572,7 @@ def test_getsitepackages_system_site(tmp_path):
     out = subprocess.check_output(
         [str(session.creator.exe), "-c", r"import site; print(site.getsitepackages())"],
         text=True,
+        encoding="utf-8",
     )
     site_packages = [str(Path(i).resolve()) for i in ast.literal_eval(out)]
 
@@ -579,6 +598,7 @@ def test_get_site_packages(tmp_path):
     out = subprocess.check_output(
         [str(session.creator.exe), "-c", r"import site; print(site.getsitepackages())"],
         text=True,
+        encoding="utf-8",
     )
     site_packages = ast.literal_eval(out)
 
@@ -617,7 +637,7 @@ def test_python_path(monkeypatch, tmp_path, python_path_on):
         if flag:
             cmd.append(flag)
         cmd.extend(["-c", "import json; import sys; print(json.dumps(sys.path))"])
-        return [i if case_sensitive else i.lower() for i in json.loads(subprocess.check_output(cmd))]
+        return [i if case_sensitive else i.lower() for i in json.loads(subprocess.check_output(cmd, encoding="utf-8"))]
 
     monkeypatch.delenv("PYTHONPATH", raising=False)
     base = _get_sys_path()
diff --git a/tests/unit/discovery/py_info/test_py_info.py b/tests/unit/discovery/py_info/test_py_info.py
index 24b129c..f3fdb7e 100644
--- a/tests/unit/discovery/py_info/test_py_info.py
+++ b/tests/unit/discovery/py_info/test_py_info.py
@@ -289,7 +289,7 @@ def test_discover_exe_on_path_non_spec_name_not_match(mocker):
     assert CURRENT.satisfies(spec, impl_must_match=True) is False
 
 
-@pytest.mark.skipif(IS_PYPY, reason="setuptools distutil1s patching does not work")
+@pytest.mark.skipif(IS_PYPY, reason="setuptools distutils patching does not work")
 def test_py_info_setuptools():
     from setuptools.dist import Distribution
 
diff --git a/tests/unit/discovery/windows/conftest.py b/tests/unit/discovery/windows/conftest.py
index 58da626..94f14da 100644
--- a/tests/unit/discovery/windows/conftest.py
+++ b/tests/unit/discovery/windows/conftest.py
@@ -9,7 +9,7 @@ def _mock_registry(mocker):
     from virtualenv.discovery.windows.pep514 import winreg
 
     loc, glob = {}, {}
-    mock_value_str = (Path(__file__).parent / "winreg-mock-values.py").read_text()
+    mock_value_str = (Path(__file__).parent / "winreg-mock-values.py").read_text(encoding="utf-8")
     exec(mock_value_str, glob, loc)
     enum_collect = loc["enum_collect"]
     value_collect = loc["value_collect"]
diff --git a/tests/unit/seed/embed/test_base_embed.py b/tests/unit/seed/embed/test_base_embed.py
index 3344c74..ef2f829 100644
--- a/tests/unit/seed/embed/test_base_embed.py
+++ b/tests/unit/seed/embed/test_base_embed.py
@@ -1,3 +1,5 @@
+import sys
+
 import pytest
 
 from virtualenv.run import session_via_cli
@@ -10,3 +12,13 @@ from virtualenv.run import session_via_cli
 def test_download_cli_flag(args, download, tmp_path):
     session = session_via_cli(args + [str(tmp_path)])
     assert session.seeder.download is download
+
+
+def test_embed_wheel_versions(tmp_path):
+    session = session_via_cli([str(tmp_path)])
+    expected = (
+        {"pip": "bundle"}
+        if sys.version_info[:2] >= (3, 12)
+        else {"pip": "bundle", "setuptools": "bundle", "wheel": "bundle"}
+    )
+    assert session.seeder.distribution_to_versions() == expected
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 2c8c3e8..015686d 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
@@ -203,7 +203,7 @@ def test_populated_read_only_cache_and_copied_app_data(tmp_path, current_fastest
 @pytest.mark.parametrize("pkg", ["pip", "setuptools", "wheel"])
 @pytest.mark.usefixtures("session_app_data", "current_fastest", "coverage_env")
 def test_base_bootstrap_link_via_app_data_no(tmp_path, pkg):
-    create_cmd = [str(tmp_path), "--seeder", "app-data", f"--no-{pkg}"]
+    create_cmd = [str(tmp_path), "--seeder", "app-data", f"--no-{pkg}", "--wheel", "bundle", "--setuptools", "bundle"]
     result = cli_run(create_cmd)
     assert not (result.creator.purelib / pkg).exists()
     for key in {"pip", "setuptools", "wheel"} - {pkg}:
@@ -231,7 +231,7 @@ def _run_parallel_threads(tmp_path):
 
     def _run(name):
         try:
-            cli_run(["--seeder", "app-data", str(tmp_path / name), "--no-pip", "--no-setuptools"])
+            cli_run(["--seeder", "app-data", str(tmp_path / name), "--no-pip", "--no-setuptools", "--wheel", "bundle"])
         except Exception as exception:
             as_str = str(exception)
             exceptions.append(as_str)
diff --git a/tests/unit/seed/wheels/test_periodic_update.py b/tests/unit/seed/wheels/test_periodic_update.py
index e7794f5..c36a983 100644
--- a/tests/unit/seed/wheels/test_periodic_update.py
+++ b/tests/unit/seed/wheels/test_periodic_update.py
@@ -66,7 +66,7 @@ def test_manual_upgrade(session_app_data, caplog, mocker, for_py_version):
 
 @pytest.mark.usefixtures("session_app_data")
 def test_pick_periodic_update(tmp_path, mocker, for_py_version):
-    embed, current = get_embed_wheel("setuptools", "3.5"), get_embed_wheel("setuptools", for_py_version)
+    embed, current = get_embed_wheel("setuptools", "3.6"), get_embed_wheel("setuptools", for_py_version)
     mocker.patch("virtualenv.seed.wheels.bundle.load_embed_wheel", return_value=embed)
     completed = datetime.now() - timedelta(days=29)
     u_log = UpdateLog(
@@ -77,7 +77,20 @@ def test_pick_periodic_update(tmp_path, mocker, for_py_version):
     )
     read_dict = mocker.patch("virtualenv.app_data.via_disk_folder.JSONStoreDisk.read", return_value=u_log.to_dict())
 
-    result = cli_run([str(tmp_path), "--activators", "", "--no-periodic-update", "--no-wheel", "--no-pip"])
+    result = cli_run(
+        [
+            str(tmp_path),
+            "--activators",
+            "",
+            "--no-periodic-update",
+            "--no-wheel",
+            "--no-pip",
+            "--setuptools",
+            "bundle",
+            "--wheel",
+            "bundle",
+        ],
+    )
 
     assert read_dict.call_count == 1
     installed = [i.name for i in result.creator.purelib.iterdir() if i.suffix == ".dist-info"]
-- 
2.40.0

