Repository: pypa/pip

Issue:
pip doesn't allow installing modules from a sub-directory of a git repository in non-editable mode
The following works just fine:

``` fish
user@host ~/test> pip install -e 'git+http://git.mydomain.com/project.git#egg=project-sdk&subdirectory=sdk/python'
```

But the following doesn't:

``` fish
user@host ~/test> pip install 'git+http://git.mydomain.com/project.git#egg=project-sdk&subdirectory=sdk/python'
Collecting project from git+http://git.mydomain.com/project.git#egg=project-sdk&subdirectory=sdk/python
Cloning git+http://git.mydomain.com/project.git to /tmp/160806751.tmpdir/pip-build-XR8vJn/project-sdk
remote: Counting objects: 180, done.
remote: Compressing objects: 100% (135/135), done.
remote: Total 180 (delta 46), reused 0 (delta 0)
Receiving objects: 100% (180/180), 52.71 KiB | 0 bytes/s, done.
Resolving deltas: 100% (46/46), done.
    Complete output from command python setup.py egg_info:
    Traceback (most recent call last):
      File "<string>", line 18, in <module>
    IOError: [Errno 2] No such file or directory: '/tmp/160806751.tmpdir/pip-build-XR8vJn/project-sdk/setup.py'

    ----------------------------------------
Command "python setup.py egg_info" failed with error code 1 in /tmp/160806751.tmpdir/pip-build-XR8vJn/project-sdk
```

The only difference in the commands is that of a `-e`.

I am using pip 7.1.2.

``` fish
user@host ~/test> pip --version
pip 7.1.2 from /home/user/test/venv/lib/python2.7/site-packages (python 2.7)
user@host ~/test> 
```



Discussion:
I face the same issue.

+1 for this issue

+1. same issue.

:+1:  for same issue


Resolving patch:
diff --git a/pip/index.py b/pip/index.py
--- a/pip/index.py
+++ b/pip/index.py
@@ -919,7 +919,7 @@ def url_without_fragment(self):
         scheme, netloc, path, query, fragment = urllib_parse.urlsplit(self.url)
         return urllib_parse.urlunsplit((scheme, netloc, path, query, None))
 
-    _egg_fragment_re = re.compile(r'#egg=([^&]*)')
+    _egg_fragment_re = re.compile(r'[#&]egg=([^&]*)')
 
     @property
     def egg_fragment(self):
@@ -928,6 +928,15 @@ def egg_fragment(self):
             return None
         return match.group(1)
 
+    _subdirectory_fragment_re = re.compile(r'[#&]subdirectory=([^&]*)')
+
+    @property
+    def subdirectory_fragment(self):
+        match = self._subdirectory_fragment_re.search(self.url)
+        if not match:
+            return None
+        return match.group(1)
+
     _hash_re = re.compile(
         r'(sha1|sha224|sha384|sha256|sha512|md5)=([a-f0-9]+)'
     )
diff --git a/pip/req/req_install.py b/pip/req/req_install.py
--- a/pip/req/req_install.py
+++ b/pip/req/req_install.py
@@ -67,7 +67,7 @@ def _strip_extras(path):
 class InstallRequirement(object):
 
     def __init__(self, req, comes_from, source_dir=None, editable=False,
-                 link=None, as_egg=False, update=True, editable_options=None,
+                 link=None, as_egg=False, update=True,
                  pycompile=True, markers=None, isolated=False, options=None,
                  wheel_cache=None, constraint=False):
         self.extras = ()
@@ -91,10 +91,6 @@ def __init__(self, req, comes_from, source_dir=None, editable=False,
         self.source_dir = source_dir
         self.editable = editable
 
-        if editable_options is None:
-            editable_options = {}
-
-        self.editable_options = editable_options
         self._wheel_cache = wheel_cache
         self.link = self.original_link = link
         self.as_egg = as_egg
@@ -135,7 +131,7 @@ def from_editable(cls, editable_req, comes_from=None, default_vcs=None,
                       constraint=False):
         from pip.index import Link
 
-        name, url, extras_override, editable_options = parse_editable(
+        name, url, extras_override = parse_editable(
             editable_req, default_vcs)
         if url.startswith('file:'):
             source_dir = url_to_path(url)
@@ -146,7 +142,6 @@ def from_editable(cls, editable_req, comes_from=None, default_vcs=None,
                   editable=True,
                   link=Link(url),
                   constraint=constraint,
-                  editable_options=editable_options,
                   isolated=isolated,
                   options=options if options else {},
                   wheel_cache=wheel_cache)
@@ -368,6 +363,12 @@ def name(self):
             return None
         return native_str(self.req.project_name)
 
+    @property
+    def setup_py_dir(self):
+        return os.path.join(
+            self.source_dir,
+            self.link and self.link.subdirectory_fragment or '')
+
     @property
     def setup_py(self):
         assert self.source_dir, "No source dir for %s" % self
@@ -384,15 +385,7 @@ def setup_py(self):
                 "install from a source distribution.\n%s" % add_msg
             )
 
-        setup_file = 'setup.py'
-
-        if self.editable_options and 'subdirectory' in self.editable_options:
-            setup_py = os.path.join(self.source_dir,
-                                    self.editable_options['subdirectory'],
-                                    setup_file)
-
-        else:
-            setup_py = os.path.join(self.source_dir, setup_file)
+        setup_py = os.path.join(self.setup_py_dir, 'setup.py')
 
         # Python2 __file__ should not be unicode
         if six.PY2 and isinstance(setup_py, six.text_type):
@@ -425,16 +418,12 @@ def run_egg_info(self):
             if self.editable:
                 egg_base_option = []
             else:
-                egg_info_dir = os.path.join(self.source_dir, 'pip-egg-info')
+                egg_info_dir = os.path.join(self.setup_py_dir, 'pip-egg-info')
                 ensure_dir(egg_info_dir)
                 egg_base_option = ['--egg-base', 'pip-egg-info']
-            cwd = self.source_dir
-            if self.editable_options and \
-                    'subdirectory' in self.editable_options:
-                cwd = os.path.join(cwd, self.editable_options['subdirectory'])
             call_subprocess(
                 egg_info_cmd + egg_base_option,
-                cwd=cwd,
+                cwd=self.setup_py_dir,
                 show_stdout=False,
                 command_level=logging.DEBUG,
                 command_desc='python setup.py egg_info')
@@ -481,7 +470,7 @@ def egg_info_path(self, filename):
             if self.editable:
                 base = self.source_dir
             else:
-                base = os.path.join(self.source_dir, 'pip-egg-info')
+                base = os.path.join(self.setup_py_dir, 'pip-egg-info')
             filenames = os.listdir(base)
             if self.editable:
                 filenames = []
@@ -799,7 +788,7 @@ def archive(self, build_dir):
                 archive_path, 'w', zipfile.ZIP_DEFLATED,
                 allowZip64=True
             )
-            dir = os.path.normcase(os.path.abspath(self.source_dir))
+            dir = os.path.normcase(os.path.abspath(self.setup_py_dir))
             for dirpath, dirnames, filenames in os.walk(dir):
                 if 'pip-egg-info' in dirnames:
                     dirnames.remove('pip-egg-info')
@@ -889,7 +878,7 @@ def install(self, install_options, global_options=[], root=None,
                 with indent_log():
                     call_subprocess(
                         install_args + install_options,
-                        cwd=self.source_dir,
+                        cwd=self.setup_py_dir,
                         show_stdout=False,
                         spinner=spinner,
                     )
@@ -981,10 +970,6 @@ def install_editable(self, install_options,
 
         with indent_log():
             # FIXME: should we do --install-headers here too?
-            cwd = self.source_dir
-            if self.editable_options and \
-                    'subdirectory' in self.editable_options:
-                cwd = os.path.join(cwd, self.editable_options['subdirectory'])
             call_subprocess(
                 [
                     sys.executable,
@@ -995,7 +980,7 @@ def install_editable(self, install_options,
                 ['develop', '--no-deps'] +
                 list(install_options),
 
-                cwd=cwd,
+                cwd=self.setup_py_dir,
                 show_stdout=False)
 
         self.install_succeeded = True
@@ -1111,26 +1096,6 @@ def _build_req_from_url(url):
     return req
 
 
-def _build_editable_options(req):
-
-    """
-        This method generates a dictionary of the query string
-        parameters contained in a given editable URL.
-    """
-    regexp = re.compile(r"[\?#&](?P<name>[^&=]+)=(?P<value>[^&=]+)")
-    matched = regexp.findall(req)
-
-    if matched:
-        ret = dict()
-        for option in matched:
-            (name, value) = option
-            if name in ret:
-                raise Exception("%s option already defined" % name)
-            ret[name] = value
-        return ret
-    return None
-
-
 def parse_editable(editable_req, default_vcs=None):
     """Parses an editable requirement into:
         - a requirement name
@@ -1173,10 +1138,9 @@ def parse_editable(editable_req, default_vcs=None):
                 pkg_resources.Requirement.parse(
                     '__placeholder__' + extras
                 ).extras,
-                {},
             )
         else:
-            return package_name, url_no_extras, None, {}
+            return package_name, url_no_extras, None
 
     for version_control in vcs:
         if url.lower().startswith('%s:' % version_control):
@@ -1201,21 +1165,12 @@ def parse_editable(editable_req, default_vcs=None):
             ' is currently supported'
         raise InstallationError(error_message)
 
-    try:
-        options = _build_editable_options(editable_req)
-    except Exception as exc:
+    package_name = Link(url).egg_fragment
+    if not package_name:
+        package_name = _build_req_from_url(editable_req)
+    if not package_name:
         raise InstallationError(
-            '--editable=%s error in editable options:%s' % (editable_req, exc)
+            '--editable=%s is not the right format; it must have '
+            '#egg=Package' % editable_req
         )
-    if not options or 'egg' not in options:
-        req = _build_req_from_url(editable_req)
-        if not req:
-            raise InstallationError(
-                '--editable=%s is not the right format; it must have '
-                '#egg=Package' % editable_req
-            )
-    else:
-        req = options['egg']
-
-    package = _strip_postfix(req)
-    return package, url, None, options
+    return _strip_postfix(package_name), url, None
diff --git a/pip/wheel.py b/pip/wheel.py
--- a/pip/wheel.py
+++ b/pip/wheel.py
@@ -715,7 +715,7 @@ def __build_one(self, req, tempd, python_tag=None):
                 wheel_args += ["--python-tag", python_tag]
 
             try:
-                call_subprocess(wheel_args, cwd=req.source_dir,
+                call_subprocess(wheel_args, cwd=req.setup_py_dir,
                                 show_stdout=False, spinner=spinner)
                 return True
             except:


Test patch:
diff --git a/tests/functional/test_install_reqs.py b/tests/functional/test_install_reqs.py
--- a/tests/functional/test_install_reqs.py
+++ b/tests/functional/test_install_reqs.py
@@ -191,6 +191,19 @@ def test_install_local_editable_with_subdirectory(script):
     result.assert_installed('version-subpkg', sub_dir='version_subdir')
 
 
+@pytest.mark.network
+def test_install_local_with_subdirectory(script):
+    version_pkg_path = _create_test_package_with_subdirectory(script,
+                                                              'version_subdir')
+    result = script.pip(
+        'install',
+        '%s#egg=version_subpkg&subdirectory=version_subdir' %
+        ('git+file://%s' % version_pkg_path,)
+    )
+
+    result.assert_installed('version_subpkg.py', editable=False)
+
+
 @pytest.mark.network
 def test_wheel_user_with_prefix_in_pydistutils_cfg(script, data, virtualenv):
     # Make sure wheel is available in the virtualenv
diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py
--- a/tests/unit/test_index.py
+++ b/tests/unit/test_index.py
@@ -75,6 +75,17 @@ def test_is_wheel(self):
     def test_is_wheel_false(self):
         assert not Link('http://yo/not_a_wheel').is_wheel
 
+    def test_fragments(self):
+        url = 'git+https://example.com/package#egg=eggname'
+        assert 'eggname' == Link(url).egg_fragment
+        assert None is Link(url).subdirectory_fragment
+        url = 'git+https://example.com/package#egg=eggname&subdirectory=subdir'
+        assert 'eggname' == Link(url).egg_fragment
+        assert 'subdir' == Link(url).subdirectory_fragment
+        url = 'git+https://example.com/package#subdirectory=subdir&egg=eggname'
+        assert 'eggname' == Link(url).egg_fragment
+        assert 'subdir' == Link(url).subdirectory_fragment
+
 
 @pytest.mark.parametrize(
     ("html", "url", "expected"),
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
@@ -507,10 +507,10 @@ def test_parse_editable_local(
     exists_mock.return_value = isdir_mock.return_value = True
     # mocks needed to support path operations on windows tests
     abspath_mock.return_value = "/some/path"
-    assert parse_editable('.', 'git') == (None, 'file:///some/path', None, {})
+    assert parse_editable('.', 'git') == (None, 'file:///some/path', None)
     abspath_mock.return_value = "/some/path/foo"
     assert parse_editable('foo', 'git') == (
-        None, 'file:///some/path/foo', None, {},
+        None, 'file:///some/path/foo', None,
     )
 
 
@@ -519,7 +519,6 @@ def test_parse_editable_default_vcs():
         'foo',
         'git+https://foo#egg=foo',
         None,
-        {'egg': 'foo'},
     )
 
 
@@ -528,7 +527,6 @@ def test_parse_editable_explicit_vcs():
         'foo',
         'svn+https://foo#egg=foo',
         None,
-        {'egg': 'foo'},
     )
 
 
@@ -537,7 +535,6 @@ def test_parse_editable_vcs_extras():
         'foo[extras]',
         'svn+https://foo#egg=foo[extras]',
         None,
-        {'egg': 'foo[extras]'},
     )
 
 
@@ -549,11 +546,11 @@ def test_parse_editable_local_extras(
     exists_mock.return_value = isdir_mock.return_value = True
     abspath_mock.return_value = "/some/path"
     assert parse_editable('.[extras]', 'git') == (
-        None, 'file://' + "/some/path", ('extras',), {},
+        None, 'file://' + "/some/path", ('extras',),
     )
     abspath_mock.return_value = "/some/path/foo"
     assert parse_editable('foo[bar,baz]', 'git') == (
-        None, 'file:///some/path/foo', ('bar', 'baz'), {},
+        None, 'file:///some/path/foo', ('bar', 'baz'),
     )