Repository: python-poetry/poetry

Issue:
poetry export -f requirements.txt adds extras to the extras.
<!--
  Hi there! Thank you for discovering and submitting an issue.

  Before you submit this; let's make sure of a few things.
  Please make sure the following boxes are ticked if they are correct.
  If not, please try and fulfill these first.
-->

<!-- Checked checkbox should look like this: [x] -->
- [X] I am on the [latest](https://github.com/sdispater/poetry/releases/latest) Poetry version.
- [X] I have searched the [issues](https://github.com/sdispater/poetry/issues) of this repo and believe that this is not a duplicate.
- [X] If an exception occurs when executing a command, I executed it again in debug mode (`-vvv` option).

<!--
  Once those are done, if you're able to fill in the following list with your information,
  it'd be very helpful to whoever handles the issue.
-->

- **OS version and name**: Mac 10.14.5, Ubuntu 19.04 (Docker)
- **Poetry version**: 1.0.0b1
<!-- Gist Link Here -->

## Issue
When you have a dependency with extra's in your pyproject.toml and you export it to a requirements.txt file the extras argument get's added to the dependency itself:

pyproject.toml
```
django-anymail = {version = "^6.0", extras = ["sparkpost"]}
django-phonenumber-field = {version = "^3.0", extras = ["phonenumbers"]}
```
Then when I do a poetry export -f requirements.txt the output is:

```
...
phonenumbers==8.10.17; extra == "phonenumbers" \
    --hash=sha256:b913023be4f99a210038efd3cef5212b3d11fdf7829a0b75a0031cff3eae2d9e \
    --hash=sha256:e9752dda6abb076d528d954d16c8bc7f8682df6f42c837862d0dea88c7667908
...
sparkpost==1.3.6; extra == "sparkpost" \
    --hash=sha256:75d1a408c7a581377ac9cc2cfef1de1249e49cb631921033cc3ff115b08c2c81 \
    --hash=sha256:abaf5045d04d821f9c29c965d97173f52f6acc2051debca6f122ea7456cc1733
...
```
Which then when I try to pip install it results in:
```
Ignoring phonenumbers: markers 'extra == "phonenumbers"' don't match your environment
Ignoring sparkpost: markers 'extra == "sparkpost"' don't match your environment
```
This does not happen in 1.0.0a5, so I reverted back for now.




Discussion:
in #1293 @tommilligan mentioned that the issue was introduced somewhere between 442ff52 and e987f01.
Looking at the changes, it might be because of  the changes in #1277 
This following patch adds a failing test against `develop`, although I don't know what the expected behaviour should be.

Can be run with `pytest -xk test_exporter_exports_requirements_txt_with_extras`

```diff
diff --git a/tests/utils/test_exporter.py b/tests/utils/test_exporter.py
index 0ca7b1a..04b25ed 100644
--- a/tests/utils/test_exporter.py
+++ b/tests/utils/test_exporter.py
@@ -309,6 +309,58 @@ foo==1.2.3 \\
 
     assert expected == content
 
+def test_exporter_exports_requirements_txt_with_extras(tmp_dir, poetry):
+    poetry.locker.mock_lock_data(
+        {
+            "package": [
+                {
+                    "name": "foo",
+                    "version": "1.2.3",
+                    "category": "main",
+                    "optional": False,
+                    "python-versions": "*",
+                    "extras": {"feature_bar": ["bar (>=4.5.0)"]},
+                    "dependencies": {
+                        "bar": {"optional": True, "version": ">=4.5.0"}
+                    },
+                },
+                {
+                    "name": "bar",
+                    "version": "4.5.6",
+                    "category": "main",
+                    "optional": False,
+                    "python-versions": "*",
+                    "marker": "extra == \"bar\""
+                },
+            ],
+            "metadata": {
+                "python-versions": "*",
+                "content-hash": "123456789",
+                "hashes": {"foo": ["12345"], "bar": ["67890"]},
+            },
+        }
+    )
+    exporter = Exporter(poetry)
+
+    exporter.export(
+        "requirements.txt",
+        Path(tmp_dir),
+        "requirements.txt",
+        dev=False,
+    )
+
+    with (Path(tmp_dir) / "requirements.txt").open(encoding="utf-8") as f:
+        content = f.read()
+
+    expected = """\
+bar==4.5.6 \\
+    --hash=sha256:67890
+foo==1.2.3 \\
+    --hash=sha256:12345
+"""
+
+    assert expected == content
+
 
 def test_exporter_can_export_requirements_txt_with_git_packages(tmp_dir, poetry):
     poetry.locker.mock_lock_data(
```
Seeing your test made me think about the lock file:
```
[[package]]
category = "main"
description = "An international phone number field for django models."
name = "django-phonenumber-field"
optional = false
python-versions = ">=3.5"
version = "3.0.1"

[package.dependencies]
Django = ">=1.11.3"
babel = "*"

[package.dependencies.phonenumbers]
optional = true
version = ">=7.0.2"

[package.extras]
phonenumbers = ["phonenumbers (>=7.0.2)"]
phonenumberslite = ["phonenumberslite (>=7.0.2)"]

[[package]]
category = "main"
description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers."
marker = "extra == \"phonenumbers\""
name = "phonenumbers"
optional = false
python-versions = "*"
version = "8.10.17"

[[package]]
category = "main"
description = "Django email integration for Amazon SES, Mailgun, Mailjet, Postmark, SendGrid, SendinBlue, SparkPost and other transactional ESPs"
name = "django-anymail"
optional = false
python-versions = "*"
version = "6.1.0"

[package.dependencies]
django = ">=1.11"
requests = ">=2.4.3"
six = "*"

[package.dependencies.sparkpost]
optional = true
version = "*"

[package.extras]
amazon_ses = ["boto3"]
sparkpost = ["sparkpost"]

[[package]]
category = "main"
description = "SparkPost Python API client"
marker = "extra == \"sparkpost\""
name = "sparkpost"
optional = false
python-versions = "*"
version = "1.3.6"
```

So maybe the bug is that the lock file has the extra marker there for the thing that is the extra instead of the package where is was given?
Bit sad that 1.0.0b2 still didn't fix the extra marker on the extra in the lock file. (talikng about the `marker = "extra == \"phonenumbers\"` and the `marker = "extra == \"sparkpost\""` in the file above which shouldn't be there but on the original package.)

The marker is for the original package, not for the package that gets installed because of it.
FWIW, this still appears to be an issue in 1.0.0b3
I did a lot of thinking on this.

The extras are just there to tell the package which other optional dependencies to install and let the package decide on the versions.

But when installing from the lock file or exported requirements.txt it should be ignored because those optional dependencies are already resolved and added, so the extra's should just be removed.


As a fix for now I moved those extra dependencies to my own pyproject.toml file, but this means I need to manage those myself when the main package updates.

Resolving patch:
diff --git a/poetry/puzzle/solver.py b/poetry/puzzle/solver.py
--- a/poetry/puzzle/solver.py
+++ b/poetry/puzzle/solver.py
@@ -219,8 +219,12 @@ def _build_graph(
         else:
             category = dep.category
             optional = dep.is_optional() and not dep.is_activated()
-            intersection = previous["marker"].intersect(previous_dep.marker)
-            intersection = intersection.intersect(package.marker)
+            intersection = (
+                previous["marker"]
+                .without_extras()
+                .intersect(previous_dep.marker.without_extras())
+            )
+            intersection = intersection.intersect(package.marker.without_extras())
 
             marker = intersection
 
diff --git a/poetry/version/markers.py b/poetry/version/markers.py
--- a/poetry/version/markers.py
+++ b/poetry/version/markers.py
@@ -204,6 +204,15 @@ def __str__(self):
     def __repr__(self):
         return "<AnyMarker>"
 
+    def __hash__(self):
+        return hash(("<any>", "<any>"))
+
+    def __eq__(self, other):
+        if not isinstance(other, BaseMarker):
+            return NotImplemented
+
+        return isinstance(other, AnyMarker)
+
 
 class EmptyMarker(BaseMarker):
     def intersect(self, other):
@@ -230,6 +239,15 @@ def __str__(self):
     def __repr__(self):
         return "<EmptyMarker>"
 
+    def __hash__(self):
+        return hash(("<empty>", "<empty>"))
+
+    def __eq__(self, other):
+        if not isinstance(other, BaseMarker):
+            return NotImplemented
+
+        return isinstance(other, EmptyMarker)
+
 
 class SingleMarker(BaseMarker):
 
@@ -329,7 +347,7 @@ def union(self, other):
             if self == other:
                 return self
 
-            return MarkerUnion(self, other)
+            return MarkerUnion.of(self, other)
 
         return other.union(self)
 
@@ -344,7 +362,7 @@ def validate(self, environment):
 
     def without_extras(self):
         if self.name == "extra":
-            return EmptyMarker()
+            return AnyMarker()
 
         return self
 
@@ -443,7 +461,7 @@ def intersect(self, other):
 
     def union(self, other):
         if isinstance(other, (SingleMarker, MultiMarker)):
-            return MarkerUnion(self, other)
+            return MarkerUnion.of(self, other)
 
         return other.union(self)
 
@@ -493,17 +511,24 @@ def __str__(self):
 
 class MarkerUnion(BaseMarker):
     def __init__(self, *markers):
-        self._markers = []
+        self._markers = list(markers)
 
-        markers = _flatten_markers(markers, MarkerUnion)
+    @property
+    def markers(self):
+        return self._markers
 
-        for marker in markers:
-            if marker in self._markers:
+    @classmethod
+    def of(cls, *markers):  # type: (tuple) -> MarkerUnion
+        flattened_markers = _flatten_markers(markers, MarkerUnion)
+
+        markers = []
+        for marker in flattened_markers:
+            if marker in markers:
                 continue
 
             if isinstance(marker, SingleMarker) and marker.name == "python_version":
                 intersected = False
-                for i, mark in enumerate(self._markers):
+                for i, mark in enumerate(markers):
                     if (
                         not isinstance(mark, SingleMarker)
                         or isinstance(mark, SingleMarker)
@@ -516,18 +541,19 @@ def __init__(self, *markers):
                         intersected = True
                         break
                     elif intersection == marker.constraint:
-                        self._markers[i] = marker
+                        markers[i] = marker
                         intersected = True
                         break
 
                 if intersected:
                     continue
 
-            self._markers.append(marker)
+            markers.append(marker)
 
-    @property
-    def markers(self):
-        return self._markers
+        if len(markers) == 1 and markers[0].is_any():
+            return AnyMarker()
+
+        return MarkerUnion(*markers)
 
     def append(self, marker):
         if marker in self._markers:
@@ -557,7 +583,7 @@ def intersect(self, other):
                     if not intersection.is_empty():
                         new_markers.append(intersection)
 
-        return MarkerUnion(*new_markers)
+        return MarkerUnion.of(*new_markers)
 
     def union(self, other):
         if other.is_any():
@@ -568,7 +594,7 @@ def union(self, other):
 
         new_markers = self._markers + [other]
 
-        return MarkerUnion(*new_markers)
+        return MarkerUnion.of(*new_markers)
 
     def validate(self, environment):
         for m in self._markers:
@@ -654,4 +680,4 @@ def _compact_markers(markers):
     if len(groups) == 1:
         return groups[0]
 
-    return MarkerUnion(*groups)
+    return MarkerUnion.of(*groups)


Test patch:
diff --git a/tests/puzzle/test_solver.py b/tests/puzzle/test_solver.py
--- a/tests/puzzle/test_solver.py
+++ b/tests/puzzle/test_solver.py
@@ -407,8 +407,10 @@ def test_solver_returns_extras_if_requested(solver, repo, package):
     package_b = get_package("B", "1.0")
     package_c = get_package("C", "1.0")
 
-    package_b.extras = {"foo": [get_dependency("C", "^1.0")]}
-    package_b.add_dependency("C", {"version": "^1.0", "optional": True})
+    dep = get_dependency("C", "^1.0", optional=True)
+    dep.marker = parse_marker("extra == 'foo'")
+    package_b.extras = {"foo": [dep]}
+    package_b.requires.append(dep)
 
     repo.add_package(package_a)
     repo.add_package(package_b)
@@ -425,6 +427,9 @@ def test_solver_returns_extras_if_requested(solver, repo, package):
         ],
     )
 
+    assert ops[-1].package.marker.is_any()
+    assert ops[0].package.marker.is_any()
+
 
 def test_solver_returns_prereleases_if_requested(solver, repo, package):
     package.add_dependency("A")
@@ -1148,10 +1153,7 @@ def test_solver_does_not_trigger_new_resolution_on_duplicate_dependencies_if_onl
         ],
     )
 
-    assert str(ops[0].package.marker) in [
-        'extra == "foo" or extra == "bar"',
-        'extra == "bar" or extra == "foo"',
-    ]
+    assert str(ops[0].package.marker) == ""
     assert str(ops[1].package.marker) == ""