Skip to content

Commit 8850376

Browse files
ichard26uranusjr
andcommitted
Support Direct URL editable requirements
This is crucial for the deprecation of non-bare egg URL fragments as they used to be the sole way to request an extra for a VCS editable install. I've also removed all references to egg fragments from the user docs and added further explanation of the Direct URL form in the VCS support topic. Getting the error handling right was a pain. That's why I extracted the two parsing flows into their own functions. Co-authored-by: Tzu-ping Chung <[email protected]>
1 parent a4b40f6 commit 8850376

File tree

8 files changed

+159
-37
lines changed

8 files changed

+159
-37
lines changed

docs/html/cli/pip_install.rst

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ for the name and project version (this is in theory slightly less reliable
6666
than using the ``egg_info`` command, but avoids downloading and processing
6767
unnecessary numbers of files).
6868

69-
Any URL may use the ``#egg=name`` syntax (see :doc:`../topics/vcs-support`) to
70-
explicitly state the project name.
69+
The :pep:`508` requirement syntax can be used to explicitly state the project
70+
name (see :doc:`../topics/vcs-support`).
7171

7272
Satisfying Requirements
7373
-----------------------
@@ -367,21 +367,21 @@ Examples
367367

368368
.. code-block:: shell
369369
370-
python -m pip install -e 'git+https://git.repo/some_pkg.git#egg=SomePackage' # from git
371-
python -m pip install -e 'hg+https://hg.repo/some_pkg.git#egg=SomePackage' # from mercurial
372-
python -m pip install -e 'svn+svn://svn.repo/some_pkg/trunk/#egg=SomePackage' # from svn
373-
python -m pip install -e 'git+https://git.repo/some_pkg.git@feature#egg=SomePackage' # from 'feature' branch
374-
python -m pip install -e 'git+https://git.repo/some_repo.git#egg=subdir&subdirectory=subdir_path' # install a python package from a repo subdirectory
370+
python -m pip install -e 'SomePackage @ git+https://git.repo/some_pkg.git' # from git
371+
python -m pip install -e 'SomePackage @ hg+https://hg.repo/some_pkg.git' # from mercurial
372+
python -m pip install -e 'SomePakcage @ svn+svn://svn.repo/some_pkg/trunk/' # from svn
373+
python -m pip install -e 'SomePackage @ git+https://git.repo/some_pkg.git@feature' # from 'feature' branch
374+
python -m pip install -e 'subdir @ git+https://git.repo/some_repo.git#subdirectory=subdir_path' # install a python package from a repo subdirectory
375375
376376
.. tab:: Windows
377377

378378
.. code-block:: shell
379379
380-
py -m pip install -e "git+https://git.repo/some_pkg.git#egg=SomePackage" # from git
381-
py -m pip install -e "hg+https://hg.repo/some_pkg.git#egg=SomePackage" # from mercurial
382-
py -m pip install -e "svn+svn://svn.repo/some_pkg/trunk/#egg=SomePackage" # from svn
383-
py -m pip install -e "git+https://git.repo/some_pkg.git@feature#egg=SomePackage" # from 'feature' branch
384-
py -m pip install -e "git+https://git.repo/some_repo.git#egg=subdir&subdirectory=subdir_path" # install a python package from a repo subdirectory
380+
py -m pip install -e "SomePackage @ git+https://git.repo/some_pkg.git" # from git
381+
py -m pip install -e "SomePackage @ hg+https://hg.repo/some_pkg.git" # from mercurial
382+
py -m pip install -e "SomePackage @ svn+svn://svn.repo/some_pkg/trunk/" # from svn
383+
py -m pip install -e "SomePackage @ git+https://git.repo/some_pkg.git@feature" # from 'feature' branch
384+
py -m pip install -e "subdir @ git+https://git.repo/some_repo.git#subdirectory=subdir_path" # install a python package from a repo subdirectory
385385
386386
#. Install a package with extras, i.e., optional dependencies
387387
(:ref:`specification <pypug:dependency-specifiers>`).

docs/html/topics/vcs-support.md

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,25 @@ control system being used). It is used through URL prefixes:
1010
- Subversion -- `svn+`
1111
- Bazaar -- `bzr+`
1212

13+
The general form of a VCS requirement is `ProjectName @ VCS_URL`, e.g.
14+
15+
```none
16+
MyProject @ git+https://git.example.com/MyProject
17+
MyProject[extra] @ git+https:/git.example.com/MyProject
18+
```
19+
20+
This is the Direct URL ({pep}`508`) requirement syntax. It is also permissible
21+
to remove `MyProject @` portion is removed and provide a bare VCS URL.
22+
23+
```none
24+
git+https://git.example.com/MyProject
25+
```
26+
27+
This is a pip specific extension. This form can be used as long as pip does
28+
not need to know the project name in advance. pip is generally able to infer
29+
the project name except in the case of {ref}`editable-vcs-installs`. In
30+
addition, extras cannot be requested using a bare VCS URL.
31+
1332
## Supported VCS
1433

1534
### Git
@@ -81,8 +100,8 @@ MyProject @ svn+ssh://[email protected]/MyProject
81100
You can also give specific revisions to an SVN URL, like so:
82101

83102
```none
84-
-e svn+http://svn.example.com/svn/MyProject/trunk@2019#egg=MyProject
85-
-e svn+http://svn.example.com/svn/MyProject/trunk@{20080101}#egg=MyProject
103+
-e MyProject @ svn+http://svn.example.com/svn/MyProject/trunk@2019
104+
-e MyProject @ svn+http://svn.example.com/svn/MyProject/trunk@{20080101}
86105
```
87106

88107
Note that you need to use [Editable VCS installs](#editable-vcs-installs) for
@@ -115,6 +134,9 @@ MyProject @ bzr+http://bzr.example.com/MyProject/[email protected]
115134
VCS projects can be installed in {ref}`editable mode <editable-installs>` (using
116135
the {ref}`--editable <install_--editable>` option) or not.
117136

137+
In editable mode, the project name must be provided upfront using the Direct URL
138+
(`MyProject @ URL`) form so pip can determine the VCS clone location.
139+
118140
- The default clone location (for editable installs) is:
119141

120142
- `<venv path>/src/SomeProject` in virtual environments
@@ -133,15 +155,15 @@ take on the VCS requirement (not the commit itself).
133155
## URL fragments
134156

135157
pip looks at the `subdirectory` fragments of VCS URLs for specifying the path to the
136-
Python package, when it is not in the root of the VCS directory. eg: `pkg_dir`.
158+
Python package, when it is not in the root of the VCS directory.
137159

138-
pip also looks at the `egg` fragment specifying the "project name". In practice the
139-
`egg` fragment is only required to help pip determine the VCS clone location in editable
140-
mode. In all other circumstances, the `egg` fragment is not necessary and its use is
141-
discouraged.
160+
```{note}
161+
pip also supports an `egg` fragment to specify the "project name". This is a legacy
162+
feature and its use is discouraged in favour of the Direct URL ({pep}`508`) form.
142163
143164
The `egg` fragment **should** be a bare {ref}`project name <pypug:name-normalization>`.
144165
Anything else is not guaranteed to work.
166+
```
145167

146168
````{admonition} Example
147169
If your repository layout is:
@@ -164,6 +186,6 @@ $ pip install "pkg @ vcs+protocol://repo_url/#subdirectory=pkg_dir"
164186
or:
165187
166188
```{pip-cli}
167-
$ pip install -e "vcs+protocol://repo_url/#egg=pkg&subdirectory=pkg_dir"
189+
$ pip install -e "pkg @ vcs+protocol://repo_url/#subdirectory=pkg_dir"
168190
```
169191
````

docs/html/user_guide.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ In practice, there are 4 common uses of Requirements files:
183183
``sometag``. You'd reference it in your requirements file with a line like
184184
so::
185185

186-
git+https://myvcs.com/some_dependency@sometag#egg=SomeDependency
186+
SomeDependency @ git+https://myvcs.com/some_dependency@sometag
187187

188188
If ``SomeDependency`` was previously a top-level requirement in your
189189
requirements file, then **replace** that line with the new line. If

news/13495.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support installing an editable requirement written as a Direct URL.

src/pip/_internal/req/constructors.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -86,17 +86,25 @@ def _set_requirement_extras(req: Requirement, new_extras: set[str]) -> Requireme
8686
return get_requirement(f"{pre}{extras}{post}")
8787

8888

89-
def parse_editable(editable_req: str) -> tuple[str | None, str, set[str]]:
90-
"""Parses an editable requirement into:
91-
- a requirement name
92-
- an URL
93-
- extras
94-
- editable options
95-
Accepted requirements:
96-
svn+http://blahblah@rev#egg=Foobar[baz]&subdirectory=version_subdir
97-
.[some_extra]
98-
"""
89+
def _parse_direct_url_editable(editable_req: str) -> tuple[str | None, str, set[str]]:
90+
try:
91+
req = Requirement(editable_req)
92+
except InvalidRequirement:
93+
pass
94+
else:
95+
if req.url:
96+
# Join the marker back into the name part. This will be parsed out
97+
# later into a Requirement again.
98+
if req.marker:
99+
name = f"{req.name} ; {req.marker}"
100+
else:
101+
name = req.name
102+
return (name, req.url, req.extras)
103+
104+
raise ValueError
99105

106+
107+
def _parse_pip_syntax_editable(editable_req: str) -> tuple[str | None, str, set[str]]:
100108
url = editable_req
101109

102110
# If a file path is specified with extras, strip off the extras.
@@ -122,23 +130,41 @@ def parse_editable(editable_req: str) -> tuple[str | None, str, set[str]]:
122130
url = f"{version_control}+{url}"
123131
break
124132

133+
return Link(url).egg_fragment, url, set()
134+
135+
136+
def parse_editable(editable_req: str) -> tuple[str | None, str, set[str]]:
137+
"""Parses an editable requirement into:
138+
- a requirement name
139+
- an URL
140+
- extras
141+
Accepted requirements:
142+
- svn+http://blahblah@rev#egg=Foobar[baz]&subdirectory=version_subdir
143+
- local_path[some_extra]
144+
- Foobar[extra] @ svn+http://blahblah@rev#subdirectory=subdir ; markers
145+
"""
146+
try:
147+
package_name, url, extras = _parse_direct_url_editable(editable_req)
148+
except ValueError:
149+
package_name, url, extras = _parse_pip_syntax_editable(editable_req)
150+
125151
link = Link(url)
126152

127-
if not link.is_vcs:
153+
if not link.is_vcs and not link.url.startswith("file:"):
128154
backends = ", ".join(vcs.all_schemes)
129155
raise InstallationError(
130156
f"{editable_req} is not a valid editable requirement. "
131157
f"It should either be a path to a local project or a VCS URL "
132158
f"(beginning with {backends})."
133159
)
134160

135-
package_name = link.egg_fragment
136-
if not package_name:
161+
# The project name can be inferred from local file URIs easily.
162+
if not package_name and not link.url.startswith("file:"):
137163
raise InstallationError(
138164
f"Could not detect requirement name for '{editable_req}', "
139-
"please specify one with #egg=your_package_name"
165+
"please specify one with your_package_name @ URL"
140166
)
141-
return package_name, url, set()
167+
return package_name, url, extras
142168

143169

144170
def check_first_requirement_in_file(filename: str) -> None:

src/pip/_internal/resolution/resolvelib/candidates.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,12 @@ def make_install_req_from_editable(
8585
link: Link, template: InstallRequirement
8686
) -> InstallRequirement:
8787
assert template.editable, "template not editable"
88+
if template.name:
89+
req_string = f"{template.name} @ {link.url}"
90+
else:
91+
req_string = link.url
8892
ireq = install_req_from_editable(
89-
link.url,
93+
req_string,
9094
user_supplied=template.user_supplied,
9195
comes_from=template.comes_from,
9296
use_pep517=template.use_pep517,

tests/functional/test_install_reqs.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
PipTestEnvironment,
1111
ResolverVariant,
1212
TestData,
13+
_create_test_package,
1314
_create_test_package_with_subdirectory,
1415
create_basic_sdist_for_package,
1516
create_basic_wheel_for_package,
@@ -941,3 +942,52 @@ def test_nonpep517_setuptools_import_failure(script: PipTestEnvironment) -> None
941942
exc_message = "ImportError: this 'setuptools' was intentionally poisoned"
942943
assert nice_message in result.stderr
943944
assert exc_message in result.stderr
945+
946+
947+
class TestEditableDirectURL:
948+
def test_install_local_project(
949+
self, script: PipTestEnvironment, data: TestData, common_wheels: Path
950+
) -> None:
951+
uri = (data.src / "simplewheel-2.0").as_uri()
952+
script.pip(
953+
"install", "--no-index", "-e", f"simplewheel @ {uri}", "-f", common_wheels
954+
)
955+
script.assert_installed(simplewheel="2.0")
956+
957+
def test_install_local_project_with_extra(
958+
self, script: PipTestEnvironment, data: TestData, common_wheels: Path
959+
) -> None:
960+
uri = (data.src / "requires_simple_extra").as_uri()
961+
script.pip(
962+
"install",
963+
"--no-index",
964+
"-e",
965+
f"requires-simple-extra[extra] @ {uri}",
966+
"-f",
967+
common_wheels,
968+
"-f",
969+
data.packages,
970+
)
971+
script.assert_installed(requires_simple_extra="0.1")
972+
script.assert_installed(simple="1.0")
973+
974+
def test_install_local_git_repo(
975+
self, script: PipTestEnvironment, common_wheels: Path
976+
) -> None:
977+
repo_path = _create_test_package(script.scratch_path, "simple")
978+
url = "git+" + repo_path.as_uri()
979+
script.pip(
980+
"install", "--no-index", "-e", f"simple @ {url}", "-f", common_wheels
981+
)
982+
script.assert_installed(simple="0.1")
983+
984+
@pytest.mark.network
985+
def test_install_remote_git_repo_with_extra(
986+
self, script: PipTestEnvironment, data: TestData, common_wheels: Path
987+
) -> None:
988+
req = "pip-test-package[extra] @ git+https://github.com/pypa/pip-test-package"
989+
script.pip(
990+
"install", "--no-index", "-e", req, "-f", common_wheels, "-f", data.packages
991+
)
992+
script.assert_installed(pip_test_package="0.1.1")
993+
script.assert_installed(simple="3.0")

tests/unit/test_req.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,25 @@ def test_install_req_extend_extras(
853853
assert extended.permit_editable_wheels == req.permit_editable_wheels
854854

855855

856+
@pytest.mark.parametrize(
857+
"req_str, expected",
858+
[
859+
(
860+
'foo[extra] @ svn+http://foo ; os_name == "nt"',
861+
('foo ; os_name == "nt"', "svn+http://foo", {"extra"}),
862+
),
863+
(
864+
"foo @ svn+http://foo",
865+
("foo", "svn+http://foo", set()),
866+
),
867+
],
868+
)
869+
def test_parse_editable_pep508(
870+
req_str: str, expected: tuple[str, str, set[str]]
871+
) -> None:
872+
assert parse_editable(req_str) == expected
873+
874+
856875
@mock.patch("pip._internal.req.req_install.os.path.abspath")
857876
@mock.patch("pip._internal.req.req_install.os.path.exists")
858877
@mock.patch("pip._internal.req.req_install.os.path.isdir")

0 commit comments

Comments
 (0)