Repository: pypa/pip

Issue:
Fix issue #1433: parse requirements in markers
- InstallRequirement supports PEP 426 markers
- RequirementSet.add_requirement() ignores an InstallRequirement if
  markers don't match.

fixes #1433

Support markers in requirements files
Hi,

I'm working on the OpenStack project which uses "requirements.txt" text files to list dependencies. The problem is that there are dependencies which are Python 2 specific, whereas others are only working on Python 3. I need a way to specify that such dependency is only for Python 2 or only for Python 3. Supporting markers in requirements.txt would be a generic solution, example of syntax:

```
iso8601
mox [sys.version_info < (3,)]
mox3 [sys.version_info >= (3,)]
```

Python 2 would install iso8601 and mox, Python 3 would install iso8601 and mox3.



Discussion:

Another usecase. I'm working on a port of the new Tulip project (written for Python 3.3+) on Python 2: Trollius. Trollius uses backports of Python 3 modules for Python 2. Trollius on Python 2.7 requires futures, on Python 2.6 it requires futures and ordereddict.

I don't see how to specify in requirements.txt that I _don't want_ to install ordereddict on Python 2.7 (collections.OrderedDict is already available), but only on Python 2.6.

(Trollius is not related to tox or OpenStack, at least, not yet :-))


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
@@ -13,6 +13,7 @@
 from email.parser import FeedParser
 
 from pip._vendor import pkg_resources, six
+from pip._vendor.distlib.markers import interpret as markers_interpret
 from pip._vendor.six.moves import configparser
 from pip._vendor.six.moves.urllib import parse as urllib_parse
 
@@ -45,7 +46,7 @@ class InstallRequirement(object):
 
     def __init__(self, req, comes_from, source_dir=None, editable=False,
                  url=None, as_egg=False, update=True, prereleases=None,
-                 editable_options=None, pycompile=True):
+                 editable_options=None, pycompile=True, markers=None):
         self.extras = ()
         if isinstance(req, six.string_types):
             req = pkg_resources.Requirement.parse(req)
@@ -61,6 +62,7 @@ def __init__(self, req, comes_from, source_dir=None, editable=False,
         self.editable_options = editable_options
         self.url = url
         self.as_egg = as_egg
+        self.markers = markers
         self._egg_info_path = None
         # This holds the pkg_resources.Distribution object if this requirement
         # is already available:
@@ -115,6 +117,17 @@ def from_line(cls, name, comes_from=None, prereleases=None):
         requirement, directory containing 'setup.py', filename, or URL.
         """
         url = None
+        if is_url(name):
+            marker_sep = '; '
+        else:
+            marker_sep = ';'
+        if marker_sep in name:
+            name, markers = name.split(marker_sep, 1)
+            markers = markers.strip()
+            if not markers:
+                markers = None
+        else:
+            markers = None
         name = name.strip()
         req = None
         path = os.path.normpath(os.path.abspath(name))
@@ -165,7 +178,8 @@ def from_line(cls, name, comes_from=None, prereleases=None):
         else:
             req = name
 
-        return cls(req, comes_from, url=url, prereleases=prereleases)
+        return cls(req, comes_from, url=url, prereleases=prereleases,
+                   markers=markers)
 
     def __str__(self):
         if self.req:
@@ -726,6 +740,12 @@ def _clean_zip_name(self, name, prefix):
         name = name.replace(os.path.sep, '/')
         return name
 
+    def match_markers(self):
+        if self.markers is not None:
+            return markers_interpret(self.markers)
+        else:
+            return True
+
     def install(self, install_options, global_options=(), root=None):
         if self.editable:
             self.install_editable(install_options, global_options)
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
@@ -94,6 +94,11 @@ def __str__(self):
         return ' '.join([str(req.req) for req in reqs])
 
     def add_requirement(self, install_req):
+        if not install_req.match_markers():
+            logger.debug("Ignore %s: markers %r don't match",
+                         install_req.name, install_req.markers)
+            return
+
         name = install_req.name
         install_req.as_egg = self.as_egg
         install_req.use_user_site = self.use_user_site


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
@@ -1,5 +1,6 @@
 import os
 import shutil
+import sys
 import tempfile
 
 import pytest
@@ -104,6 +105,63 @@ def test_url_preserved_editable_req(self):
         req = InstallRequirement.from_editable(url)
         assert req.url == url
 
+    def test_markers(self):
+        for line in (
+            # recommanded syntax
+            'mock3; python_version >= "3"',
+            # with more spaces
+            'mock3 ; python_version >= "3" ',
+            # without spaces
+            'mock3;python_version >= "3"',
+        ):
+            req = InstallRequirement.from_line(line)
+            assert req.req.project_name == 'mock3'
+            assert req.req.specs == []
+            assert 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.project_name == 'semicolon'
+        assert req.req.specs == []
+        assert req.markers == 'os_name == "a; b"'
+
+    def test_markers_url(self):
+        # test "URL; markers" syntax
+        url = 'http://foo.com/?p=bar.git;a=snapshot;h=v0.1;sf=tgz'
+        line = '%s; python_version >= "3"' % url
+        req = InstallRequirement.from_line(line)
+        assert req.url == url, req.url
+        assert 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'
+        line = '%s;python_version >= "3"' % url
+        req = InstallRequirement.from_line(line)
+        assert req.url == line, req.url
+        assert req.markers is None
+
+    def test_markers_match(self):
+        # match
+        for markers in (
+            'python_version >= "1.0"',
+            'sys_platform == %r' % sys.platform,
+        ):
+            line = 'name; ' + markers
+            req = InstallRequirement.from_line(line)
+            assert req.markers == 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.from_line(line)
+            assert req.markers == markers
+            assert not req.match_markers()
+
 
 def test_requirements_data_structure_keeps_order():
     requirements = Requirements()