diff --git a/docs/html/reference/pip_install.rst b/docs/html/reference/pip_install.rst
index f56dc9a0e9f..fa475c462c8 100644
--- a/docs/html/reference/pip_install.rst
+++ b/docs/html/reference/pip_install.rst
@@ -244,8 +244,7 @@ pip supports installing from a package index using a :term:`requirement
specifier `. Generally speaking, a requirement
specifier is composed of a project name followed by optional :term:`version
specifiers `. :pep:`508` contains a full specification
-of the format of a requirement (pip does not support the ``url_req`` form
-of specifier at this time).
+of the format of a requirement.
Some examples:
@@ -265,6 +264,13 @@ Since version 6.0, pip also supports specifiers containing `environment markers
SomeProject ==5.4 ; python_version < '2.7'
SomeProject; sys_platform == 'win32'
+Since version 19.1, pip also supports `direct references
+`__ like so:
+
+ ::
+
+ SomeProject @ file:///somewhere/...
+
Environment markers are supported in the command line and in requirements files.
.. note::
@@ -880,6 +886,14 @@ Examples
$ pip install http://my.package.repo/SomePackage-1.0.4.zip
+#. Install a particular source archive file following :pep:`440` direct references.
+
+ ::
+
+ $ pip install SomeProject==1.0.4@http://my.package.repo//SomeProject-1.2.3-py33-none-any.whl
+ $ pip install "SomeProject==1.0.4 @ http://my.package.repo//SomeProject-1.2.3-py33-none-any.whl"
+
+
#. Install from alternative package repositories.
Install from a different index, and not `PyPI`_ ::
diff --git a/news/6202.bugfix b/news/6202.bugfix
new file mode 100644
index 00000000000..03184fa8d93
--- /dev/null
+++ b/news/6202.bugfix
@@ -0,0 +1,2 @@
+Fix requirement line parser to correctly handle PEP 440 requirements with a URL
+pointing to an archive file.
diff --git a/src/pip/_internal/req/constructors.py b/src/pip/_internal/req/constructors.py
index 28cef933221..cacf84bfc85 100644
--- a/src/pip/_internal/req/constructors.py
+++ b/src/pip/_internal/req/constructors.py
@@ -201,6 +201,60 @@ def install_req_from_editable(
)
+def _looks_like_path(name):
+ # type: (str) -> bool
+ """Checks whether the string "looks like" a path on the filesystem.
+
+ This does not check whether the target actually exists, only judge from the
+ appearance.
+
+ Returns true if any of the following conditions is true:
+ * a path separator is found (either os.path.sep or os.path.altsep);
+ * a dot is found (which represents the current directory).
+ """
+ if os.path.sep in name:
+ return True
+ if os.path.altsep is not None and os.path.altsep in name:
+ return True
+ if name.startswith("."):
+ return True
+ return False
+
+
+def _get_url_from_path(path, name):
+ # type: (str, str) -> str
+ """
+ First, it checks whether a provided path is an installable directory
+ (e.g. it has a setup.py). If it is, returns the path.
+
+ If false, check if the path is an archive file (such as a .whl).
+ The function checks if the path is a file. If false, if the path has
+ an @, it will treat it as a PEP 440 URL requirement and return the path.
+ """
+ if _looks_like_path(name) and os.path.isdir(path):
+ if is_installable_dir(path):
+ return path_to_url(path)
+ raise InstallationError(
+ "Directory %r is not installable. Neither 'setup.py' "
+ "nor 'pyproject.toml' found." % name
+ )
+ if not is_archive_file(path):
+ return None
+ if os.path.isfile(path):
+ return path_to_url(path)
+ urlreq_parts = name.split('@', 1)
+ if len(urlreq_parts) >= 2 and not _looks_like_path(urlreq_parts[0]):
+ # If the path contains '@' and the part before it does not look
+ # like a path, try to treat it as a PEP 440 URL req instead.
+ return None
+ logger.warning(
+ 'Requirement %r looks like a filename, but the '
+ 'file does not exist',
+ name
+ )
+ return path_to_url(path)
+
+
def install_req_from_line(
name, # type: str
comes_from=None, # type: Optional[Union[str, InstallRequirement]]
@@ -241,26 +295,9 @@ def install_req_from_line(
link = Link(name)
else:
p, extras_as_string = _strip_extras(path)
- looks_like_dir = os.path.isdir(p) and (
- os.path.sep in name or
- (os.path.altsep is not None and os.path.altsep in name) or
- name.startswith('.')
- )
- if looks_like_dir:
- if not is_installable_dir(p):
- raise InstallationError(
- "Directory %r is not installable. Neither 'setup.py' "
- "nor 'pyproject.toml' found." % name
- )
- link = Link(path_to_url(p))
- elif is_archive_file(p):
- if not os.path.isfile(p):
- logger.warning(
- 'Requirement %r looks like a filename, but the '
- 'file does not exist',
- name
- )
- link = Link(path_to_url(p))
+ url = _get_url_from_path(p, name)
+ if url is not None:
+ link = Link(url)
# it's a local file, dir, or url
if link:
diff --git a/tests/unit/test_req.py b/tests/unit/test_req.py
index ebd3e8f03bf..8151586e5e2 100644
--- a/tests/unit/test_req.py
+++ b/tests/unit/test_req.py
@@ -21,6 +21,8 @@
from pip._internal.operations.prepare import RequirementPreparer
from pip._internal.req import InstallRequirement, RequirementSet
from pip._internal.req.constructors import (
+ _get_url_from_path,
+ _looks_like_path,
install_req_from_editable,
install_req_from_line,
parse_editable,
@@ -335,6 +337,33 @@ def test_url_with_query(self):
req = install_req_from_line(url + fragment)
assert req.link.url == url + fragment, req.link
+ def test_pep440_wheel_link_requirement(self):
+ url = 'https://whatever.com/test-0.4-py2.py3-bogus-any.whl'
+ line = 'test @ https://whatever.com/test-0.4-py2.py3-bogus-any.whl'
+ req = install_req_from_line(line)
+ parts = str(req.req).split('@', 1)
+ assert len(parts) == 2
+ assert parts[0].strip() == 'test'
+ assert parts[1].strip() == url
+
+ def test_pep440_url_link_requirement(self):
+ url = 'git+http://foo.com@ref#egg=foo'
+ line = 'foo @ git+http://foo.com@ref#egg=foo'
+ req = install_req_from_line(line)
+ parts = str(req.req).split('@', 1)
+ assert len(parts) == 2
+ assert parts[0].strip() == 'foo'
+ assert parts[1].strip() == url
+
+ def test_url_with_authentication_link_requirement(self):
+ url = 'https://what@whatever.com/test-0.4-py2.py3-bogus-any.whl'
+ line = 'https://what@whatever.com/test-0.4-py2.py3-bogus-any.whl'
+ req = install_req_from_line(line)
+ assert req.link is not None
+ assert req.link.is_wheel
+ assert req.link.scheme == "https"
+ assert req.link.url == url
+
def test_unsupported_wheel_link_requirement_raises(self):
reqset = RequirementSet()
req = install_req_from_line(
@@ -626,3 +655,95 @@ def test_mismatched_versions(caplog, tmpdir):
'Requested simplewheel==2.0, '
'but installing version 1.0'
)
+
+
+@pytest.mark.parametrize('args, expected', [
+ # Test UNIX-like paths
+ (('/path/to/installable'), True),
+ # Test relative paths
+ (('./path/to/installable'), True),
+ # Test current path
+ (('.'), True),
+ # Test url paths
+ (('https://whatever.com/test-0.4-py2.py3-bogus-any.whl'), True),
+ # Test pep440 paths
+ (('test @ https://whatever.com/test-0.4-py2.py3-bogus-any.whl'), True),
+ # Test wheel
+ (('simple-0.1-py2.py3-none-any.whl'), False),
+])
+def test_looks_like_path(args, expected):
+ assert _looks_like_path(args) == expected
+
+
+@pytest.mark.skipif(
+ not sys.platform.startswith("win"),
+ reason='Test only available on Windows'
+)
+@pytest.mark.parametrize('args, expected', [
+ # Test relative paths
+ (('.\\path\\to\\installable'), True),
+ (('relative\\path'), True),
+ # Test absolute paths
+ (('C:\\absolute\\path'), True),
+])
+def test_looks_like_path_win(args, expected):
+ assert _looks_like_path(args) == expected
+
+
+@pytest.mark.parametrize('args, mock_returns, expected', [
+ # Test pep440 urls
+ (('/path/to/foo @ git+http://foo.com@ref#egg=foo',
+ 'foo @ git+http://foo.com@ref#egg=foo'), (False, False), None),
+ # Test pep440 urls without spaces
+ (('/path/to/foo@git+http://foo.com@ref#egg=foo',
+ 'foo @ git+http://foo.com@ref#egg=foo'), (False, False), None),
+ # Test pep440 wheel
+ (('/path/to/test @ https://whatever.com/test-0.4-py2.py3-bogus-any.whl',
+ 'test @ https://whatever.com/test-0.4-py2.py3-bogus-any.whl'),
+ (False, False), None),
+ # Test name is not a file
+ (('/path/to/simple==0.1',
+ 'simple==0.1'),
+ (False, False), None),
+])
+@patch('pip._internal.req.req_install.os.path.isdir')
+@patch('pip._internal.req.req_install.os.path.isfile')
+def test_get_url_from_path(
+ isdir_mock, isfile_mock, args, mock_returns, expected
+):
+ isdir_mock.return_value = mock_returns[0]
+ isfile_mock.return_value = mock_returns[1]
+ assert _get_url_from_path(*args) is expected
+
+
+@patch('pip._internal.req.req_install.os.path.isdir')
+@patch('pip._internal.req.req_install.os.path.isfile')
+def test_get_url_from_path__archive_file(isdir_mock, isfile_mock):
+ isdir_mock.return_value = False
+ isfile_mock.return_value = True
+ name = 'simple-0.1-py2.py3-none-any.whl'
+ path = os.path.join('/path/to/' + name)
+ url = path_to_url(path)
+ assert _get_url_from_path(path, name) == url
+
+
+@patch('pip._internal.req.req_install.os.path.isdir')
+@patch('pip._internal.req.req_install.os.path.isfile')
+def test_get_url_from_path__installable_dir(isdir_mock, isfile_mock):
+ isdir_mock.return_value = True
+ isfile_mock.return_value = True
+ name = 'some/setuptools/project'
+ path = os.path.join('/path/to/' + name)
+ url = path_to_url(path)
+ assert _get_url_from_path(path, name) == url
+
+
+@patch('pip._internal.req.req_install.os.path.isdir')
+def test_get_url_from_path__installable_error(isdir_mock):
+ isdir_mock.return_value = True
+ name = 'some/setuptools/project'
+ path = os.path.join('/path/to/' + name)
+ with pytest.raises(InstallationError) as e:
+ _get_url_from_path(path, name)
+ err_msg = e.value.args[0]
+ assert "Neither 'setup.py' nor 'pyproject.toml' found" in err_msg
diff --git a/tests/unit/test_req_file.py b/tests/unit/test_req_file.py
index 3ebb55d3cfc..3bc208f34bb 100644
--- a/tests/unit/test_req_file.py
+++ b/tests/unit/test_req_file.py
@@ -225,6 +225,13 @@ def test_yield_line_requirement(self):
req = install_req_from_line(line, comes_from=comes_from)
assert repr(list(process_line(line, filename, 1))[0]) == repr(req)
+ def test_yield_pep440_line_requirement(self):
+ line = 'SomeProject @ https://url/SomeProject-py2-py3-none-any.whl'
+ filename = 'filename'
+ comes_from = '-r %s (line %s)' % (filename, 1)
+ req = install_req_from_line(line, comes_from=comes_from)
+ assert repr(list(process_line(line, filename, 1))[0]) == repr(req)
+
def test_yield_line_constraint(self):
line = 'SomeProject'
filename = 'filename'