Repository: pypa/pip

Issue:
extras not installed when a dep is mentioned in a constraints file
# Workaround

Include the extras to be installed in the constraints file
# details

```
pip install -c c.txt zope.security[untrustedpython]
Collecting zope.security==3.8.3 (from -c c.txt (line 1))
  Downloading zope.security-3.8.3.tar.gz (103kB)
    100% |████████████████████████████████| 106kB 2.0MB/s 
...
Successfully installed zope.security-3.8.3
$ cat c.txt 
zope.security==3.8.3
```

Whats happening is that we're not updating the requirement with additional extras encountered. This is a subset of the 'we don't do resolution' problem - we should be merging in all extras referenced anywhere in the dep graph today, and in future we should be doing that as part of full resolution too.

The minimal test case today would be two packages A and B, A declares an extra containing B, a constraints file that contains A, and pip install -c file A[extra].



Discussion:

Resolving patch:
diff --git a/pip/req/req_set.py b/pip/req/req_set.py
--- a/pip/req/req_set.py
+++ b/pip/req/req_set.py
@@ -241,7 +241,8 @@ def add_requirement(self, install_req, parent_req_name=None):
             except KeyError:
                 existing_req = None
             if (parent_req_name is None and existing_req and not
-                    existing_req.constraint):
+                    existing_req.constraint and
+                    existing_req.extras == install_req.extras):
                 raise InstallationError(
                     'Double requirement given: %s (already in %s, name=%r)'
                     % (install_req, existing_req, name))
@@ -267,6 +268,11 @@ def add_requirement(self, install_req, parent_req_name=None):
                     # If we're now installing a constraint, mark the existing
                     # object for real installation.
                     existing_req.constraint = False
+                    existing_req.extras = tuple(
+                        sorted(set(existing_req.extras).union(
+                               set(install_req.extras))))
+                    logger.debug("Setting %s extras to: %s" % (
+                        existing_req, existing_req.extras))
                     # And now we need to scan this.
                     result = [existing_req]
                 # Canonicalise to the already-added object for the backref


Test patch:
diff --git a/tests/data/packages/LocalExtras-0.0.2/.gitignore b/tests/data/packages/LocalExtras-0.0.2/.gitignore
new file mode 100644
--- /dev/null
+++ b/tests/data/packages/LocalExtras-0.0.2/.gitignore
@@ -0,0 +1 @@
+/LocalExtras-0.0.2.egg-info
diff --git a/tests/data/packages/LocalExtras-0.0.2/localextras/__init__.py b/tests/data/packages/LocalExtras-0.0.2/localextras/__init__.py
new file mode 100644
diff --git a/tests/data/packages/LocalExtras-0.0.2/setup.py b/tests/data/packages/LocalExtras-0.0.2/setup.py
new file mode 100644
--- /dev/null
+++ b/tests/data/packages/LocalExtras-0.0.2/setup.py
@@ -0,0 +1,30 @@
+import os
+from setuptools import setup, find_packages
+
+
+def path_to_url(path):
+    """
+    Convert a path to URI. The path will be made absolute and
+    will not have quoted path parts.
+    """
+    path = os.path.normpath(os.path.abspath(path))
+    drive, path = os.path.splitdrive(path)
+    filepath = path.split(os.path.sep)
+    url = '/'.join(filepath)
+    if drive:
+        return 'file:///' + drive + url
+    return 'file://' +url
+
+
+HERE = os.path.dirname(__file__)
+DEP_PATH = os.path.join(HERE, '..', '..', 'indexes', 'simple', 'simple')
+DEP_URL = path_to_url(DEP_PATH)
+
+setup(
+    name='LocalExtras',
+    version='0.0.2',
+    packages=find_packages(),
+    install_requires=['simple==1.0'],
+    extras_require={ 'bar': ['simple==2.0'], 'baz': ['singlemodule'] },
+    dependency_links=[DEP_URL]
+)
diff --git a/tests/data/packages/LocalExtras/setup.py b/tests/data/packages/LocalExtras/setup.py
--- a/tests/data/packages/LocalExtras/setup.py
+++ b/tests/data/packages/LocalExtras/setup.py
@@ -24,6 +24,6 @@ def path_to_url(path):
     name='LocalExtras',
     version='0.0.1',
     packages=find_packages(),
-    extras_require={ 'bar': ['simple'] },
+    extras_require={ 'bar': ['simple'], 'baz': ['singlemodule'] },
     dependency_links=[DEP_URL]
 )
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
@@ -343,3 +343,95 @@ def test_double_install_spurious_hash_mismatch(script, tmpdir):
         result = script.pip_install_local(
             '-r', reqs_file.abspath, expect_error=False)
         assert 'Successfully installed simple-1.0' in str(result)
+
+
+def test_install_with_extras_from_constraints(script, data):
+    to_install = data.packages.join("LocalExtras")
+    script.scratch_path.join("constraints.txt").write(
+        "file://%s#egg=LocalExtras[bar]" % to_install
+    )
+    result = script.pip_install_local(
+        '-c', script.scratch_path / 'constraints.txt', 'LocalExtras')
+    assert script.site_packages / 'simple' in result.files_created
+
+
+def test_install_with_extras_from_install(script, data):
+    to_install = data.packages.join("LocalExtras")
+    script.scratch_path.join("constraints.txt").write(
+        "file://%s#egg=LocalExtras" % to_install
+    )
+    result = script.pip_install_local(
+        '-c', script.scratch_path / 'constraints.txt', 'LocalExtras[baz]')
+    assert script.site_packages / 'singlemodule.py'in result.files_created
+
+
+def test_install_with_extras_joined(script, data):
+    to_install = data.packages.join("LocalExtras")
+    script.scratch_path.join("constraints.txt").write(
+        "file://%s#egg=LocalExtras[bar]" % to_install
+    )
+    result = script.pip_install_local(
+        '-c', script.scratch_path / 'constraints.txt', 'LocalExtras[baz]'
+    )
+    assert script.site_packages / 'simple' in result.files_created
+    assert script.site_packages / 'singlemodule.py'in result.files_created
+
+
+def test_install_with_extras_editable_joined(script, data):
+    to_install = data.packages.join("LocalExtras")
+    script.scratch_path.join("constraints.txt").write(
+        "-e file://%s#egg=LocalExtras[bar]" % to_install
+    )
+    result = script.pip_install_local(
+        '-c', script.scratch_path / 'constraints.txt', 'LocalExtras[baz]')
+    assert script.site_packages / 'simple' in result.files_created
+    assert script.site_packages / 'singlemodule.py'in result.files_created
+
+
+def test_install_distribution_full_union(script, data):
+    to_install = data.packages.join("LocalExtras")
+    result = script.pip_install_local(
+        to_install, to_install + "[bar]", to_install + "[baz]")
+    assert 'Running setup.py install for LocalExtras' in result.stdout
+    assert script.site_packages / 'simple' in result.files_created
+    assert script.site_packages / 'singlemodule.py' in result.files_created
+
+
+def test_install_distribution_duplicate_extras(script, data):
+    to_install = data.packages.join("LocalExtras")
+    package_name = to_install + "[bar]"
+    with pytest.raises(AssertionError):
+        result = script.pip_install_local(package_name, package_name)
+        assert 'Double requirement given: %s' % package_name in result.stderr
+
+
+def test_install_distribution_union_with_constraints(script, data):
+    to_install = data.packages.join("LocalExtras")
+    script.scratch_path.join("constraints.txt").write(
+        "%s[bar]" % to_install)
+    result = script.pip_install_local(
+        '-c', script.scratch_path / 'constraints.txt', to_install + '[baz]')
+    assert 'Running setup.py install for LocalExtras' in result.stdout
+    assert script.site_packages / 'singlemodule.py' in result.files_created
+
+
+def test_install_distribution_union_with_versions(script, data):
+    to_install_001 = data.packages.join("LocalExtras")
+    to_install_002 = data.packages.join("LocalExtras-0.0.2")
+    result = script.pip_install_local(
+        to_install_001 + "[bar]", to_install_002 + "[baz]")
+    assert ("Successfully installed LocalExtras-0.0.1 simple-3.0 " +
+            "singlemodule-0.0.1" in result.stdout)
+
+
+@pytest.mark.xfail
+def test_install_distribution_union_conflicting_extras(script, data):
+    # LocalExtras requires simple==1.0, LocalExtras[bar] requires simple==2.0;
+    # without a resolver, pip does not detect the conflict between simple==1.0
+    # and simple==2.0. Once a resolver is added, this conflict should be
+    # detected.
+    to_install = data.packages.join("LocalExtras-0.0.2")
+    result = script.pip_install_local(to_install, to_install + "[bar]",
+                                      expect_error=True)
+    assert 'installed' not in result.stdout
+    assert "Conflict" in result.stderr