Skip to content

Commit 6a22806

Browse files
authored
Correctly normalize relative paths for 'pip show'
1 parent e1a3855 commit 6a22806

File tree

5 files changed

+142
-49
lines changed

5 files changed

+142
-49
lines changed

src/pip/_internal/commands/show.py

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import csv
22
import logging
3-
import os
3+
import pathlib
44
from optparse import Values
5-
from typing import Iterator, List, NamedTuple, Optional
5+
from typing import Iterator, List, NamedTuple, Optional, Tuple
66

77
from pip._vendor.packaging.utils import canonicalize_name
88

@@ -66,6 +66,33 @@ class _PackageInfo(NamedTuple):
6666
files: Optional[List[str]]
6767

6868

69+
def _covert_legacy_entry(entry: Tuple[str, ...], info: Tuple[str, ...]) -> str:
70+
"""Convert a legacy installed-files.txt path into modern RECORD path.
71+
72+
The legacy format stores paths relative to the info directory, while the
73+
modern format stores paths relative to the package root, e.g. the
74+
site-packages directory.
75+
76+
:param entry: Path parts of the installed-files.txt entry.
77+
:param info: Path parts of the egg-info directory relative to package root.
78+
:returns: The converted entry.
79+
80+
For best compatibility with symlinks, this does not use ``abspath()`` or
81+
``Path.resolve()``, but tries to work with path parts:
82+
83+
1. While ``entry`` starts with ``..``, remove the equal amounts of parts
84+
from ``info``; if ``info`` is empty, start appending ``..`` instead.
85+
2. Join the two directly.
86+
"""
87+
while entry and entry[0] == "..":
88+
if not info or info[-1] == "..":
89+
info += ("..",)
90+
else:
91+
info = info[:-1]
92+
entry = entry[1:]
93+
return str(pathlib.Path(*info, *entry))
94+
95+
6996
def search_packages_info(query: List[str]) -> Iterator[_PackageInfo]:
7097
"""
7198
Gather details from installed distributions. Print distribution name,
@@ -100,14 +127,29 @@ def _files_from_record(dist: BaseDistribution) -> Optional[Iterator[str]]:
100127
text = dist.read_text('RECORD')
101128
except FileNotFoundError:
102129
return None
103-
return (row[0] for row in csv.reader(text.splitlines()))
130+
# This extra Path-str cast normalizes entries.
131+
return (str(pathlib.Path(row[0])) for row in csv.reader(text.splitlines()))
104132

105-
def _files_from_installed_files(dist: BaseDistribution) -> Optional[Iterator[str]]:
133+
def _files_from_legacy(dist: BaseDistribution) -> Optional[Iterator[str]]:
106134
try:
107135
text = dist.read_text('installed-files.txt')
108136
except FileNotFoundError:
109137
return None
110-
return (p for p in text.splitlines(keepends=False) if p)
138+
paths = (p for p in text.splitlines(keepends=False) if p)
139+
root = dist.location
140+
info = dist.info_directory
141+
if root is None or info is None:
142+
return paths
143+
try:
144+
info_rel = pathlib.Path(info).relative_to(root)
145+
except ValueError: # info is not relative to root.
146+
return paths
147+
if not info_rel.parts: # info *is* root.
148+
return paths
149+
return (
150+
_covert_legacy_entry(pathlib.Path(p).parts, info_rel.parts)
151+
for p in paths
152+
)
111153

112154
for query_name in query_names:
113155
try:
@@ -121,11 +163,11 @@ def _files_from_installed_files(dist: BaseDistribution) -> Optional[Iterator[str
121163
except FileNotFoundError:
122164
entry_points = []
123165

124-
files_iter = _files_from_record(dist) or _files_from_installed_files(dist)
166+
files_iter = _files_from_record(dist) or _files_from_legacy(dist)
125167
if files_iter is None:
126168
files: Optional[List[str]] = None
127169
else:
128-
files = sorted(os.path.relpath(p, dist.location) for p in files_iter)
170+
files = sorted(files_iter)
129171

130172
metadata = dist.metadata
131173

src/pip/_internal/metadata/base.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,26 @@ def location(self) -> Optional[str]:
5757
A string value is not necessarily a filesystem path, since distributions
5858
can be loaded from other sources, e.g. arbitrary zip archives. ``None``
5959
means the distribution is created in-memory.
60+
61+
Do not canonicalize this value with e.g. ``pathlib.Path.resolve()``. If
62+
this is a symbolic link, we want to preserve the relative path between
63+
it and files in the distribution.
64+
"""
65+
raise NotImplementedError()
66+
67+
@property
68+
def info_directory(self) -> Optional[str]:
69+
"""Location of the .[egg|dist]-info directory.
70+
71+
Similarly to ``location``, a string value is not necessarily a
72+
filesystem path. ``None`` means the distribution is created in-memory.
73+
74+
For a modern .dist-info installation on disk, this should be something
75+
like ``{location}/{raw_name}-{version}.dist-info``.
76+
77+
Do not canonicalize this value with e.g. ``pathlib.Path.resolve()``. If
78+
this is a symbolic link, we want to preserve the relative path between
79+
it and other files in the distribution.
6080
"""
6181
raise NotImplementedError()
6282

src/pip/_internal/metadata/pkg_resources.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ def from_wheel(cls, path: str, name: str) -> "Distribution":
4848
def location(self) -> Optional[str]:
4949
return self._dist.location
5050

51+
@property
52+
def info_directory(self) -> Optional[str]:
53+
return self._dist.egg_info
54+
5155
@property
5256
def canonical_name(self) -> "NormalizedName":
5357
return canonicalize_name(self._dist.project_name)

src/pip/_internal/operations/install/legacy.py

Lines changed: 41 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,46 @@ def __init__(self):
2525
self.parent = sys.exc_info()
2626

2727

28+
def write_installed_files_from_setuptools_record(
29+
record_lines: List[str],
30+
root: Optional[str],
31+
req_description: str,
32+
) -> None:
33+
def prepend_root(path):
34+
# type: (str) -> str
35+
if root is None or not os.path.isabs(path):
36+
return path
37+
else:
38+
return change_root(root, path)
39+
40+
for line in record_lines:
41+
directory = os.path.dirname(line)
42+
if directory.endswith('.egg-info'):
43+
egg_info_dir = prepend_root(directory)
44+
break
45+
else:
46+
message = (
47+
"{} did not indicate that it installed an "
48+
".egg-info directory. Only setup.py projects "
49+
"generating .egg-info directories are supported."
50+
).format(req_description)
51+
raise InstallationError(message)
52+
53+
new_lines = []
54+
for line in record_lines:
55+
filename = line.strip()
56+
if os.path.isdir(filename):
57+
filename += os.path.sep
58+
new_lines.append(
59+
os.path.relpath(prepend_root(filename), egg_info_dir)
60+
)
61+
new_lines.sort()
62+
ensure_dir(egg_info_dir)
63+
inst_files_path = os.path.join(egg_info_dir, 'installed-files.txt')
64+
with open(inst_files_path, 'w') as f:
65+
f.write('\n'.join(new_lines) + '\n')
66+
67+
2868
def install(
2969
install_options, # type: List[str]
3070
global_options, # type: Sequence[str]
@@ -88,38 +128,5 @@ def install(
88128
with open(record_filename) as f:
89129
record_lines = f.read().splitlines()
90130

91-
def prepend_root(path):
92-
# type: (str) -> str
93-
if root is None or not os.path.isabs(path):
94-
return path
95-
else:
96-
return change_root(root, path)
97-
98-
for line in record_lines:
99-
directory = os.path.dirname(line)
100-
if directory.endswith('.egg-info'):
101-
egg_info_dir = prepend_root(directory)
102-
break
103-
else:
104-
message = (
105-
"{} did not indicate that it installed an "
106-
".egg-info directory. Only setup.py projects "
107-
"generating .egg-info directories are supported."
108-
).format(req_description)
109-
raise InstallationError(message)
110-
111-
new_lines = []
112-
for line in record_lines:
113-
filename = line.strip()
114-
if os.path.isdir(filename):
115-
filename += os.path.sep
116-
new_lines.append(
117-
os.path.relpath(prepend_root(filename), egg_info_dir)
118-
)
119-
new_lines.sort()
120-
ensure_dir(egg_info_dir)
121-
inst_files_path = os.path.join(egg_info_dir, 'installed-files.txt')
122-
with open(inst_files_path, 'w') as f:
123-
f.write('\n'.join(new_lines) + '\n')
124-
131+
write_installed_files_from_setuptools_record(record_lines, root, req_description)
125132
return True

tests/functional/test_show.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import os
22
import re
33

4-
import pytest
5-
64
from pip import __version__
75
from pip._internal.commands.show import search_packages_info
6+
from pip._internal.operations.install.legacy import (
7+
write_installed_files_from_setuptools_record,
8+
)
9+
from pip._internal.utils.unpacking import untar_file
810
from tests.lib import create_test_package_with_setup
911

1012

@@ -41,7 +43,7 @@ def test_show_with_files_not_found(script, data):
4143

4244
def test_show_with_files_from_wheel(script, data):
4345
"""
44-
Test that a wheel's files can be listed
46+
Test that a wheel's files can be listed.
4547
"""
4648
wheel_file = data.packages.joinpath('simple.dist-0.1-py2.py3-none-any.whl')
4749
script.pip('install', '--no-index', wheel_file)
@@ -50,18 +52,36 @@ def test_show_with_files_from_wheel(script, data):
5052
assert 'Name: simple.dist' in lines
5153
assert 'Cannot locate RECORD or installed-files.txt' not in lines[6], lines[6]
5254
assert re.search(r"Files:\n( .+\n)+", result.stdout)
55+
assert f" simpledist{os.sep}__init__.py" in lines[6:]
5356

5457

55-
@pytest.mark.network
56-
def test_show_with_all_files(script):
58+
def test_show_with_files_from_legacy(tmp_path, script, data):
5759
"""
58-
Test listing all files in the show command.
60+
Test listing files in the show command (legacy installed-files.txt).
5961
"""
60-
script.pip('install', 'initools==0.2')
61-
result = script.pip('show', '--files', 'initools')
62+
# Since 'pip install' now always tries to build a wheel from sdist, it
63+
# cannot properly generate a setup. The legacy code path is basically
64+
# 'setup.py install' plus installed-files.txt, which we manually generate.
65+
source_dir = tmp_path.joinpath("unpacked-sdist")
66+
setuptools_record = tmp_path.joinpath("installed-record.txt")
67+
untar_file(data.packages.joinpath("simple-1.0.tar.gz"), str(source_dir))
68+
script.run(
69+
"python", "setup.py", "install",
70+
"--single-version-externally-managed",
71+
"--record", str(setuptools_record),
72+
cwd=source_dir,
73+
)
74+
write_installed_files_from_setuptools_record(
75+
setuptools_record.read_text().splitlines(),
76+
root=None,
77+
req_description="simple==1.0",
78+
)
79+
80+
result = script.pip('show', '--files', 'simple')
6281
lines = result.stdout.splitlines()
6382
assert 'Cannot locate RECORD or installed-files.txt' not in lines[6], lines[6]
6483
assert re.search(r"Files:\n( .+\n)+", result.stdout)
84+
assert f" simple{os.sep}__init__.py" in lines[6:]
6585

6686

6787
def test_missing_argument(script):

0 commit comments

Comments
 (0)