Repository: pypa/pip

Issue:
Honor environment markers in install_requires
- Pip version: 8.1.2
- Python version: 3.5.2
- Operating System: MacOS
### Description:

I'm trying to install a package using pip which has python-specific dependencies.
### What I've run:

I set up a venv with the latest pip:

```
$ python -m venv ~/.envs/textract
$ ~/.envs/textract/bin/python -m pip install -U pip
Collecting pip
  Using cached pip-8.1.2-py2.py3-none-any.whl
Installing collected packages: pip
  Found existing installation: pip 8.1.1
    Uninstalling pip-8.1.1:
      Successfully uninstalled pip-8.1.1
Successfully installed pip-8.1.2
```

I have a checkout of https://github.com/deanmalmgren/textract and I've modified the `requirements/python` file to have an environment marker.

```
$ ~/.envs/textract/bin/python -V                 
Python 3.5.2
$ hg diff
diff -r 3101b4e6d526 requirements/python
--- a/requirements/python   Sat Oct 10 08:14:24 2015 -0500
+++ b/requirements/python   Fri Jul 01 17:36:59 2016 -0400
@@ -4,7 +4,7 @@
 chardet
 python-pptx>=0.5.1
 python-docx
-pdfminer==20140328
+pdfminer==20140328; python_version=="2.7" or python_version=="2.6"
 beautifulsoup4
 xlrd
 EbookLib
```

I expect pdfminer to be bypassed when installing the dependencies, but when I attempt to install it on Python 3, it still tries to install that Python 2 only package.

```
$ ~/.envs/textract/bin/python -m pip install -e .
Obtaining file:///Users/jaraco/Dropbox/code/public/textract
Collecting argcomplete (from textract==1.4.0)
  Using cached argcomplete-1.4.1-py2.py3-none-any.whl
Collecting chardet (from textract==1.4.0)
  Using cached chardet-2.3.0.tar.gz
Collecting python-pptx>=0.5.1 (from textract==1.4.0)
  Using cached python-pptx-0.5.8.tar.gz
Collecting python-docx (from textract==1.4.0)
  Using cached python-docx-0.8.6.tar.gz
Collecting pdfminer==20140328; python_version == "2.7" or python_version == "2.6" (from textract==1.4.0)
  Using cached pdfminer-20140328.tar.gz
    Complete output from command python setup.py egg_info:
    Traceback (most recent call last):
      File "<string>", line 1, in <module>
      File "/private/var/folders/c6/v7hnmq453xb6p2dbz1gqc6rr0000gn/T/pip-build-sixohp47/pdfminer/setup.py", line 3, in <module>
        from pdfminer import __version__
      File "/private/var/folders/c6/v7hnmq453xb6p2dbz1gqc6rr0000gn/T/pip-build-sixohp47/pdfminer/pdfminer/__init__.py", line 5
        print __version__
                        ^
    SyntaxError: Missing parentheses in call to 'print'

    ----------------------------------------
Command "python setup.py egg_info" failed with error code 1 in /private/var/folders/c6/v7hnmq453xb6p2dbz1gqc6rr0000gn/T/pip-build-sixohp47/pdfminer/
```

I note that textract is trying to be a good citizen and declares its dependencies in a requirements.txt file (specifically `requirements/python`), and the setup.py file parses this and includes the environment marker in `install_requires` metadata, but it seems pip doesn't honor it.

It's only recently that [setuptools added support for environment markers other than in extras_require](http://setuptools.readthedocs.io/en/latest/history.html#id20), so it's not entirely surprising that pip doesn't recognize the markers here, but I suspect it should.



Discussion:
If I use the older syntax where environment variables are declared in extras, it works as expected:

```
diff -r 3101b4e6d526 requirements/python
--- a/requirements/python   Sat Oct 10 08:14:24 2015 -0500
+++ b/requirements/python   Fri Jul 01 18:23:35 2016 -0400
@@ -4,7 +4,6 @@
 chardet
 python-pptx>=0.5.1
 python-docx
-pdfminer==20140328
 beautifulsoup4
 xlrd
 EbookLib
diff -r 3101b4e6d526 setup.py
--- a/setup.py  Sat Oct 10 08:14:24 2015 -0500
+++ b/setup.py  Fri Jul 01 18:23:35 2016 -0400
@@ -44,5 +44,13 @@
     ],
     install_requires=dependencies,
     dependency_links=dependency_links,
+    extras_require={
+        ':python_version=="2.7" or python_version=="2.6"': [
+            'pdfminer==20140328',
+        ],
+        ':python_version=="3.5"': [
+            'pdfminer.six',
+        ],
+    },
     zip_safe=False,
 )
```

I think bdist_wheel would have to be modified for this to work. What do the Requires-Dist in the wheel METADATA say?


Resolving patch:
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
@@ -100,7 +100,10 @@ def __init__(self, req, comes_from, source_dir=None, editable=False,
         self._wheel_cache = wheel_cache
         self.link = self.original_link = link
         self.as_egg = as_egg
-        self.markers = markers
+        if markers is not None:
+            self.markers = markers
+        else:
+            self.markers = req and req.marker
         self._egg_info_path = None
         # This holds the pkg_resources.Distribution object if this requirement
         # is already available:
@@ -175,6 +178,8 @@ def from_line(
             markers = markers.strip()
             if not markers:
                 markers = None
+            else:
+                markers = Marker(markers)
         else:
             markers = None
         name = name.strip()
@@ -819,9 +824,15 @@ def _clean_zip_name(self, name, prefix):
         name = name.replace(os.path.sep, '/')
         return name
 
-    def match_markers(self):
+    def match_markers(self, extras_requested=None):
+        if not extras_requested:
+            # Provide an extra to safely evaluate the markers
+            # without matching any extra
+            extras_requested = ('',)
         if self.markers is not None:
-            return Marker(self.markers).evaluate()
+            return any(
+                self.markers.evaluate({'extra': extra})
+                for extra in extras_requested)
         else:
             return True
 
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
@@ -212,7 +212,8 @@ def __repr__(self):
         return ('<%s object; %d requirement(s): %s>'
                 % (self.__class__.__name__, len(reqs), reqs_str))
 
-    def add_requirement(self, install_req, parent_req_name=None):
+    def add_requirement(self, install_req, parent_req_name=None,
+                        extras_requested=None):
         """Add install_req as a requirement to install.
 
         :param parent_req_name: The name of the requirement that needed this
@@ -221,13 +222,15 @@ def add_requirement(self, install_req, parent_req_name=None):
             links that point outside the Requirements set. parent_req must
             already be added. Note that None implies that this is a user
             supplied requirement, vs an inferred one.
+        :param extras_requested: an iterable of extras used to evaluate the
+            environement markers.
         :return: Additional requirements to scan. That is either [] if
             the requirement is not applicable, or [install_req] if the
             requirement is applicable and has just been added.
         """
         name = install_req.name
-        if not install_req.match_markers():
-            logger.warning("Ignoring %s: markers %r don't match your "
+        if not install_req.match_markers(extras_requested):
+            logger.warning("Ignoring %s: markers '%s' don't match your "
                            "environment", install_req.name,
                            install_req.markers)
             return []
@@ -669,7 +672,7 @@ def _prepare_file(self,
                     raise
             more_reqs = []
 
-            def add_req(subreq):
+            def add_req(subreq, extras_requested):
                 sub_install_req = InstallRequirement(
                     str(subreq),
                     req_to_install,
@@ -677,7 +680,8 @@ def add_req(subreq):
                     wheel_cache=self._wheel_cache,
                 )
                 more_reqs.extend(self.add_requirement(
-                    sub_install_req, req_to_install.name))
+                    sub_install_req, req_to_install.name,
+                    extras_requested=extras_requested))
 
             # We add req_to_install before its dependencies, so that we
             # can refer to it when adding dependencies.
@@ -704,7 +708,7 @@ def add_req(subreq):
                     set(dist.extras) & set(req_to_install.extras)
                 )
                 for subreq in dist.requires(available_requested):
-                    add_req(subreq)
+                    add_req(subreq, extras_requested=available_requested)
 
             # cleanup tmp src
             self.reqs_to_cleanup.append(req_to_install)


Test patch:
diff --git a/tests/functional/test_install.py b/tests/functional/test_install.py
--- a/tests/functional/test_install.py
+++ b/tests/functional/test_install.py
@@ -1140,3 +1140,24 @@ def test_install_compatible_python_requires(script):
     script.pip('install', 'setuptools>24.2')  # This should not be needed
     res = script.pip('install', pkga_path, expect_error=True)
     assert "Successfully installed pkga-0.1" in res.stdout, res
+
+
+def test_install_environment_markers(script, data):
+    # make a dummy project
+    pkga_path = script.scratch_path / 'pkga'
+    pkga_path.mkdir()
+    pkga_path.join("setup.py").write(textwrap.dedent("""
+        from setuptools import setup
+        setup(name='pkga',
+              version='0.1',
+              install_requires=[
+                'missing_pkg; python_version=="1.0"',
+              ],
+        )
+    """))
+
+    res = script.pip('install', '--no-index', pkga_path, expect_stderr=True)
+    # missing_pkg should be ignored
+    assert ("Ignoring missing-pkg: markers 'python_version == \"1.0\"' don't "
+            "match your environment") in res.stderr, str(res)
+    assert "Successfully installed pkga-0.1" in res.stdout, str(res)
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
@@ -498,9 +498,8 @@ def test_install_unsupported_wheel_link_with_marker(script, data):
         expect_stderr=True,
     )
 
-    s = "Ignoring asdf: markers %r don't match your environment" %\
-        u'sys_platform == "xyz"'
-    assert s in result.stderr
+    assert ("Ignoring asdf: markers 'sys_platform == \"xyz\"' don't match "
+            "your environment") in result.stderr
     assert len(result.files_created) == 0
 
 
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
@@ -16,6 +16,7 @@
 from pip.req.req_install import parse_editable
 from pip.utils import read_text_file
 from pip._vendor import pkg_resources
+from pip._vendor.packaging.markers import Marker
 from pip._vendor.packaging.requirements import Requirement
 from tests.lib import assert_raises_regexp, requirements_file
 
@@ -415,14 +416,14 @@ def test_markers(self):
             req = InstallRequirement.from_line(line)
             assert req.req.name == 'mock3'
             assert str(req.req.specifier) == ''
-            assert req.markers == 'python_version >= "3"'
+            assert str(req.markers) == 'python_version >= "3"'
 
     def test_markers_semicolon(self):
         # check that the markers can contain a semicolon
         req = InstallRequirement.from_line('semicolon; os_name == "a; b"')
         assert req.req.name == 'semicolon'
         assert str(req.req.specifier) == ''
-        assert req.markers == 'os_name == "a; b"'
+        assert str(req.markers) == 'os_name == "a; b"'
 
     def test_markers_url(self):
         # test "URL; markers" syntax
@@ -430,7 +431,7 @@ def test_markers_url(self):
         line = '%s; python_version >= "3"' % url
         req = InstallRequirement.from_line(line)
         assert req.link.url == url, req.url
-        assert req.markers == 'python_version >= "3"'
+        assert str(req.markers) == 'python_version >= "3"'
 
         # without space, markers are part of the URL
         url = 'http://foo.com/?p=bar.git;a=snapshot;h=v0.1;sf=tgz'
@@ -439,7 +440,7 @@ def test_markers_url(self):
         assert req.link.url == line, req.url
         assert req.markers is None
 
-    def test_markers_match(self):
+    def test_markers_match_from_line(self):
         # match
         for markers in (
             'python_version >= "1.0"',
@@ -447,7 +448,7 @@ def test_markers_match(self):
         ):
             line = 'name; ' + markers
             req = InstallRequirement.from_line(line)
-            assert req.markers == markers
+            assert str(req.markers) == str(Marker(markers))
             assert req.match_markers()
 
         # don't match
@@ -457,7 +458,28 @@ def test_markers_match(self):
         ):
             line = 'name; ' + markers
             req = InstallRequirement.from_line(line)
-            assert req.markers == markers
+            assert str(req.markers) == str(Marker(markers))
+            assert not req.match_markers()
+
+    def test_markers_match(self):
+        # match
+        for markers in (
+            'python_version >= "1.0"',
+            'sys_platform == %r' % sys.platform,
+        ):
+            line = 'name; ' + markers
+            req = InstallRequirement(line, comes_from='')
+            assert str(req.markers) == str(Marker(markers))
+            assert req.match_markers()
+
+        # don't match
+        for markers in (
+            'python_version >= "5.0"',
+            'sys_platform != %r' % sys.platform,
+        ):
+            line = 'name; ' + markers
+            req = InstallRequirement(line, comes_from='')
+            assert str(req.markers) == str(Marker(markers))
             assert not req.match_markers()
 
     def test_extras_for_line_path_requirement(self):