Repository: pypa/setuptools

Issue:
Include location of bad METADATA files in errors
`pkg_resources` should probably be including the location of the errant METADATA (e.g. path to the file) in the `ValueError` message to make troubleshooting easier. Maybe someone can file an issue there.

_Originally posted by @cjerdonek in https://github.com/pypa/pip/issues/6194#issuecomment-458878882_
Include location of bad METADATA files in errors
`pkg_resources` should probably be including the location of the errant METADATA (e.g. path to the file) in the `ValueError` message to make troubleshooting easier. Maybe someone can file an issue there.

_Originally posted by @cjerdonek in https://github.com/pypa/pip/issues/6194#issuecomment-458878882_


Discussion:
By the way, what is the purpose of this repo? https://github.com/pypa/pkg_resources Is it still being used?
> What is the purpose of the pkg_resources repo?

I was planning to extract pkg_resources as a separate package and vendor it in setuptools (#863), but my work stalled on that and that repo is now stale. Presumably it wouldn't take much to merge it with recent changes found authoritatively in the setuptools repo. The one thing I want to avoid is maintaining the functionality in two places, so any transition should be clear-cut.

Instead, I've been focusing my energy on [importlib_resources](https://gitlab.com/python-devs/importlib_resources/), which promises to obviate many if not most of the use-cases of pkg_resources... and is planned to become part of the stdlib in Python 3.8.

Funny (not funny) how my goal not to maintain the functionality in two places led me to just that approach.
@zooba had a good suggestion here https://github.com/pypa/pip/issues/6251#issuecomment-463713318 to make a custom type for these errors. That would let callers like pip provide more information to the user without having to expose an exception object or traceback. The exception could have as attributes the expected path of the METADATA file and, if applicable, the name of the attribute being accessed.
By the way, what is the purpose of this repo? https://github.com/pypa/pkg_resources Is it still being used?
> What is the purpose of the pkg_resources repo?

I was planning to extract pkg_resources as a separate package and vendor it in setuptools (#863), but my work stalled on that and that repo is now stale. Presumably it wouldn't take much to merge it with recent changes found authoritatively in the setuptools repo. The one thing I want to avoid is maintaining the functionality in two places, so any transition should be clear-cut.

Instead, I've been focusing my energy on [importlib_resources](https://gitlab.com/python-devs/importlib_resources/), which promises to obviate many if not most of the use-cases of pkg_resources... and is planned to become part of the stdlib in Python 3.8.

Funny (not funny) how my goal not to maintain the functionality in two places led me to just that approach.
@zooba had a good suggestion here https://github.com/pypa/pip/issues/6251#issuecomment-463713318 to make a custom type for these errors. That would let callers like pip provide more information to the user without having to expose an exception object or traceback. The exception could have as attributes the expected path of the METADATA file and, if applicable, the name of the attribute being accessed.

Resolving patch:
diff --git a/pkg_resources/__init__.py b/pkg_resources/__init__.py
--- a/pkg_resources/__init__.py
+++ b/pkg_resources/__init__.py
@@ -1403,8 +1403,15 @@ def get_resource_string(self, manager, resource_name):
     def has_resource(self, resource_name):
         return self._has(self._fn(self.module_path, resource_name))
 
+    def _get_metadata_path(self, name):
+        return self._fn(self.egg_info, name)
+
     def has_metadata(self, name):
-        return self.egg_info and self._has(self._fn(self.egg_info, name))
+        if not self.egg_info:
+            return self.egg_info
+
+        path = self._get_metadata_path(name)
+        return self._has(path)
 
     def get_metadata(self, name):
         if not self.egg_info:
@@ -1868,6 +1875,9 @@ class FileMetadata(EmptyProvider):
     def __init__(self, path):
         self.path = path
 
+    def _get_metadata_path(self, name):
+        return self.path
+
     def has_metadata(self, name):
         return name == 'PKG-INFO' and os.path.isfile(self.path)
 
@@ -2661,10 +2671,14 @@ def version(self):
         try:
             return self._version
         except AttributeError:
-            version = _version_from_file(self._get_metadata(self.PKG_INFO))
+            version = self._get_version()
             if version is None:
-                tmpl = "Missing 'Version:' header and/or %s file"
-                raise ValueError(tmpl % self.PKG_INFO, self)
+                path = self._get_metadata_path_for_display(self.PKG_INFO)
+                msg = (
+                    "Missing 'Version:' header and/or {} file at path: {}"
+                ).format(self.PKG_INFO, path)
+                raise ValueError(msg, self)
+
             return version
 
     @property
@@ -2722,11 +2736,34 @@ def requires(self, extras=()):
                 )
         return deps
 
+    def _get_metadata_path_for_display(self, name):
+        """
+        Return the path to the given metadata file, if available.
+        """
+        try:
+            # We need to access _get_metadata_path() on the provider object
+            # directly rather than through this class's __getattr__()
+            # since _get_metadata_path() is marked private.
+            path = self._provider._get_metadata_path(name)
+
+        # Handle exceptions e.g. in case the distribution's metadata
+        # provider doesn't support _get_metadata_path().
+        except Exception:
+            return '[could not detect]'
+
+        return path
+
     def _get_metadata(self, name):
         if self.has_metadata(name):
             for line in self.get_metadata_lines(name):
                 yield line
 
+    def _get_version(self):
+        lines = self._get_metadata(self.PKG_INFO)
+        version = _version_from_file(lines)
+
+        return version
+
     def activate(self, path=None, replace=False):
         """Ensure distribution is importable on `path` (default=sys.path)"""
         if path is None:
@@ -2945,7 +2982,7 @@ def _reload_version(self):
         take an extra step and try to get the version number from
         the metadata file itself instead of the filename.
         """
-        md_version = _version_from_file(self._get_metadata(self.PKG_INFO))
+        md_version = self._get_version()
         if md_version:
             self._version = md_version
         return self


Test patch:
diff --git a/pkg_resources/tests/test_pkg_resources.py b/pkg_resources/tests/test_pkg_resources.py
--- a/pkg_resources/tests/test_pkg_resources.py
+++ b/pkg_resources/tests/test_pkg_resources.py
@@ -17,6 +17,7 @@
 except ImportError:
     import mock
 
+from pkg_resources import DistInfoDistribution, Distribution, EggInfoDistribution
 from pkg_resources.extern.six.moves import map
 from pkg_resources.extern.six import text_type, string_types
 
@@ -190,6 +191,89 @@ def test_setuptools_not_imported(self):
         subprocess.check_call(cmd)
 
 
+# TODO: remove this in favor of Path.touch() when Python 2 is dropped.
+def touch_file(path):
+    """
+    Create an empty file.
+    """
+    with open(path, 'w'):
+        pass
+
+
+def make_distribution_no_version(tmpdir, basename):
+    """
+    Create a distribution directory with no file containing the version.
+    """
+    # Convert the LocalPath object to a string before joining.
+    dist_dir = os.path.join(str(tmpdir), basename)
+    os.mkdir(dist_dir)
+    # Make the directory non-empty so distributions_from_metadata()
+    # will detect it and yield it.
+    touch_file(os.path.join(dist_dir, 'temp.txt'))
+
+    dists = list(pkg_resources.distributions_from_metadata(dist_dir))
+    assert len(dists) == 1
+    dist, = dists
+
+    return dist, dist_dir
+
+
+@pytest.mark.parametrize(
+    'suffix, expected_filename, expected_dist_type',
+    [
+        ('egg-info', 'PKG-INFO', EggInfoDistribution),
+        ('dist-info', 'METADATA', DistInfoDistribution),
+    ],
+)
+def test_distribution_version_missing(tmpdir, suffix, expected_filename,
+    expected_dist_type):
+    """
+    Test Distribution.version when the "Version" header is missing.
+    """
+    basename = 'foo.{}'.format(suffix)
+    dist, dist_dir = make_distribution_no_version(tmpdir, basename)
+
+    expected_text = (
+        "Missing 'Version:' header and/or {} file at path: "
+    ).format(expected_filename)
+    metadata_path = os.path.join(dist_dir, expected_filename)
+
+    # Now check the exception raised when the "version" attribute is accessed.
+    with pytest.raises(ValueError) as excinfo:
+        dist.version
+
+    err = str(excinfo)
+    # Include a string expression after the assert so the full strings
+    # will be visible for inspection on failure.
+    assert expected_text in err, str((expected_text, err))
+
+    # Also check the args passed to the ValueError.
+    msg, dist = excinfo.value.args
+    assert expected_text in msg
+    # Check that the message portion contains the path.
+    assert metadata_path in msg, str((metadata_path, msg))
+    assert type(dist) == expected_dist_type
+
+
+def test_distribution_version_missing_undetected_path():
+    """
+    Test Distribution.version when the "Version" header is missing and
+    the path can't be detected.
+    """
+    # Create a Distribution object with no metadata argument, which results
+    # in an empty metadata provider.
+    dist = Distribution('/foo')
+    with pytest.raises(ValueError) as excinfo:
+        dist.version
+
+    msg, dist = excinfo.value.args
+    expected = (
+        "Missing 'Version:' header and/or PKG-INFO file at path: "
+        '[could not detect]'
+    )
+    assert msg == expected
+
+
 class TestDeepVersionLookupDistutils:
     @pytest.fixture
     def env(self, tmpdir):