Skip to content

Commit ccc71cb

Browse files
authored
Merge pull request #563 from pdm-project/new-installer
2 parents cc0468d + e1f1f8c commit ccc71cb

12 files changed

+106
-59
lines changed

news/519.refactor.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Use installer as the wheel installer, replacing `distlib`.

news/529.bugfix.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Accept non-canonical distribution name in the wheel's dist-info directory name.

pdm.lock

+12-11
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pdm/cli/actions.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,7 @@ def version_matcher(py_version: PythonInfo) -> bool:
513513
and not project.environment.is_global
514514
):
515515
project.core.ui.echo(termui.cyan("Updating executable scripts..."))
516-
project.environment.update_shebangs(new_path)
516+
project.environment.update_shebangs(old_path, new_path)
517517

518518

519519
def do_import(

pdm/installers/installers.py

+50-11
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
from __future__ import annotations
22

33
import pathlib
4+
import sys
45
from typing import TYPE_CHECKING
56

6-
import distlib.scripts
7-
from distlib.wheel import Wheel
7+
from installer.exceptions import InvalidWheelSource
8+
from installer.sources import WheelFile as _WheelFile
89
from pip._vendor.pkg_resources import EggInfoDistribution
910

1011
from pdm import termui
1112
from pdm.models import pip_shims
1213
from pdm.models.requirements import parse_requirement
14+
from pdm.utils import cached_property
1315

1416
if TYPE_CHECKING:
1517
from pip._vendor.pkg_resources import Distribution
@@ -30,6 +32,23 @@ def format_dist(dist: Distribution) -> str:
3032
return formatter.format(version=termui.yellow(dist.version), path=path)
3133

3234

35+
class WheelFile(_WheelFile):
36+
@cached_property
37+
def dist_info_dir(self) -> str:
38+
namelist = self._zipfile.namelist()
39+
try:
40+
return next(
41+
name.split("/")[0]
42+
for name in namelist
43+
if name.split("/")[0].endswith(".dist-info")
44+
)
45+
except StopIteration: # pragma: no cover
46+
canonical_name = super().dist_info_dir
47+
raise InvalidWheelSource(
48+
f"The wheel doesn't contain metadata {canonical_name!r}"
49+
)
50+
51+
3352
class Installer: # pragma: no cover
3453
"""The installer that performs the installation and uninstallation actions."""
3554

@@ -44,17 +63,27 @@ def install(self, candidate: Candidate) -> None:
4463
self.install_editable(candidate.ireq)
4564
else:
4665
built = candidate.build()
47-
self.install_wheel(Wheel(built))
66+
self.install_wheel(built)
4867

49-
def install_wheel(self, wheel: Wheel) -> None:
50-
paths = self.environment.get_paths()
51-
maker = distlib.scripts.ScriptMaker(None, None)
52-
maker.variants = set(("",))
53-
enquoted_executable = distlib.scripts.enquote_executable(
54-
self.environment.interpreter.executable
68+
def install_wheel(self, wheel: str) -> None:
69+
from installer import __version__, install
70+
from installer.destinations import SchemeDictionaryDestination
71+
72+
destination = SchemeDictionaryDestination(
73+
self.environment.get_paths(),
74+
interpreter=self.environment.interpreter.executable,
75+
script_kind=self._get_kind(),
5576
)
56-
maker.executable = enquoted_executable
57-
wheel.install(paths, maker)
77+
78+
with WheelFile.open(wheel) as source:
79+
install(
80+
source=source,
81+
destination=destination,
82+
# Additional metadata that is generated by the installation tool.
83+
additional_metadata={
84+
"INSTALLER": f"installer {__version__}".encode(),
85+
},
86+
)
5887

5988
def install_editable(self, ireq: pip_shims.InstallRequirement) -> None:
6089
from pdm.builders import EditableBuilder
@@ -83,3 +112,13 @@ def uninstall(self, dist: Distribution) -> None:
83112
pathset = ireq.uninstall(auto_confirm=self.auto_confirm)
84113
if pathset:
85114
pathset.commit()
115+
116+
def _get_kind(self) -> str:
117+
if sys.platform != "win32":
118+
return "posix"
119+
is_32bit = self.environment.interpreter.is_32bit
120+
# TODO: support win arm64
121+
if is_32bit:
122+
return "win-ia32"
123+
else:
124+
return "win-amd64"

pdm/models/environment.py

+19-9
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import collections
44
import os
5-
import re
5+
import shlex
66
import shutil
77
import subprocess
88
import sys
@@ -12,7 +12,6 @@
1212
from pathlib import Path
1313
from typing import TYPE_CHECKING, Any, Generator, Iterator
1414

15-
from distlib.scripts import ScriptMaker
1615
from pip._vendor import packaging, pkg_resources
1716

1817
from pdm import termui
@@ -33,6 +32,19 @@
3332
from pdm.project import Project
3433

3534

35+
def _get_shebang_path(executable: str, is_launcher: bool) -> bytes:
36+
"""Get the interpreter path in the shebang line
37+
38+
The launcher can just use the command as-is.
39+
Otherwise if the path contains whitespace or is too long, both distlib
40+
and installer use a clever hack to make the shebang after ``/bin/sh``,
41+
where the interpreter path is quoted.
42+
"""
43+
if is_launcher or " " not in executable and (len(executable) + 3) <= 127:
44+
return executable.encode("utf-8")
45+
return shlex.quote(executable).encode("utf-8")
46+
47+
3648
class WorkingSet(collections.abc.Mapping):
3749
"""A dict-like class that holds all installed packages in the lib directory."""
3850

@@ -217,18 +229,16 @@ def which(self, command: str) -> str | None:
217229
new_path = os.pathsep.join([python_root, this_path, os.getenv("PATH", "")])
218230
return shutil.which(command, path=new_path)
219231

220-
def update_shebangs(self, new_path: str) -> None:
232+
def update_shebangs(self, old_path: str, new_path: str) -> None:
221233
"""Update the shebang lines"""
222234
scripts = self.get_paths()["scripts"]
223-
maker = ScriptMaker(None, None)
224-
maker.executable = new_path
225-
shebang = maker._get_shebang("utf-8").rstrip().replace(b"\\", b"\\\\")
226235
for child in Path(scripts).iterdir():
227236
if not child.is_file() or child.suffix not in (".exe", ".py", ""):
228237
continue
229-
child.write_bytes(
230-
re.sub(rb"#!.+?python.*?$", shebang, child.read_bytes(), flags=re.M)
231-
)
238+
is_launcher = child.suffix == ".exe"
239+
old_shebang = _get_shebang_path(old_path, is_launcher)
240+
new_shebang = _get_shebang_path(new_path, is_launcher)
241+
child.write_bytes(child.read_bytes().replace(old_shebang, new_shebang, 1))
232242

233243
def _download_pip_wheel(self, path: str | Path) -> None:
234244
dirname = Path(tempfile.mkdtemp(prefix="pip-download-"))

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ dependencies = [
1212
"appdirs",
1313
"atoml>=1.0.3",
1414
"click>=7",
15-
"distlib>=0.3.1",
1615
"importlib-metadata; python_version < \"3.8\"",
1716
"pdm-pep517>=0.8,<0.9",
1817
"pep517",
@@ -23,6 +22,7 @@ dependencies = [
2322
"shellingham<2.0.0,>=1.3.2",
2423
"wheel<1.0.0,>=0.36.2",
2524
"tomli~=1.0",
25+
"installer~=0.2",
2626
]
2727
name = "pdm"
2828
description = "Python Development Master"

tests/cli/test_actions.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import sys
33

44
import pytest
5-
from distlib.wheel import Wheel
65

76
from pdm.cli import actions
87
from pdm.exceptions import InvalidPyVersion, PdmException, PdmUsageError
@@ -222,8 +221,8 @@ def test_lock_dependencies(project):
222221
def test_build_distributions(tmp_path, core):
223222
project = core.create_project()
224223
actions.do_build(project, dest=tmp_path.as_posix())
225-
wheel = Wheel(next(tmp_path.glob("*.whl")).as_posix())
226-
assert wheel.name == "pdm"
224+
wheel = next(tmp_path.glob("*.whl"))
225+
assert wheel.name.startswith("pdm-")
227226
tarball = next(tmp_path.glob("*.tar.gz"))
228227
assert tarball.exists()
229228

tests/cli/test_build.py

-6
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import tarfile
22
import zipfile
33

4-
from distlib.wheel import Wheel
5-
64
from pdm.cli import actions
75

86

@@ -40,10 +38,6 @@ def test_build_single_module(fixture_project):
4038
for name in ("pyproject.toml", "LICENSE"):
4139
assert name not in zip_names
4240

43-
assert Wheel(
44-
(project.root / "dist/demo_module-0.1.0-py3-none-any.whl").as_posix()
45-
).metadata
46-
4741

4842
def test_build_single_module_with_readme(fixture_project):
4943
project = fixture_project("demo-module")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:50f11ea3c386c4330c7a8be92c0736b4b6a5924bdbd00b8b93f93b0c7fe6f2b3
3+
size 49497

tests/test_installer.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from pdm.installers import Installer
2+
from pdm.models.candidates import Candidate
3+
from pdm.models.pip_shims import Link
4+
from pdm.models.requirements import parse_requirement
5+
6+
7+
def test_install_wheel_with_inconsistent_dist_info(project):
8+
req = parse_requirement("pyfunctional")
9+
candidate = Candidate(
10+
req,
11+
project.environment,
12+
link=Link("http://fixtures.test/artifacts/PyFunctional-1.4.3-py3-none-any.whl"),
13+
)
14+
installer = Installer(project.environment)
15+
installer.install(candidate)
16+
assert "pyfunctional" in project.environment.get_working_set()

tests/test_project.py

-17
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@
33
import venv
44
from pathlib import Path
55

6-
import distlib.wheel
76
import pytest
87

9-
from pdm.models.requirements import filter_requirements_with_extras
10-
from pdm.pep517.api import build_wheel
118
from pdm.utils import cd, temp_environ
129

1310

@@ -92,20 +89,6 @@ def test_project_use_venv(project):
9289
assert env.is_global
9390

9491

95-
def test_project_with_combined_extras(fixture_project):
96-
project = fixture_project("demo-combined-extras")
97-
(project.root / "build").mkdir(exist_ok=True)
98-
with cd(project.root.as_posix()):
99-
wheel_name = build_wheel(str(project.root / "build"))
100-
wheel = distlib.wheel.Wheel(str(project.root / "build" / wheel_name))
101-
102-
all_requires = filter_requirements_with_extras(
103-
wheel.metadata.run_requires, ("all",)
104-
)
105-
for dep in ("urllib3", "chardet", "idna"):
106-
assert dep in all_requires
107-
108-
10992
def test_project_packages_path(project):
11093
packages_path = project.environment.packages_path
11194
version = ".".join(map(str, sys.version_info[:2]))

0 commit comments

Comments
 (0)