Skip to content

Commit 558d86b

Browse files
authored
Merge pull request #10249 from sbidoul/list-freeze-pep610-sbi
Support PEP 610 editables in pip freeze and pip list
2 parents 9c32868 + ca05176 commit 558d86b

File tree

14 files changed

+356
-218
lines changed

14 files changed

+356
-218
lines changed

docs/html/cli/pip_list.rst

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,93 @@ Examples
139139
docopt==0.6.2
140140
idlex==1.13
141141
jedi==0.9.0
142+
143+
#. List packages installed in editable mode
144+
145+
When some packages are installed in editable mode, ``pip list`` outputs an
146+
additional column that shows the directory where the editable project is
147+
located (i.e. the directory that contains the ``pyproject.toml`` or
148+
``setup.py`` file).
149+
150+
.. tab:: Unix/macOS
151+
152+
.. code-block:: console
153+
154+
$ python -m pip list
155+
Package Version Editable project location
156+
---------------- -------- -------------------------------------
157+
pip 21.2.4
158+
pip-test-package 0.1.1 /home/you/.venv/src/pip-test-package
159+
setuptools 57.4.0
160+
wheel 0.36.2
161+
162+
163+
.. tab:: Windows
164+
165+
.. code-block:: console
166+
167+
C:\> py -m pip list
168+
Package Version Editable project location
169+
---------------- -------- ----------------------------------------
170+
pip 21.2.4
171+
pip-test-package 0.1.1 C:\Users\You\.venv\src\pip-test-package
172+
setuptools 57.4.0
173+
wheel 0.36.2
174+
175+
The json format outputs an additional ``editable_project_location`` field.
176+
177+
.. tab:: Unix/macOS
178+
179+
.. code-block:: console
180+
181+
$ python -m pip list --format=json | python -m json.tool
182+
[
183+
{
184+
"name": "pip",
185+
"version": "21.2.4",
186+
},
187+
{
188+
"name": "pip-test-package",
189+
"version": "0.1.1",
190+
"editable_project_location": "/home/you/.venv/src/pip-test-package"
191+
},
192+
{
193+
"name": "setuptools",
194+
"version": "57.4.0"
195+
},
196+
{
197+
"name": "wheel",
198+
"version": "0.36.2"
199+
}
200+
]
201+
202+
.. tab:: Windows
203+
204+
.. code-block:: console
205+
206+
C:\> py -m pip list --format=json | py -m json.tool
207+
[
208+
{
209+
"name": "pip",
210+
"version": "21.2.4",
211+
},
212+
{
213+
"name": "pip-test-package",
214+
"version": "0.1.1",
215+
"editable_project_location": "C:\Users\You\.venv\src\pip-test-package"
216+
},
217+
{
218+
"name": "setuptools",
219+
"version": "57.4.0"
220+
},
221+
{
222+
"name": "wheel",
223+
"version": "0.36.2"
224+
}
225+
]
226+
227+
.. note::
228+
229+
Contrary to the ``freeze`` comand, ``pip list --format=freeze`` will not
230+
report editable install information, but the version of the package at the
231+
time it was installed.

news/10249.feature.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Support `PEP 610 <https://www.python.org/dev/peps/pep-0610/>`_ to detect
2+
editable installs in ``pip freeze`` and ``pip list``. The ``pip list`` column output
3+
has a new ``Editable project location`` column, and the JSON output has a new
4+
``editable_project_location`` field.

src/pip/_internal/commands/list.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
from pip._internal.metadata import BaseDistribution, get_environment
1515
from pip._internal.models.selection_prefs import SelectionPreferences
1616
from pip._internal.network.session import PipSession
17-
from pip._internal.utils.misc import stdlib_pkgs, tabulate, write_output
17+
from pip._internal.utils.compat import stdlib_pkgs
18+
from pip._internal.utils.misc import tabulate, write_output
1819
from pip._internal.utils.parallel import map_multithread
1920

2021
if TYPE_CHECKING:
@@ -302,19 +303,22 @@ def format_for_columns(
302303
Convert the package data into something usable
303304
by output_package_listing_columns.
304305
"""
306+
header = ["Package", "Version"]
307+
305308
running_outdated = options.outdated
306-
# Adjust the header for the `pip list --outdated` case.
307309
if running_outdated:
308-
header = ["Package", "Version", "Latest", "Type"]
309-
else:
310-
header = ["Package", "Version"]
310+
header.extend(["Latest", "Type"])
311311

312-
data = []
313-
if options.verbose >= 1 or any(x.editable for x in pkgs):
312+
has_editables = any(x.editable for x in pkgs)
313+
if has_editables:
314+
header.append("Editable project location")
315+
316+
if options.verbose >= 1:
314317
header.append("Location")
315318
if options.verbose >= 1:
316319
header.append("Installer")
317320

321+
data = []
318322
for proj in pkgs:
319323
# if we're working on the 'outdated' list, separate out the
320324
# latest_version and type
@@ -324,7 +328,10 @@ def format_for_columns(
324328
row.append(str(proj.latest_version))
325329
row.append(proj.latest_filetype)
326330

327-
if options.verbose >= 1 or proj.editable:
331+
if has_editables:
332+
row.append(proj.editable_project_location or "")
333+
334+
if options.verbose >= 1:
328335
row.append(proj.location or "")
329336
if options.verbose >= 1:
330337
row.append(proj.installer)
@@ -347,5 +354,8 @@ def format_for_json(packages: "_ProcessedDists", options: Values) -> str:
347354
if options.outdated:
348355
info["latest_version"] = str(dist.latest_version)
349356
info["latest_filetype"] = dist.latest_filetype
357+
editable_project_location = dist.editable_project_location
358+
if editable_project_location:
359+
info["editable_project_location"] = editable_project_location
350360
data.append(info)
351361
return json.dumps(data)

src/pip/_internal/metadata/base.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
DirectUrl,
2525
DirectUrlValidationError,
2626
)
27-
from pip._internal.utils.misc import stdlib_pkgs # TODO: Move definition here.
27+
from pip._internal.utils.compat import stdlib_pkgs # TODO: Move definition here.
28+
from pip._internal.utils.egg_link import egg_link_path_from_sys_path
29+
from pip._internal.utils.urls import url_to_path
2830

2931
if TYPE_CHECKING:
3032
from typing import Protocol
@@ -73,6 +75,28 @@ def location(self) -> Optional[str]:
7375
"""
7476
raise NotImplementedError()
7577

78+
@property
79+
def editable_project_location(self) -> Optional[str]:
80+
"""The project location for editable distributions.
81+
82+
This is the directory where pyproject.toml or setup.py is located.
83+
None if the distribution is not installed in editable mode.
84+
"""
85+
# TODO: this property is relatively costly to compute, memoize it ?
86+
direct_url = self.direct_url
87+
if direct_url:
88+
if direct_url.is_local_editable():
89+
return url_to_path(direct_url.url)
90+
else:
91+
# Search for an .egg-link file by walking sys.path, as it was
92+
# done before by dist_is_editable().
93+
egg_link_path = egg_link_path_from_sys_path(self.raw_name)
94+
if egg_link_path:
95+
# TODO: get project location from second line of egg_link file
96+
# (https://github.com/pypa/pip/issues/10243)
97+
return self.location
98+
return None
99+
76100
@property
77101
def info_directory(self) -> Optional[str]:
78102
"""Location of the .[egg|dist]-info directory.
@@ -129,7 +153,7 @@ def installer(self) -> str:
129153

130154
@property
131155
def editable(self) -> bool:
132-
raise NotImplementedError()
156+
return bool(self.editable_project_location)
133157

134158
@property
135159
def local(self) -> bool:

src/pip/_internal/metadata/pkg_resources.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,6 @@ def version(self) -> DistributionVersion:
6969
def installer(self) -> str:
7070
return get_installer(self._dist)
7171

72-
@property
73-
def editable(self) -> bool:
74-
return misc.dist_is_editable(self._dist)
75-
7672
@property
7773
def local(self) -> bool:
7874
return misc.dist_is_local(self._dist)

src/pip/_internal/models/direct_url.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,3 +215,6 @@ def from_json(cls, s: str) -> "DirectUrl":
215215

216216
def to_json(self) -> str:
217217
return json.dumps(self.to_dict(), sort_keys=True)
218+
219+
def is_local_editable(self) -> bool:
220+
return isinstance(self.info, DirInfo) and self.info.editable

src/pip/_internal/operations/freeze.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -169,16 +169,9 @@ def _get_editable_info(dist: BaseDistribution) -> _EditableInfo:
169169
"""
170170
if not dist.editable:
171171
return _EditableInfo(requirement=None, editable=False, comments=[])
172-
if dist.location is None:
173-
display = _format_as_name_version(dist)
174-
logger.warning("Editable requirement not found on disk: %s", display)
175-
return _EditableInfo(
176-
requirement=None,
177-
editable=True,
178-
comments=[f"# Editable install not found ({display})"],
179-
)
180-
181-
location = os.path.normcase(os.path.abspath(dist.location))
172+
editable_project_location = dist.editable_project_location
173+
assert editable_project_location
174+
location = os.path.normcase(os.path.abspath(editable_project_location))
182175

183176
from pip._internal.vcs import RemoteNotFoundError, RemoteNotValidError, vcs
184177

src/pip/_internal/req/req_uninstall.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212
from pip._internal.exceptions import UninstallationError
1313
from pip._internal.locations import get_bin_prefix, get_bin_user
1414
from pip._internal.utils.compat import WINDOWS
15+
from pip._internal.utils.egg_link import egg_link_path_from_location
1516
from pip._internal.utils.logging import getLogger, indent_log
1617
from pip._internal.utils.misc import (
1718
ask,
1819
dist_in_usersite,
1920
dist_is_local,
20-
egg_link_path,
2121
is_local,
2222
normalize_path,
2323
renames,
@@ -459,7 +459,7 @@ def from_dist(cls, dist: Distribution) -> "UninstallPathSet":
459459
return cls(dist)
460460

461461
paths_to_remove = cls(dist)
462-
develop_egg_link = egg_link_path(dist)
462+
develop_egg_link = egg_link_path_from_location(dist.project_name)
463463
develop_egg_link_egg_info = "{}.egg-info".format(
464464
pkg_resources.to_filename(dist.project_name)
465465
)

src/pip/_internal/utils/egg_link.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# The following comment should be removed at some point in the future.
2+
# mypy: strict-optional=False
3+
4+
import os
5+
import re
6+
import sys
7+
from typing import Optional
8+
9+
from pip._internal.locations import site_packages, user_site
10+
from pip._internal.utils.virtualenv import (
11+
running_under_virtualenv,
12+
virtualenv_no_global,
13+
)
14+
15+
__all__ = [
16+
"egg_link_path_from_sys_path",
17+
"egg_link_path_from_location",
18+
]
19+
20+
21+
def _egg_link_name(raw_name: str) -> str:
22+
"""
23+
Convert a Name metadata value to a .egg-link name, by applying
24+
the same substitution as pkg_resources's safe_name function.
25+
Note: we cannot use canonicalize_name because it has a different logic.
26+
"""
27+
return re.sub("[^A-Za-z0-9.]+", "-", raw_name) + ".egg-link"
28+
29+
30+
def egg_link_path_from_sys_path(raw_name: str) -> Optional[str]:
31+
"""
32+
Look for a .egg-link file for project name, by walking sys.path.
33+
"""
34+
egg_link_name = _egg_link_name(raw_name)
35+
for path_item in sys.path:
36+
egg_link = os.path.join(path_item, egg_link_name)
37+
if os.path.isfile(egg_link):
38+
return egg_link
39+
return None
40+
41+
42+
def egg_link_path_from_location(raw_name: str) -> Optional[str]:
43+
"""
44+
Return the path for the .egg-link file if it exists, otherwise, None.
45+
46+
There's 3 scenarios:
47+
1) not in a virtualenv
48+
try to find in site.USER_SITE, then site_packages
49+
2) in a no-global virtualenv
50+
try to find in site_packages
51+
3) in a yes-global virtualenv
52+
try to find in site_packages, then site.USER_SITE
53+
(don't look in global location)
54+
55+
For #1 and #3, there could be odd cases, where there's an egg-link in 2
56+
locations.
57+
58+
This method will just return the first one found.
59+
"""
60+
sites = []
61+
if running_under_virtualenv():
62+
sites.append(site_packages)
63+
if not virtualenv_no_global() and user_site:
64+
sites.append(user_site)
65+
else:
66+
if user_site:
67+
sites.append(user_site)
68+
sites.append(site_packages)
69+
70+
egg_link_name = _egg_link_name(raw_name)
71+
for site in sites:
72+
egglink = os.path.join(site, egg_link_name)
73+
if os.path.isfile(egglink):
74+
return egglink
75+
return None

0 commit comments

Comments
 (0)