Repository: pypa/pip

Issue:
pip fails to install remote dependency when it is a .whl and follows PEP 440
**Environment**

* pip version: `pip 18.1`
* Python version: `Python 2.7.12`
* OS: `Ubuntu 16.04.1 - Linux 4.15.0-42-generic`

<!-- Feel free to add more information about your environment here -->
I'm using `virtualenv` on the version `15.2.0`

**Description**
<!-- A clear and concise description of what the bug is. -->
When following [PEP 508](https://www.python.org/dev/peps/pep-0508/) and PEP 440's [direct references](
https://www.python.org/dev/peps/pep-0440/#direct-references) and adding a dependency to a file, say `requirements.txt` like this:

```boto @ https://files.pythonhosted.org/packages/23/10/c0b78c27298029e4454a472a1919bde20cb182dab1662cec7f2ca1dcc523/boto-2.49.0-py2.py3-none-any.whl```

doing a `pip install -r requirements.txt` gives this error.

```
Requirement u'boto @ git+https://files.pythonhosted.org/packages/23/10/c0b78c27298029e4454a472a1919bde20cb182dab1662cec7f2ca1dcc523/boto-2.49.0-py2.py3-none-any.whl' looks like a filename, but the file does not exist
Processing ./boto @ git+https:/files.pythonhosted.org/packages/23/10/c0b78c27298029e4454a472a1919bde20cb182dab1662cec7f2ca1dcc523/boto-2.49.0-py2.py3-none-any.whl
Could not install packages due to an EnvironmentError: [Errno 2] No such file or directory: '/home/vinicyusmacedo/Documents/Development/myapp/boto @ git+https:/files.pythonhosted.org/packages/23/10/c0b78c27298029e4454a472a1919bde20cb182dab1662cec7f2ca1dcc523/boto-2.49.0-py2.py3-none-any.whl'
```

**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
Pip should download and install the dependency. 

```
pip install -r tests.txt
Collecting boto@ https://files.pythonhosted.org/packages/23/10/c0b78c27298029e4454a472a1919bde20cb182dab1662cec7f2ca1dcc523/boto-2.49.0-py2.py3-none-any.whl# from https://files.pythonhosted.org/packages/23/10/c0b78c27298029e4454a472a1919bde20cb182dab1662cec7f2ca1dcc523/boto-2.49.0-py2.py3-none-any.whl# (from -r tests.txt (line 1))
  Using cached https://files.pythonhosted.org/packages/23/10/c0b78c27298029e4454a472a1919bde20cb182dab1662cec7f2ca1dcc523/boto-2.49.0-py2.py3-none-any.whl
Installing collected packages: boto
Successfully installed boto-2.49.0
```

Note that including a `#` to the end of the URL works just fine. 

**How to Reproduce**
<!-- Describe the steps to reproduce this bug. -->

1. Create a file (say `requirements.txt`) with some URLs to remote .whl files following PEP 440's direct references
2. Run `pip install -r requirements.txt`
3. An error occurs.


Discussion:

Resolving patch:
diff --git a/src/pip/_internal/req/constructors.py b/src/pip/_internal/req/constructors.py
--- 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:


Test patch:
diff --git a/tests/unit/test_req.py b/tests/unit/test_req.py
--- 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
--- 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'