Repository: jazzband/pip-tools

Issue:
Pip-compile ignores extras_require when parsing setup.py
The documentation for python-wheel describes ways to define conditional dependencies: http://wheel.readthedocs.io/en/stable/index.html?highlight=extras_require#defining-conditional-dependencies

The most well-supported of these is to use the parameter `extras_require` to specify dependencies for specific environment markers. For example:

```
from setuptools import setup
setup(
    name='mypackage',
    extras_require={
        ':platform_python_implementation=="CPython"': ['python-cjson'],
    }
)
```

This allows us to install optimisations for CPython which are not available on other implementations of Python. This works correctly for pip/setuptools (e.g. `pip install .` or `python setup.py install`) but is ignored by pip-compile.

##### Environment Versions

1. OS Type (Linux/Ubuntu)
1. Python version: 2.7
1. pip version: 9.0.1
1. pip-tools version: 1.11.0

##### Steps to replicate

1. Create a package with a conditional dependency specified using `extras_require` in `setup.py`.
2. Run pip-compile on the package in an environment which meets the conditions, and in one that does not.

##### Expected result

The generated requirements.txt should contain the conditional packages for the environment which meets the conditions, and not otherwise.

##### Actual result

The conditional dependencies are not present in any requirements files.



Discussion:
Does it make a difference if you specify the conditional dependency in the `install_requires` argument?

Here's an example:
```python
setup(
    name='mypackage',
    install_requires=[
        'python-cjson ; platform_python_implementation=="CPython"',
        ...
    ])
```
@suutari Turns out it doesn't make any difference. Setuptools Distribution class separates out any installation requirements with environment markers, and then merges them into `extras_requires`, so the end result is the same from the perspective of the returned Distribution object! On the plus side, this means that any solution to this issue should work for both methods of specifying conditions on requirements. I was using the older method (via `extras_require`) because this is supported since setuptools 18, whereas the method you suggest is only available since setuptools 36.2 and is not fully supported by pip.

The Distribution object provides an `extras_require` attribute containing the necessary information, but I'm not sure how we would want to process it. We either need to evaluate if the marker conditions are met and include/omit based on that, or we need the markers to exist in the generated requirements file. The latter is nice as it allows single requirements files to work across all environments, but then this should probably be applied to inherited dependencies too. Currently inherited dependencies are included based on marker conditions, and markers are not preserved in the generated requirements file.
Hi @davidjlloyd, thanks for the details.

I did a small test and can point out this: you will get the proper result if you use a requirements.in file like this:
requirements.in:
```
-e .
```
Having pip parse the requirements.in file, and then process the setup.py, works fine.
The "direct" setup.py parse done by pip-compile is incomplete and would need improvement.

I would generally advise to use requirements files, its the battle-tested flow. Hindsight being 20/20, I might have voted against parsing the setup.py directly in retrospective. 

Contributions are welcomed of course 😄 
We just ran head first into this one. We are not a package but rather have a requirements.in that needs to be resolved into frozen packages. In this case one of our dependencies have a extras_require section. This issue means that one dependency is going to be missing completely in the resulting requirements.txt and fail the entire install as all packages needs to be frozen in --require-hashes mode.

We cant see any workaround other then depending on the package specified in extras_require ourselvs. This is very inconvenient and makes upgrades a lot less flexible.

We would very much like to see a fix for this as the whole purpose of pip-compile seams to be to solve this kind problem.

I might try to get a PR in for it if I get some time on my hands but in the meantime please be advised that this issue has a real and serious impact without workaround.
@vphilippon thank you very much for your comments and suggested approach (and for maintaining such a good package)!

I have a little doubt left though: since, as you said, the support to `setup.py` in incomplete
(for example I am getting a `RuntimeError: 'distutils.core.setup()' was never called -- perhaps 'setup.py' is not a Distutils setup script?`, when trying to run `pip-compile` on both [example1](https://github.com/abravalheri/dummy-pip-tools-example-for-issue) and [example2](https://github.com/abravalheri/dummy-pip-tools-example-for-issue/tree/dependencies-on-setup-py)) and adding `-e .` to `requirements.in`, as you suggested, create an absolute path in `requirements.txt`  (see [example3](https://github.com/abravalheri/dummy-pip-tools-example-for-issue/blob/requirements-in/requirements.txt)) and therefore prevents sharing it between multiple developers of a same project, what would be your recommendation for being able to use the `pip-tools` workflow while relying on `setuptools` for packaging without double bookkeeping the dependencies?

Is there an alternative workaround for this?
Have the same problem. In my case it's https://github.com/stefanfoulis/django-phonenumber-field it used **install_requires** and **extras_require** in setup:
```
    install_requires=["Django>=1.11.3", "babel"],
    extras_require={
        "phonenumbers": ["phonenumbers>=7.0.2"],
        "phonenumberslite": ["phonenumberslite>=7.0.2"],
    },
```

**install_requires** is considered but **extras_require** not:
```
/tmp$ echo "django-phonenumber-field" > test.in
/tmp$ pip-compile test.in 
#
# This file is autogenerated by pip-compile
# To update, run:
#
#    pip-compile test.in
#
babel==2.7.0              # via django-phonenumber-field
django-phonenumber-field==3.0.1
django==2.2.2             # via django-phonenumber-field
pytz==2019.1              # via babel, django
sqlparse==0.3.0           # via django
```

Hello @jedie! What about this:

```bash
$ echo "django-phonenumber-field[phonenumbers]" | pip-compile - -qo-
#
# This file is autogenerated by pip-compile
# To update, run:
#
#    pip-compile --output-file=- -
#
babel==2.7.0              # via django-phonenumber-field
django-phonenumber-field[phonenumbers]==3.0.1
django==2.2.2             # via django-phonenumber-field
phonenumbers==8.10.13     # via django-phonenumber-field
pytz==2019.1              # via babel, django
sqlparse==0.3.0           # via django
```
Yes. But in my case: **django-phonenumber-field** is a dependency of a other package. So i didn't have this in my `.in` file...
What about something like:

`pip-compile setup.py --with-extra dev` building a `requirements-dev.txt` file using the `extras_require["dev"]` section?

@jedie 

> Yes. But in my case: **django-phonenumber-field** is a dependency of a other package. So i didn't have this in my `.in` file...

That's author's decision to force developers to install extra requirements manually. See [changelog](https://github.com/stefanfoulis/django-phonenumber-field/blob/master/CHANGELOG.rst#210-2018-10-28) and PR https://github.com/stefanfoulis/django-phonenumber-field/pull/236. So in your case you have to put `phonenumbers` to `requirements.in` (even if it is an extra dependency of another package you are using).  IMO `pip-compile` has nothing to do with this case.
Can someone clarify the expected behavior?

I see above that `-e .` works. My question is, what about more complex variants such as `-e .[dev,quality,test]`? It seems they don't work. Is that the expected behavior?
Related question: I have a library project where the library's own `requirements.in` lists `-e .` as a requirement. In requirements.txt, this became: `-e file:///opt/app/src`, the absolute local path of the library source. Then when I setup the development environment using `requirements.txt`, `pip freeze` shows that it actually installed something like `-e git+git@github.com:barry-hart/my-library.git@ef30e106ce844377b59b82c23943f16d8cbbb5a7#egg=mylib`. This is not at all what I wanted -- it's installing remote source, using the last commit of the code. I'm happy to create a separate issue for this if that seems appropriate -- please let me know.
Hi @barrywhart. I don't see how your question relates at all to "extras_require", which is key to this topic. It sounds like you have another issue.
I've filed an issue #908 which describes how to resolve a bunch of problems with "extras_require" in setup.py. Please comment if someone is interested in.
I'm confused how `-e .` (in a `requirements.in`) is a valid workaround?

This results in the package being added to the requirements file itself, which is not what happens if you run `pip-compile .` on a package (`setup.py`).
I think this is a workflow used by developers of apps that require the app (site, etc) to be itself an installed Python project, for entry points or other integrations (e.g. Pyramid sites need this): developers want to document one command to install project dependencies and the project itself (I guess), so having `-e .` in `requirements.txt` can be more convenient than having two install commands.

I am not sure about having `-e .` in `requirements.in` though!
Sounds reasonable. But just to note, the “two install commands” can be used in one line e.g.

    pip install -r requirements.txt -e .

which works fine and does not require to be `-e .` in the requirements.in .

I also think, the above line in the documentation, instead putting `-e .` into the requirements.in . The package itself is not a requirement and the user might want to use it in another way—such as extending PYTHONPATH—although the package might require most times entry points or so. Additionally, `-e .` does not work with --generate-hashes .
At the moment I manually edit the requirements.txt file after pip-compile and remove the package itself by hand, which is the best thing I could find up until now. 
>I also think, the above line in the documentation, instead putting -e . into the requirements.in . The package itself is not a requirement and the user might want to use it in another way—such as extending PYTHONPATH—although the package might require most times entry points or so. Additionally, -e . does not work with --generate-hashes .
At the moment I manually edit the requirements.txt file after pip-compile and remove the package itself by hand, which is the best thing I could find up until now.

Yep. I have a package that should be able to be 'frozen' with hashes, for both its regular dependencies, and `foo[dev]`. Adding the package itself as a dependency into requirements.txt makes no sense.
I think something like:

```
pip-compile .[dev]
```

Resolving patch:
diff --git a/piptools/scripts/compile.py b/piptools/scripts/compile.py
--- a/piptools/scripts/compile.py
+++ b/piptools/scripts/compile.py
@@ -2,12 +2,13 @@
 import shlex
 import sys
 import tempfile
-from typing import Any, BinaryIO, Optional, Tuple, cast
+from typing import Any, BinaryIO, List, Optional, Tuple, cast
 
 import click
 from click.utils import LazyFile, safecall
 from pep517 import meta
 from pip._internal.commands import create_command
+from pip._internal.req import InstallRequirement
 from pip._internal.req.constructors import install_req_from_line
 from pip._internal.utils.misc import redact_auth_from_url
 
@@ -19,7 +20,13 @@
 from ..repositories import LocalRequirementsRepository, PyPIRepository
 from ..repositories.base import BaseRepository
 from ..resolver import Resolver
-from ..utils import UNSAFE_PACKAGES, dedup, is_pinned_requirement, key_from_ireq
+from ..utils import (
+    UNSAFE_PACKAGES,
+    dedup,
+    drop_extras,
+    is_pinned_requirement,
+    key_from_ireq,
+)
 from ..writer import OutputWriter
 
 DEFAULT_REQUIREMENTS_FILE = "requirements.in"
@@ -61,6 +68,12 @@ def _get_default_option(option_name: str) -> Any:
     is_flag=True,
     help="Clear any caches upfront, rebuild from scratch",
 )
+@click.option(
+    "--extra",
+    "extras",
+    multiple=True,
+    help="Names of extras_require to install",
+)
 @click.option(
     "-f",
     "--find-links",
@@ -206,22 +219,23 @@ def cli(
     dry_run: bool,
     pre: bool,
     rebuild: bool,
-    find_links: Tuple[str],
+    extras: Tuple[str, ...],
+    find_links: Tuple[str, ...],
     index_url: str,
-    extra_index_url: Tuple[str],
+    extra_index_url: Tuple[str, ...],
     cert: Optional[str],
     client_cert: Optional[str],
-    trusted_host: Tuple[str],
+    trusted_host: Tuple[str, ...],
     header: bool,
     emit_trusted_host: bool,
     annotate: bool,
     upgrade: bool,
-    upgrade_packages: Tuple[str],
+    upgrade_packages: Tuple[str, ...],
     output_file: Optional[LazyFile],
     allow_unsafe: bool,
     generate_hashes: bool,
     reuse_hashes: bool,
-    src_files: Tuple[str],
+    src_files: Tuple[str, ...],
     max_rounds: int,
     build_isolation: bool,
     emit_find_links: bool,
@@ -336,7 +350,8 @@ def cli(
     # Parsing/collecting initial requirements
     ###
 
-    constraints = []
+    constraints: List[InstallRequirement] = []
+    setup_file_found = False
     for src_file in src_files:
         is_setup_file = os.path.basename(src_file) in METADATA_FILENAMES
         if src_file == "-":
@@ -360,6 +375,7 @@ def cli(
                 req.comes_from = comes_from
             constraints.extend(reqs)
         elif is_setup_file:
+            setup_file_found = True
             dist = meta.load(os.path.dirname(os.path.abspath(src_file)))
             comes_from = f"{dist.metadata.get_all('Name')[0]} ({src_file})"
             constraints.extend(
@@ -378,6 +394,10 @@ def cli(
                 )
             )
 
+    if extras and not setup_file_found:
+        msg = "--extra has effect only with setup.py and PEP-517 input formats"
+        raise click.BadParameter(msg)
+
     primary_packages = {
         key_from_ireq(ireq) for ireq in constraints if not ireq.constraint
     }
@@ -387,16 +407,9 @@ def cli(
         ireq for key, ireq in upgrade_install_reqs.items() if key in allowed_upgrades
     )
 
-    # Filter out pip environment markers which do not match (PEP496)
-    constraints = [
-        req
-        for req in constraints
-        if req.markers is None
-        # We explicitly set extra=None to filter out optional requirements
-        # since evaluating an extra marker with no environment raises UndefinedEnvironmentName
-        # (see https://packaging.pypa.io/en/latest/markers.html#usage)
-        or req.markers.evaluate({"extra": None})
-    ]
+    constraints = [req for req in constraints if req.match_markers(extras)]
+    for req in constraints:
+        drop_extras(req)
 
     log.debug("Using indexes:")
     with log.indentation():
diff --git a/piptools/utils.py b/piptools/utils.py
--- a/piptools/utils.py
+++ b/piptools/utils.py
@@ -6,6 +6,7 @@
     Dict,
     Iterable,
     Iterator,
+    List,
     Optional,
     Set,
     Tuple,
@@ -210,6 +211,57 @@ def dedup(iterable: Iterable[_T]) -> Iterable[_T]:
     return iter(dict.fromkeys(iterable))
 
 
+def drop_extras(ireq: InstallRequirement) -> None:
+    """Remove "extra" markers (PEP-508) from requirement."""
+    if ireq.markers is None:
+        return
+    ireq.markers._markers = _drop_extras(ireq.markers._markers)
+    if not ireq.markers._markers:
+        ireq.markers = None
+
+
+def _drop_extras(markers: List[_T]) -> List[_T]:
+    # drop `extra` tokens
+    to_remove: List[int] = []
+    for i, token in enumerate(markers):
+        # operator (and/or)
+        if isinstance(token, str):
+            continue
+        # sub-expression (inside braces)
+        if isinstance(token, list):
+            markers[i] = _drop_extras(token)  # type: ignore
+            if markers[i]:
+                continue
+            to_remove.append(i)
+            continue
+        # test expression (like `extra == "dev"`)
+        assert isinstance(token, tuple)
+        if token[0].value == "extra":
+            to_remove.append(i)
+    for i in reversed(to_remove):
+        markers.pop(i)
+
+    # drop duplicate bool operators (and/or)
+    to_remove = []
+    for i, (token1, token2) in enumerate(zip(markers, markers[1:])):
+        if not isinstance(token1, str):
+            continue
+        if not isinstance(token2, str):
+            continue
+        if token1 == "and":
+            to_remove.append(i)
+        else:
+            to_remove.append(i + 1)
+    for i in reversed(to_remove):
+        markers.pop(i)
+    if markers and isinstance(markers[0], str):
+        markers.pop(0)
+    if markers and isinstance(markers[-1], str):
+        markers.pop(-1)
+
+    return markers
+
+
 def get_hashes_from_ireq(ireq: InstallRequirement) -> Set[str]:
     """
     Given an InstallRequirement, return a set of string hashes in the format


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -322,3 +322,42 @@ def _make_sdist(package_dir, dist_dir, *args):
         return run_setup_file(package_dir, "sdist", "--dist-dir", str(dist_dir), *args)
 
     return _make_sdist
+
+
+@pytest.fixture
+def make_module(tmpdir):
+    """
+    Make a metadata file with the given name and content and a fake module.
+    """
+
+    def _make_module(fname, content):
+        path = os.path.join(tmpdir, "sample_lib")
+        os.mkdir(path)
+        path = os.path.join(tmpdir, "sample_lib", "__init__.py")
+        with open(path, "w") as stream:
+            stream.write("'example module'\n__version__ = '1.2.3'")
+        path = os.path.join(tmpdir, fname)
+        with open(path, "w") as stream:
+            stream.write(dedent(content))
+        return path
+
+    return _make_module
+
+
+@pytest.fixture
+def fake_dists(tmpdir, make_package, make_wheel):
+    """
+    Generate distribution packages `small-fake-{a..f}`
+    """
+    dists_path = os.path.join(tmpdir, "dists")
+    pkgs = [
+        make_package("small-fake-a", version="0.1"),
+        make_package("small-fake-b", version="0.2"),
+        make_package("small-fake-c", version="0.3"),
+        make_package("small-fake-d", version="0.4"),
+        make_package("small-fake-e", version="0.5"),
+        make_package("small-fake-f", version="0.6"),
+    ]
+    for pkg in pkgs:
+        make_wheel(pkg, dists_path)
+    return dists_path
diff --git a/tests/test_cli_compile.py b/tests/test_cli_compile.py
--- a/tests/test_cli_compile.py
+++ b/tests/test_cli_compile.py
@@ -1658,119 +1658,108 @@ def test_triple_equal_pinned_dependency_is_used(
         assert line in out.stderr
 
 
-@pytest.mark.network
-@pytest.mark.parametrize(
-    ("fname", "content"),
-    (
-        pytest.param(
-            "setup.cfg",
-            """
-                [metadata]
-                name = sample_lib
-                author = Vincent Driessen
-                author_email = me@nvie.com
-
-                [options]
-                packages = find:
-                install_requires =
-                    small-fake-a==0.1
-                    small-fake-b==0.2
-
-                [options.extras_require]
-                dev =
-                    small-fake-c==0.3
-                    small-fake-d==0.4
-                test =
-                    small-fake-e==0.5
-                    small-fake-f==0.6
-            """,
-            id="setup.cfg",
-        ),
-        pytest.param(
-            "setup.py",
-            """
-                from setuptools import setup
-
-                setup(
-                    name="sample_lib",
-                    version=0.1,
-                    install_requires=["small-fake-a==0.1", "small-fake-b==0.2"],
-                    extras_require={
-                        "dev": ["small-fake-c==0.3", "small-fake-d==0.4"],
-                        "test": ["small-fake-e==0.5", "small-fake-f==0.6"],
-                    },
-                )
-            """,
-            id="setup.py",
-        ),
-        pytest.param(
-            "pyproject.toml",
-            """
-                [build-system]
-                requires = ["flit_core >=2,<4"]
-                build-backend = "flit_core.buildapi"
-
-                [tool.flit.metadata]
-                module = "sample_lib"
-                author = "Vincent Driessen"
-                author-email = "me@nvie.com"
-
-                requires = ["small-fake-a==0.1", "small-fake-b==0.2"]
-
-                [tool.flit.metadata.requires-extra]
-                dev  = ["small-fake-c==0.3", "small-fake-d==0.4"]
-                test = ["small-fake-e==0.5", "small-fake-f==0.6"]
-            """,
-            id="flit",
-        ),
-        pytest.param(
-            "pyproject.toml",
-            """
-                [build-system]
-                requires = ["poetry_core>=1.0.0"]
-                build-backend = "poetry.core.masonry.api"
-
-                [tool.poetry]
-                name = "sample_lib"
-                version = "0.1.0"
-                description = ""
-                authors = ["Vincent Driessen <me@nvie.com>"]
-
-                [tool.poetry.dependencies]
-                python = "*"
-                small-fake-a = "0.1"
-                small-fake-b = "0.2"
-
-                small-fake-c = "0.3"
-                small-fake-d = "0.4"
-                small-fake-e = "0.5"
-                small-fake-f = "0.6"
-
-                [tool.poetry.extras]
-                dev  = ["small-fake-c", "small-fake-d"]
-                test = ["small-fake-e", "small-fake-f"]
-            """,
-            id="poetry",
-        ),
+METADATA_TEST_CASES = (
+    pytest.param(
+        "setup.cfg",
+        """
+            [metadata]
+            name = sample_lib
+            author = Vincent Driessen
+            author_email = me@nvie.com
+
+            [options]
+            packages = find:
+            install_requires =
+                small-fake-a==0.1
+                small-fake-b==0.2
+
+            [options.extras_require]
+            dev =
+                small-fake-c==0.3
+                small-fake-d==0.4
+            test =
+                small-fake-e==0.5
+                small-fake-f==0.6
+        """,
+        id="setup.cfg",
+    ),
+    pytest.param(
+        "setup.py",
+        """
+            from setuptools import setup
+
+            setup(
+                name="sample_lib",
+                version=0.1,
+                install_requires=["small-fake-a==0.1", "small-fake-b==0.2"],
+                extras_require={
+                    "dev": ["small-fake-c==0.3", "small-fake-d==0.4"],
+                    "test": ["small-fake-e==0.5", "small-fake-f==0.6"],
+                },
+            )
+        """,
+        id="setup.py",
+    ),
+    pytest.param(
+        "pyproject.toml",
+        """
+            [build-system]
+            requires = ["flit_core >=2,<4"]
+            build-backend = "flit_core.buildapi"
+
+            [tool.flit.metadata]
+            module = "sample_lib"
+            author = "Vincent Driessen"
+            author-email = "me@nvie.com"
+
+            requires = ["small-fake-a==0.1", "small-fake-b==0.2"]
+
+            [tool.flit.metadata.requires-extra]
+            dev  = ["small-fake-c==0.3", "small-fake-d==0.4"]
+            test = ["small-fake-e==0.5", "small-fake-f==0.6"]
+        """,
+        id="flit",
+    ),
+    pytest.param(
+        "pyproject.toml",
+        """
+            [build-system]
+            requires = ["poetry_core>=1.0.0"]
+            build-backend = "poetry.core.masonry.api"
+
+            [tool.poetry]
+            name = "sample_lib"
+            version = "0.1.0"
+            description = ""
+            authors = ["Vincent Driessen <me@nvie.com>"]
+
+            [tool.poetry.dependencies]
+            python = "*"
+            small-fake-a = "0.1"
+            small-fake-b = "0.2"
+
+            small-fake-c = "0.3"
+            small-fake-d = "0.4"
+            small-fake-e = "0.5"
+            small-fake-f = "0.6"
+
+            [tool.poetry.extras]
+            dev  = ["small-fake-c", "small-fake-d"]
+            test = ["small-fake-e", "small-fake-f"]
+        """,
+        id="poetry",
     ),
 )
-def test_input_formats(make_package, make_wheel, runner, tmpdir, fname, content):
-    dists_path = os.path.join(tmpdir, "dists")
-    pkg = make_package("small-fake-a", version="0.1")
-    make_wheel(pkg, dists_path)
-    pkg = make_package("small-fake-b", version="0.2")
-    make_wheel(pkg, dists_path)
-
-    path = os.path.join(tmpdir, "sample_lib")
-    os.mkdir(path)
-    path = os.path.join(tmpdir, "sample_lib", "__init__.py")
-    with open(path, "w") as stream:
-        stream.write("'example module'\n__version__ = '1.2.3'")
-    path = os.path.join(tmpdir, fname)
-    with open(path, "w") as stream:
-        stream.write(dedent(content))
 
-    out = runner.invoke(cli, ["-n", "--find-links", dists_path, path])
+
+@pytest.mark.network
+@pytest.mark.parametrize(("fname", "content"), METADATA_TEST_CASES)
+def test_input_formats(fake_dists, runner, make_module, fname, content):
+    """
+    Test different dependency formats as input file.
+    """
+    meta_path = make_module(fname=fname, content=content)
+    out = runner.invoke(cli, ["-n", "--find-links", fake_dists, meta_path])
     assert out.exit_code == 0, out.stderr
     assert "small-fake-a==0.1" in out.stderr
     assert "small-fake-b==0.2" in out.stderr
@@ -1778,3 +1767,67 @@ def test_input_formats(make_package, make_wheel, runner, tmpdir, fname, content)
     assert "small-fake-d" not in out.stderr
     assert "small-fake-e" not in out.stderr
     assert "small-fake-f" not in out.stderr
+    assert "extra ==" not in out.stderr
+
+
+@pytest.mark.network
+@pytest.mark.parametrize(("fname", "content"), METADATA_TEST_CASES)
+def test_one_extra(fake_dists, runner, make_module, fname, content):
+    """
+    Test one `--extra` (dev) passed, other extras (test) must be ignored.
+    """
+    meta_path = make_module(fname=fname, content=content)
+    out = runner.invoke(
+        cli, ["-n", "--extra", "dev", "--find-links", fake_dists, meta_path]
+    )
+    assert out.exit_code == 0, out.stderr
+    assert "small-fake-a==0.1" in out.stderr
+    assert "small-fake-b==0.2" in out.stderr
+    assert "small-fake-c==0.3" in out.stderr
+    assert "small-fake-d==0.4" in out.stderr
+    assert "small-fake-e" not in out.stderr
+    assert "small-fake-f" not in out.stderr
+    assert "extra ==" not in out.stderr
+
+
+@pytest.mark.network
+@pytest.mark.parametrize(("fname", "content"), METADATA_TEST_CASES)
+def test_multiple_extras(fake_dists, runner, make_module, fname, content):
+    """
+    Test passing multiple `--extra` params.
+    """
+    meta_path = make_module(fname=fname, content=content)
+    out = runner.invoke(
+        cli,
+        [
+            "-n",
+            "--extra",
+            "dev",
+            "--extra",
+            "test",
+            "--find-links",
+            fake_dists,
+            meta_path,
+        ],
+    )
+    assert out.exit_code == 0, out.stderr
+    assert "small-fake-a==0.1" in out.stderr
+    assert "small-fake-b==0.2" in out.stderr
+    assert "small-fake-c==0.3" in out.stderr
+    assert "small-fake-d==0.4" in out.stderr
+    assert "small-fake-e==0.5" in out.stderr
+    assert "small-fake-f==0.6" in out.stderr
+    assert "extra ==" not in out.stderr
+
+
+def test_extras_fail_with_requirements_in(runner, tmpdir):
+    """
+    Test that passing `--extra` with `requirements.in` input file fails.
+    """
+    path = os.path.join(tmpdir, "requirements.in")
+    with open(path, "w") as stream:
+        stream.write("\n")
+    out = runner.invoke(cli, ["-n", "--extra", "something", path])
+    assert out.exit_code == 2
+    exp = "--extra has effect only with setup.py and PEP-517 input formats"
+    assert exp in out.stderr
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -9,6 +9,7 @@
 from piptools.utils import (
     as_tuple,
     dedup,
+    drop_extras,
     flat_map,
     format_requirement,
     format_specifier,
@@ -367,3 +368,58 @@ def test_lookup_table_from_tuples_with_empty_values():
 
 def test_lookup_table_with_empty_values():
     assert lookup_table((), operator.itemgetter(0)) == {}
+
+
+@pytest.mark.parametrize(
+    ("given", "expected"),
+    (
+        ("", None),
+        ("extra == 'dev'", None),
+        ("extra == 'dev' or extra == 'test'", None),
+        ("os_name == 'nt' and extra == 'dev'", "os_name == 'nt'"),
+        ("extra == 'dev' and os_name == 'nt'", "os_name == 'nt'"),
+        ("os_name == 'nt' or extra == 'dev'", "os_name == 'nt'"),
+        ("extra == 'dev' or os_name == 'nt'", "os_name == 'nt'"),
+        ("(extra == 'dev') or os_name == 'nt'", "os_name == 'nt'"),
+        ("os_name == 'nt' and (extra == 'dev' or extra == 'test')", "os_name == 'nt'"),
+        ("os_name == 'nt' or (extra == 'dev' or extra == 'test')", "os_name == 'nt'"),
+        ("(extra == 'dev' or extra == 'test') or os_name == 'nt'", "os_name == 'nt'"),
+        ("(extra == 'dev' or extra == 'test') and os_name == 'nt'", "os_name == 'nt'"),
+        (
+            "os_name == 'nt' or (os_name == 'unix' and extra == 'test')",
+            "os_name == 'nt' or os_name == 'unix'",
+        ),
+        (
+            "(os_name == 'unix' and extra == 'test') or os_name == 'nt'",
+            "os_name == 'unix' or os_name == 'nt'",
+        ),
+        (
+            "(os_name == 'unix' or extra == 'test') and os_name == 'nt'",
+            "os_name == 'unix' and os_name == 'nt'",
+        ),
+        (
+            "(os_name == 'unix' or os_name == 'nt') and extra == 'dev'",
+            "os_name == 'unix' or os_name == 'nt'",
+        ),
+        (
+            "(os_name == 'unix' and extra == 'test' or python_version < '3.5')"
+            " or os_name == 'nt'",
+            "(os_name == 'unix' or python_version < '3.5') or os_name == 'nt'",
+        ),
+        (
+            "os_name == 'unix' and extra == 'test' or os_name == 'nt'",
+            "os_name == 'unix' or os_name == 'nt'",
+        ),
+        (
+            "os_name == 'unix' or extra == 'test' and os_name == 'nt'",
+            "os_name == 'unix' or os_name == 'nt'",
+        ),
+    ),
+)
+def test_drop_extras(from_line, given, expected):
+    ireq = from_line(f"test;{given}")
+    drop_extras(ireq)
+    if expected is None:
+        assert ireq.markers is None
+    else:
+        assert str(ireq.markers).replace("'", '"') == expected.replace("'", '"')