Repository: pypa/setuptools

Issue:
entry_point with un-satisfied extras_require doesn't throw a DistributionNotFound if you installed your package as a wheel
This is somewhat-complex, but I think I have a handle on what's going on. I have a [project](https://github.com/jimporter/bfg9000) with a `setup.py` that looks like this:

```python
setup(
    extras_require={
        'msbuild': ['lxml'],
    },

    entry_points={
        'bfg9000.backends': [
            'msbuild=bfg9000.backends.msbuild.writer [msbuild]',
        ],
    },
)
```

The goal of this code is to say "the MSBuild backend requires the `lxml` package". Later, I do the following:

```python
from pkg_resources import iter_entry_points, DistributionNotFound
# ...
for i in iter_entry_points('bfg9000.backends'):
    try:
        backend = i.load()
        backends.append((i.name, backend))
    except DistributionNotFound:
        pass
```

The above should load all the backends whose requirements are met (i.e. don't load the MSBuild backend if you don't have lxml). This works fine when the package was **not** installed as a Wheel, but if it's a Wheel, setuptools tries to actually import the MSBuild backend and raises an `ImportError` instead of `DistributionNotFound`.

It appears to be caused by [this code](https://github.com/pypa/setuptools/blob/f783aa36971d78b293fb5d627d1aacd6dbd49afc/pkg_resources/__init__.py#L828-L829), which from my reading says, "If the extras for this requirement are not satisfied, skip this requirement." However, the requirement in this case is `lxml; extra == "msbuild"` (in a non-Wheel installation, it's just `lxml`), so pkg_resources is basically saying, "It's ok if you don't have lxml; just try to load the MSBuild backend." Unfortunately, that's the opposite of what I want, and isn't consistent with the docs:

> [An entry_point] can also include a bracketed list of “extras” that are required for the entry point to be used. When the invoking application or framework requests loading of an entry point, any requirements implied by the associated extras will be passed to pkg_resources.require(), so that an appropriate error message can be displayed if the needed package(s) are missing.

I assume "appropriate error message" in the above is supposed to mean a `DistributionNotFound` exception.


Discussion:

Resolving patch:
diff --git a/pkg_resources/__init__.py b/pkg_resources/__init__.py
--- a/pkg_resources/__init__.py
+++ b/pkg_resources/__init__.py
@@ -786,7 +786,7 @@ def add(self, dist, entry=None, insert=True, replace=False):
         self._added_new(dist)
 
     def resolve(self, requirements, env=None, installer=None,
-            replace_conflicting=False):
+                replace_conflicting=False, extras=None):
         """List all distributions needed to (recursively) meet `requirements`
 
         `requirements` must be a sequence of ``Requirement`` objects.  `env`,
@@ -802,6 +802,12 @@ def resolve(self, requirements, env=None, installer=None,
         the wrong version.  Otherwise, if an `installer` is supplied it will be
         invoked to obtain the correct version of the requirement and activate
         it.
+
+        `extras` is a list of the extras to be used with these requirements.
+        This is important because extra requirements may look like `my_req;
+        extra = "my_extra"`, which would otherwise be interpreted as a purely
+        optional requirement.  Instead, we want to be able to assert that these
+        requirements are truly required.
         """
 
         # set up the stack
@@ -825,7 +831,7 @@ def resolve(self, requirements, env=None, installer=None,
                 # Ignore cyclic or redundant dependencies
                 continue
 
-            if not req_extras.markers_pass(req):
+            if not req_extras.markers_pass(req, extras):
                 continue
 
             dist = best.get(req.key)
@@ -1004,7 +1010,7 @@ class _ReqExtras(dict):
     Map each requirement to the extras that demanded it.
     """
 
-    def markers_pass(self, req):
+    def markers_pass(self, req, extras=None):
         """
         Evaluate markers for req against each extra that
         demanded it.
@@ -1014,7 +1020,7 @@ def markers_pass(self, req):
         """
         extra_evals = (
             req.marker.evaluate({'extra': extra})
-            for extra in self.get(req, ()) + (None,)
+            for extra in self.get(req, ()) + (extras or (None,))
         )
         return not req.marker or any(extra_evals)
 
@@ -2299,8 +2305,14 @@ def resolve(self):
     def require(self, env=None, installer=None):
         if self.extras and not self.dist:
             raise UnknownExtra("Can't require() without a distribution", self)
+
+        # Get the requirements for this entry point with all its extras and
+        # then resolve them. We have to pass `extras` along when resolving so
+        # that the working set knows what extras we want. Otherwise, for
+        # dist-info distributions, the working set will assume that the
+        # requirements for that extra are purely optional and skip over them.
         reqs = self.dist.requires(self.extras)
-        items = working_set.resolve(reqs, env, installer)
+        items = working_set.resolve(reqs, env, installer, extras=self.extras)
         list(map(working_set.add, items))
 
     pattern = re.compile(


Test patch: