Skip to content

Commit 82c2dd4

Browse files
authored
Merge pull request #6203 from vinicyusmacedo/fix-pep-508
Fix is_url from splitting the scheme incorrectly when using PEP 440's direct references
2 parents 3f48765 + 16af35c commit 82c2dd4

File tree

5 files changed

+203
-22
lines changed

5 files changed

+203
-22
lines changed

docs/html/reference/pip_install.rst

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,7 @@ pip supports installing from a package index using a :term:`requirement
244244
specifier <pypug:Requirement Specifier>`. Generally speaking, a requirement
245245
specifier is composed of a project name followed by optional :term:`version
246246
specifiers <pypug:Version Specifier>`. :pep:`508` contains a full specification
247-
of the format of a requirement (pip does not support the ``url_req`` form
248-
of specifier at this time).
247+
of the format of a requirement.
249248

250249
Some examples:
251250

@@ -265,6 +264,13 @@ Since version 6.0, pip also supports specifiers containing `environment markers
265264
SomeProject ==5.4 ; python_version < '2.7'
266265
SomeProject; sys_platform == 'win32'
267266

267+
Since version 19.1, pip also supports `direct references
268+
<https://www.python.org/dev/peps/pep-0440/#direct-references>`__ like so:
269+
270+
::
271+
272+
SomeProject @ file:///somewhere/...
273+
268274
Environment markers are supported in the command line and in requirements files.
269275

270276
.. note::
@@ -880,6 +886,14 @@ Examples
880886
$ pip install http://my.package.repo/SomePackage-1.0.4.zip
881887

882888

889+
#. Install a particular source archive file following :pep:`440` direct references.
890+
891+
::
892+
893+
$ pip install SomeProject==1.0.4@http://my.package.repo//SomeProject-1.2.3-py33-none-any.whl
894+
$ pip install "SomeProject==1.0.4 @ http://my.package.repo//SomeProject-1.2.3-py33-none-any.whl"
895+
896+
883897
#. Install from alternative package repositories.
884898

885899
Install from a different index, and not `PyPI`_ ::

news/6202.bugfix

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix requirement line parser to correctly handle PEP 440 requirements with a URL
2+
pointing to an archive file.

src/pip/_internal/req/constructors.py

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,60 @@ def install_req_from_editable(
215215
)
216216

217217

218+
def _looks_like_path(name):
219+
# type: (str) -> bool
220+
"""Checks whether the string "looks like" a path on the filesystem.
221+
222+
This does not check whether the target actually exists, only judge from the
223+
appearance.
224+
225+
Returns true if any of the following conditions is true:
226+
* a path separator is found (either os.path.sep or os.path.altsep);
227+
* a dot is found (which represents the current directory).
228+
"""
229+
if os.path.sep in name:
230+
return True
231+
if os.path.altsep is not None and os.path.altsep in name:
232+
return True
233+
if name.startswith("."):
234+
return True
235+
return False
236+
237+
238+
def _get_url_from_path(path, name):
239+
# type: (str, str) -> str
240+
"""
241+
First, it checks whether a provided path is an installable directory
242+
(e.g. it has a setup.py). If it is, returns the path.
243+
244+
If false, check if the path is an archive file (such as a .whl).
245+
The function checks if the path is a file. If false, if the path has
246+
an @, it will treat it as a PEP 440 URL requirement and return the path.
247+
"""
248+
if _looks_like_path(name) and os.path.isdir(path):
249+
if is_installable_dir(path):
250+
return path_to_url(path)
251+
raise InstallationError(
252+
"Directory %r is not installable. Neither 'setup.py' "
253+
"nor 'pyproject.toml' found." % name
254+
)
255+
if not is_archive_file(path):
256+
return None
257+
if os.path.isfile(path):
258+
return path_to_url(path)
259+
urlreq_parts = name.split('@', 1)
260+
if len(urlreq_parts) >= 2 and not _looks_like_path(urlreq_parts[0]):
261+
# If the path contains '@' and the part before it does not look
262+
# like a path, try to treat it as a PEP 440 URL req instead.
263+
return None
264+
logger.warning(
265+
'Requirement %r looks like a filename, but the '
266+
'file does not exist',
267+
name
268+
)
269+
return path_to_url(path)
270+
271+
218272
def install_req_from_line(
219273
name, # type: str
220274
comes_from=None, # type: Optional[Union[str, InstallRequirement]]
@@ -255,26 +309,9 @@ def install_req_from_line(
255309
link = Link(name)
256310
else:
257311
p, extras_as_string = _strip_extras(path)
258-
looks_like_dir = os.path.isdir(p) and (
259-
os.path.sep in name or
260-
(os.path.altsep is not None and os.path.altsep in name) or
261-
name.startswith('.')
262-
)
263-
if looks_like_dir:
264-
if not is_installable_dir(p):
265-
raise InstallationError(
266-
"Directory %r is not installable. Neither 'setup.py' "
267-
"nor 'pyproject.toml' found." % name
268-
)
269-
link = Link(path_to_url(p))
270-
elif is_archive_file(p):
271-
if not os.path.isfile(p):
272-
logger.warning(
273-
'Requirement %r looks like a filename, but the '
274-
'file does not exist',
275-
name
276-
)
277-
link = Link(path_to_url(p))
312+
url = _get_url_from_path(p, name)
313+
if url is not None:
314+
link = Link(url)
278315

279316
# it's a local file, dir, or url
280317
if link:

tests/unit/test_req.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
from pip._internal.operations.prepare import RequirementPreparer
2323
from pip._internal.req import InstallRequirement, RequirementSet
2424
from pip._internal.req.constructors import (
25+
_get_url_from_path,
26+
_looks_like_path,
2527
install_req_from_editable,
2628
install_req_from_line,
2729
install_req_from_req_string,
@@ -343,6 +345,33 @@ def test_url_with_query(self):
343345
req = install_req_from_line(url + fragment)
344346
assert req.link.url == url + fragment, req.link
345347

348+
def test_pep440_wheel_link_requirement(self):
349+
url = 'https://whatever.com/test-0.4-py2.py3-bogus-any.whl'
350+
line = 'test @ https://whatever.com/test-0.4-py2.py3-bogus-any.whl'
351+
req = install_req_from_line(line)
352+
parts = str(req.req).split('@', 1)
353+
assert len(parts) == 2
354+
assert parts[0].strip() == 'test'
355+
assert parts[1].strip() == url
356+
357+
def test_pep440_url_link_requirement(self):
358+
url = 'git+http://foo.com@ref#egg=foo'
359+
line = 'foo @ git+http://foo.com@ref#egg=foo'
360+
req = install_req_from_line(line)
361+
parts = str(req.req).split('@', 1)
362+
assert len(parts) == 2
363+
assert parts[0].strip() == 'foo'
364+
assert parts[1].strip() == url
365+
366+
def test_url_with_authentication_link_requirement(self):
367+
url = 'https://[email protected]/test-0.4-py2.py3-bogus-any.whl'
368+
line = 'https://[email protected]/test-0.4-py2.py3-bogus-any.whl'
369+
req = install_req_from_line(line)
370+
assert req.link is not None
371+
assert req.link.is_wheel
372+
assert req.link.scheme == "https"
373+
assert req.link.url == url
374+
346375
def test_unsupported_wheel_link_requirement_raises(self):
347376
reqset = RequirementSet()
348377
req = install_req_from_line(
@@ -634,3 +663,95 @@ def test_mismatched_versions(caplog, tmpdir):
634663
'Requested simplewheel==2.0, '
635664
'but installing version 1.0'
636665
)
666+
667+
668+
@pytest.mark.parametrize('args, expected', [
669+
# Test UNIX-like paths
670+
(('/path/to/installable'), True),
671+
# Test relative paths
672+
(('./path/to/installable'), True),
673+
# Test current path
674+
(('.'), True),
675+
# Test url paths
676+
(('https://whatever.com/test-0.4-py2.py3-bogus-any.whl'), True),
677+
# Test pep440 paths
678+
(('test @ https://whatever.com/test-0.4-py2.py3-bogus-any.whl'), True),
679+
# Test wheel
680+
(('simple-0.1-py2.py3-none-any.whl'), False),
681+
])
682+
def test_looks_like_path(args, expected):
683+
assert _looks_like_path(args) == expected
684+
685+
686+
@pytest.mark.skipif(
687+
not sys.platform.startswith("win"),
688+
reason='Test only available on Windows'
689+
)
690+
@pytest.mark.parametrize('args, expected', [
691+
# Test relative paths
692+
(('.\\path\\to\\installable'), True),
693+
(('relative\\path'), True),
694+
# Test absolute paths
695+
(('C:\\absolute\\path'), True),
696+
])
697+
def test_looks_like_path_win(args, expected):
698+
assert _looks_like_path(args) == expected
699+
700+
701+
@pytest.mark.parametrize('args, mock_returns, expected', [
702+
# Test pep440 urls
703+
(('/path/to/foo @ git+http://foo.com@ref#egg=foo',
704+
'foo @ git+http://foo.com@ref#egg=foo'), (False, False), None),
705+
# Test pep440 urls without spaces
706+
(('/path/to/foo@git+http://foo.com@ref#egg=foo',
707+
'foo @ git+http://foo.com@ref#egg=foo'), (False, False), None),
708+
# Test pep440 wheel
709+
(('/path/to/test @ https://whatever.com/test-0.4-py2.py3-bogus-any.whl',
710+
'test @ https://whatever.com/test-0.4-py2.py3-bogus-any.whl'),
711+
(False, False), None),
712+
# Test name is not a file
713+
(('/path/to/simple==0.1',
714+
'simple==0.1'),
715+
(False, False), None),
716+
])
717+
@patch('pip._internal.req.req_install.os.path.isdir')
718+
@patch('pip._internal.req.req_install.os.path.isfile')
719+
def test_get_url_from_path(
720+
isdir_mock, isfile_mock, args, mock_returns, expected
721+
):
722+
isdir_mock.return_value = mock_returns[0]
723+
isfile_mock.return_value = mock_returns[1]
724+
assert _get_url_from_path(*args) is expected
725+
726+
727+
@patch('pip._internal.req.req_install.os.path.isdir')
728+
@patch('pip._internal.req.req_install.os.path.isfile')
729+
def test_get_url_from_path__archive_file(isdir_mock, isfile_mock):
730+
isdir_mock.return_value = False
731+
isfile_mock.return_value = True
732+
name = 'simple-0.1-py2.py3-none-any.whl'
733+
path = os.path.join('/path/to/' + name)
734+
url = path_to_url(path)
735+
assert _get_url_from_path(path, name) == url
736+
737+
738+
@patch('pip._internal.req.req_install.os.path.isdir')
739+
@patch('pip._internal.req.req_install.os.path.isfile')
740+
def test_get_url_from_path__installable_dir(isdir_mock, isfile_mock):
741+
isdir_mock.return_value = True
742+
isfile_mock.return_value = True
743+
name = 'some/setuptools/project'
744+
path = os.path.join('/path/to/' + name)
745+
url = path_to_url(path)
746+
assert _get_url_from_path(path, name) == url
747+
748+
749+
@patch('pip._internal.req.req_install.os.path.isdir')
750+
def test_get_url_from_path__installable_error(isdir_mock):
751+
isdir_mock.return_value = True
752+
name = 'some/setuptools/project'
753+
path = os.path.join('/path/to/' + name)
754+
with pytest.raises(InstallationError) as e:
755+
_get_url_from_path(path, name)
756+
err_msg = e.value.args[0]
757+
assert "Neither 'setup.py' nor 'pyproject.toml' found" in err_msg

tests/unit/test_req_file.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,13 @@ def test_yield_line_requirement(self):
225225
req = install_req_from_line(line, comes_from=comes_from)
226226
assert repr(list(process_line(line, filename, 1))[0]) == repr(req)
227227

228+
def test_yield_pep440_line_requirement(self):
229+
line = 'SomeProject @ https://url/SomeProject-py2-py3-none-any.whl'
230+
filename = 'filename'
231+
comes_from = '-r %s (line %s)' % (filename, 1)
232+
req = install_req_from_line(line, comes_from=comes_from)
233+
assert repr(list(process_line(line, filename, 1))[0]) == repr(req)
234+
228235
def test_yield_line_constraint(self):
229236
line = 'SomeProject'
230237
filename = 'filename'

0 commit comments

Comments
 (0)