Expose package metadata from wheels in .par files (#49)

* Expose package metadata from wheels in .par files

* Update pkg_resources's default distribution metadata object.

`pkg_resources` creates a default WorkingSet as soon as it is
imported, so even though we add hooks for it to find the .dist-info
metadata inside .par files, it is too late.  So we manually update the
default WorkingSet, trying not to disturb existing entries.

* Cut down unnecessary test data.

* Fix lint errors
diff --git a/WORKSPACE b/WORKSPACE
index 89cbb3c..09df09f 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -5,6 +5,14 @@
     name = "test_workspace",
     path = "tests/test_workspace",
 )
+local_repository(
+    name = "pypi__portpicker_1_2_0",
+    path = "third_party/pypi__portpicker_1_2_0",
+)
+local_repository(
+    name = "pypi__yapf_0_19_0",
+    path = "third_party/pypi__yapf_0_19_0",
+)
 
 # Not actually referenced anywhere, but must be marked as a separate
 # repository so that things like "bazel test //..." don't get confused
diff --git a/runtime/support.py b/runtime/support.py
index d9e957a..0132e39 100644
--- a/runtime/support.py
+++ b/runtime/support.py
@@ -33,8 +33,10 @@
 """
 
 import os
+import pkgutil
 import sys
 import warnings
+import zipimport
 
 
 def _log(msg):
@@ -61,6 +63,97 @@
     return archive_path
 
 
+def _setup_pkg_resources(pkg_resources_name):
+    """Setup hooks into the `pkg_resources` module
+
+    This enables the pkg_resources module to find metadata from wheels
+    that have been included in this .par file.
+
+    The functions and classes here are scoped to this function, since
+    we might have multitple pkg_resources modules, or none.
+    """
+
+    try:
+        __import__(pkg_resources_name)
+        pkg_resources = sys.modules.get(pkg_resources_name)
+        if pkg_resources is None:
+            return
+    except ImportError:
+        # Skip setup
+        return
+
+    class DistInfoMetadata(pkg_resources.EggMetadata):
+        """Metadata provider for zip files containing .dist-info
+
+        In find_dist_info_in_zip(), we call
+        metadata.resource_listdir(directory_name).  However, it doesn't
+        work with EggMetadata, because _zipinfo_name() expects the
+        directory name to end with a /, but metadata._listdir() which
+        expects the directory to _not_ end with a /.
+
+        Therefore this class exists.
+        """
+
+        def _listdir(self, fspath):
+            """List of resource names in the directory (like ``os.listdir()``)
+
+            Overrides EggMetadata._listdir()
+            """
+
+            zipinfo_name = self._zipinfo_name(fspath)
+            while zipinfo_name.endswith('/'):
+                zipinfo_name = zipinfo_name[:-1]
+            result = self._index().get(zipinfo_name, ())
+            return list(result)
+
+    def find_dist_info_in_zip(importer, path_item, only=False):
+        """Find dist-info style metadata in zip files.
+
+        We ignore the `only` flag because it's not clear what it should
+        actually do in this case.
+        """
+        metadata = DistInfoMetadata(importer)
+        for subitem in metadata.resource_listdir('/'):
+            if subitem.lower().endswith('.dist-info'):
+                subpath = os.path.join(path_item, subitem)
+                submeta = pkg_resources.EggMetadata(
+                    zipimport.zipimporter(subpath))
+                submeta.egg_info = subpath
+                dist = pkg_resources.Distribution.from_location(
+                    path_item, subitem, submeta)
+                yield dist
+        return
+
+    def find_eggs_and_dist_info_in_zip(importer, path_item, only=False):
+        """Chain together our finder and the standard pkg_resources finder
+
+        For simplicity, and since pkg_resources doesn't provide a public
+        interface to do so, we hardcode the chaining (find_eggs_in_zip).
+        """
+        # Our finder
+        for dist in find_dist_info_in_zip(importer, path_item, only):
+            yield dist
+        # The standard pkg_resources finder
+        for dist in pkg_resources.find_eggs_in_zip(importer, path_item, only):
+            yield dist
+        return
+
+    # This overwrites the existing registered finder.
+    pkg_resources.register_finder(zipimport.zipimporter,
+                                  find_eggs_and_dist_info_in_zip)
+
+    # Note that the default WorkingSet has already been created, and
+    # there is no public interface to easily refresh/reload it that
+    # doesn't also have a "Don't use this" warning.  So we manually
+    # add just the entries we know about to the existing WorkingSet.
+    for entry in sys.path:
+        importer = pkgutil.get_importer(entry)
+        if isinstance(importer, zipimport.zipimporter):
+            for dist in find_dist_info_in_zip(importer, entry, only=True):
+                pkg_resources.working_set.add(dist, entry, insert=False,
+                                              replace=False)
+
+
 def setup(import_roots=None):
     """Initialize subpar run-time support"""
     # Add third-party library entries to sys.path
@@ -75,3 +168,7 @@
         new_path = os.path.join(archive_path, import_root)
         _log('# adding %s to sys.path' % new_path)
         sys.path.insert(1, new_path)
+
+    # Add hook for package metadata
+    _setup_pkg_resources('pkg_resources')
+    _setup_pkg_resources('pip._vendor.pkg_resources')
diff --git a/runtime/support_test.py b/runtime/support_test.py
index ab25bd6..3ec1063 100644
--- a/runtime/support_test.py
+++ b/runtime/support_test.py
@@ -21,7 +21,7 @@
 
 class SupportTest(unittest.TestCase):
 
-    def test_log(self):
+    def test__log(self):
         old_stderr = sys.stderr
         try:
             mock_stderr = io.StringIO()
@@ -36,17 +36,35 @@
         finally:
             sys.stderr = old_stderr
 
-    def test_find_archive(self):
+    def test__find_archive(self):
         # pylint: disable=protected-access
         path = support._find_archive()
         self.assertNotEqual(path, None)
 
     def test_setup(self):
-        support.setup(import_roots=['some_root', 'another_root'])
-        self.assertTrue(sys.path[1].endswith('subpar/runtime/some_root'),
-                        sys.path)
-        self.assertTrue(sys.path[2].endswith('subpar/runtime/another_root'),
-                        sys.path)
+        # `import pip` can cause arbitrary sys.path changes,
+        # especially if using the Debian `python-pip` package or
+        # similar.  Get that lunacy out of the way before starting
+        # test
+        try:
+            import pip  # noqa
+        except ImportError:
+            pass
+
+        old_sys_path = sys.path
+        try:
+            mock_sys_path = list(sys.path)
+            sys.path = mock_sys_path
+            support.setup(import_roots=['some_root', 'another_root'])
+        finally:
+            sys.path = old_sys_path
+        self.assertTrue(mock_sys_path[1].endswith('subpar/runtime/some_root'),
+                        mock_sys_path)
+        self.assertTrue(
+            mock_sys_path[2].endswith('subpar/runtime/another_root'),
+            mock_sys_path)
+        self.assertEqual(mock_sys_path[0], sys.path[0])
+        self.assertEqual(mock_sys_path[3:], sys.path[1:])
 
 
 if __name__ == '__main__':
diff --git a/tests/BUILD b/tests/BUILD
index 0d35eb7..0d215bc 100644
--- a/tests/BUILD
+++ b/tests/BUILD
@@ -104,6 +104,19 @@
     srcs_version = "PY2AND3",
 )
 
+par_binary(
+    name = "package_pkg_resources/main",
+    srcs = [
+        "package_pkg_resources/main.py",
+    ],
+    data = [
+        "@pypi__portpicker_1_2_0//:files",
+        "@pypi__yapf_0_19_0//:files",
+    ],
+    main = "package_pkg_resources/main.py",
+    srcs_version = "PY2AND3",
+)
+
 # Test targets
 [(
     # Run test without .par file as a control
@@ -154,6 +167,7 @@
     ),
     ("indirect_dependency", "//tests:package_c/c", "tests/package_c/c"),
     ("main_boilerplate", "//tests:package_g/g", "tests/package_g/g"),
+    ("pkg_resources", "//tests:package_pkg_resources/main", "tests/package_pkg_resources/main"),
     ("shadow", "//tests:package_shadow/main", "tests/package_shadow/main"),
     ("version", "//tests:package_f/f", "tests/package_f/f"),
 ]]
diff --git a/tests/package_pkg_resources/main.py b/tests/package_pkg_resources/main.py
new file mode 100644
index 0000000..dea801d
--- /dev/null
+++ b/tests/package_pkg_resources/main.py
@@ -0,0 +1,45 @@
+# Copyright 2017 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Integration test program for Subpar.
+
+Test that pkg_resources correctly identifies distribution packages
+inside a .par file.
+"""
+
+
+def main():
+    print('In pkg_resources test main()')
+    try:
+        import pkg_resources
+    except ImportError:
+        print('Skipping test, pkg_resources module is not available')
+        return
+
+    ws = pkg_resources.working_set
+
+    # Informational for debugging
+    distributions = list(ws)
+    print('Resources found: %s' % distributions)
+
+    # Check for the packages we provided metadata for.  There will
+    # also be metadata for whatever other packages happen to be
+    # installed in the current Python interpreter.
+    for spec in ['portpicker==1.2.0', 'yapf==0.19.0']:
+        dist = ws.find(pkg_resources.Requirement.parse(spec))
+        assert dist, (spec, distributions)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/tests/package_pkg_resources/main_PY2_filelist.txt b/tests/package_pkg_resources/main_PY2_filelist.txt
new file mode 100644
index 0000000..7b93f00
--- /dev/null
+++ b/tests/package_pkg_resources/main_PY2_filelist.txt
@@ -0,0 +1,12 @@
+__main__.py
+pypi__portpicker_1_2_0/portpicker-1.2.0.dist-info/METADATA
+pypi__portpicker_1_2_0/portpicker-1.2.0.dist-info/metadata.json
+pypi__yapf_0_19_0/yapf-0.19.0.dist-info/METADATA
+pypi__yapf_0_19_0/yapf-0.19.0.dist-info/metadata.json
+subpar/__init__.py
+subpar/runtime/__init__.py
+subpar/runtime/support.py
+subpar/tests/__init__.py
+subpar/tests/package_pkg_resources/__init__.py
+subpar/tests/package_pkg_resources/main
+subpar/tests/package_pkg_resources/main.py
diff --git a/tests/package_pkg_resources/main_PY3_filelist.txt b/tests/package_pkg_resources/main_PY3_filelist.txt
new file mode 100644
index 0000000..7b93f00
--- /dev/null
+++ b/tests/package_pkg_resources/main_PY3_filelist.txt
@@ -0,0 +1,12 @@
+__main__.py
+pypi__portpicker_1_2_0/portpicker-1.2.0.dist-info/METADATA
+pypi__portpicker_1_2_0/portpicker-1.2.0.dist-info/metadata.json
+pypi__yapf_0_19_0/yapf-0.19.0.dist-info/METADATA
+pypi__yapf_0_19_0/yapf-0.19.0.dist-info/metadata.json
+subpar/__init__.py
+subpar/runtime/__init__.py
+subpar/runtime/support.py
+subpar/tests/__init__.py
+subpar/tests/package_pkg_resources/__init__.py
+subpar/tests/package_pkg_resources/main
+subpar/tests/package_pkg_resources/main.py
diff --git a/third_party/README.md b/third_party/README.md
new file mode 100644
index 0000000..6cbf2d2
--- /dev/null
+++ b/third_party/README.md
@@ -0,0 +1,9 @@
+# Wheels
+
+This directory contains code, metadata files, and wheels, from other projects,
+used for testing.  The projects were chosen to match the licence and ownership
+of this project, i.e. Apache License 2.0, copyright owned by Google, from the
+Google organization repository.  Do not add any other type of thing here.
+
+- [python_portpicker](https://github.com/google/python_portpicker)
+- [yapf](https://github.com/google/yapf)
diff --git a/third_party/pypi__portpicker_1_2_0/BUILD b/third_party/pypi__portpicker_1_2_0/BUILD
new file mode 100644
index 0000000..6cff5d5
--- /dev/null
+++ b/third_party/pypi__portpicker_1_2_0/BUILD
@@ -0,0 +1,10 @@
+# Test package for Subpar
+
+package(default_visibility = ["//visibility:public"])
+
+py_library(
+    name = "files",
+    srcs = [],
+    data = glob(["portpicker-1.2.0.dist-info/**"]),
+    imports = ["."],
+)
diff --git a/third_party/pypi__portpicker_1_2_0/WORKSPACE b/third_party/pypi__portpicker_1_2_0/WORKSPACE
new file mode 100644
index 0000000..57c31df
--- /dev/null
+++ b/third_party/pypi__portpicker_1_2_0/WORKSPACE
@@ -0,0 +1 @@
+workspace(name = "pypi__portpicker_1_2_0")
diff --git a/third_party/pypi__portpicker_1_2_0/portpicker-1.2.0.dist-info/METADATA b/third_party/pypi__portpicker_1_2_0/portpicker-1.2.0.dist-info/METADATA
new file mode 100644
index 0000000..8e852c1
--- /dev/null
+++ b/third_party/pypi__portpicker_1_2_0/portpicker-1.2.0.dist-info/METADATA
@@ -0,0 +1,29 @@
+Metadata-Version: 2.0
+Name: portpicker
+Version: 1.2.0
+Summary: A library to choose unique available network ports.
+Home-page: https://github.com/google/python_portpicker
+Author: Google
+Author-email: greg@krypto.org
+License: Apache 2.0
+Description-Content-Type: UNKNOWN
+Platform: POSIX
+Classifier: Development Status :: 5 - Production/Stable
+Classifier: License :: OSI Approved :: Apache Software License
+Classifier: Intended Audience :: Developers
+Classifier: Programming Language :: Python
+Classifier: Programming Language :: Python :: 2
+Classifier: Programming Language :: Python :: 2.7
+Classifier: Programming Language :: Python :: 3
+Classifier: Programming Language :: Python :: 3.3
+Classifier: Programming Language :: Python :: 3.4
+Classifier: Programming Language :: Python :: 3.5
+Classifier: Programming Language :: Python :: 3.6
+Classifier: Programming Language :: Python :: Implementation :: CPython
+Classifier: Programming Language :: Python :: Implementation :: Jython
+Classifier: Programming Language :: Python :: Implementation :: PyPy
+
+Portpicker provides an API to find and return an available network
+port for an application to bind to.  Ideally suited for use from
+unittests or for test harnesses that launch local servers.
+
diff --git a/third_party/pypi__portpicker_1_2_0/portpicker-1.2.0.dist-info/metadata.json b/third_party/pypi__portpicker_1_2_0/portpicker-1.2.0.dist-info/metadata.json
new file mode 100644
index 0000000..6ec9c43
--- /dev/null
+++ b/third_party/pypi__portpicker_1_2_0/portpicker-1.2.0.dist-info/metadata.json
@@ -0,0 +1 @@
+{"classifiers": ["Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", "Intended Audience :: Developers", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: Jython", "Programming Language :: Python :: Implementation :: PyPy"], "description_content_type": "UNKNOWN", "extensions": {"python.details": {"contacts": [{"email": "greg@krypto.org", "name": "Google", "role": "author"}], "document_names": {"description": "DESCRIPTION.rst"}, "project_urls": {"Home": "https://github.com/google/python_portpicker"}}}, "generator": "bdist_wheel (0.30.0)", "license": "Apache 2.0", "metadata_version": "2.0", "name": "portpicker", "platform": "POSIX", "summary": "A library to choose unique available network ports.", "version": "1.2.0"}
\ No newline at end of file
diff --git a/third_party/pypi__yapf_0_19_0/BUILD b/third_party/pypi__yapf_0_19_0/BUILD
new file mode 100644
index 0000000..cd12f69
--- /dev/null
+++ b/third_party/pypi__yapf_0_19_0/BUILD
@@ -0,0 +1,10 @@
+# Test package for Subpar
+
+package(default_visibility = ["//visibility:public"])
+
+py_library(
+    name = "files",
+    srcs = [],
+    data = glob(["yapf-0.19.0.dist-info/**"]),
+    imports = ["."],
+)
diff --git a/third_party/pypi__yapf_0_19_0/WORKSPACE b/third_party/pypi__yapf_0_19_0/WORKSPACE
new file mode 100644
index 0000000..eae31db
--- /dev/null
+++ b/third_party/pypi__yapf_0_19_0/WORKSPACE
@@ -0,0 +1 @@
+workspace(name = "pypi__yapf_0_19_0")
diff --git a/third_party/pypi__yapf_0_19_0/yapf-0.19.0.dist-info/METADATA b/third_party/pypi__yapf_0_19_0/yapf-0.19.0.dist-info/METADATA
new file mode 100644
index 0000000..067f12a
--- /dev/null
+++ b/third_party/pypi__yapf_0_19_0/yapf-0.19.0.dist-info/METADATA
@@ -0,0 +1,24 @@
+Metadata-Version: 2.0
+Name: yapf
+Version: 0.19.0
+Summary: A formatter for Python code.
+Home-page: UNKNOWN
+Author: Bill Wendling
+Author-email: morbo@google.com
+License: Apache License, Version 2.0
+Platform: UNKNOWN
+Classifier: Development Status :: 4 - Beta
+Classifier: Environment :: Console
+Classifier: Intended Audience :: Developers
+Classifier: License :: OSI Approved :: Apache Software License
+Classifier: Operating System :: OS Independent
+Classifier: Programming Language :: Python
+Classifier: Programming Language :: Python :: 2
+Classifier: Programming Language :: Python :: 2.7
+Classifier: Programming Language :: Python :: 3
+Classifier: Programming Language :: Python :: 3.4
+Classifier: Programming Language :: Python :: 3.5
+Classifier: Topic :: Software Development :: Libraries :: Python Modules
+Classifier: Topic :: Software Development :: Quality Assurance
+
+Text from original package has been elided.
diff --git a/third_party/pypi__yapf_0_19_0/yapf-0.19.0.dist-info/metadata.json b/third_party/pypi__yapf_0_19_0/yapf-0.19.0.dist-info/metadata.json
new file mode 100644
index 0000000..71e9541
--- /dev/null
+++ b/third_party/pypi__yapf_0_19_0/yapf-0.19.0.dist-info/metadata.json
@@ -0,0 +1 @@
+{"classifiers": ["Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Quality Assurance"], "extensions": {"python.commands": {"wrap_console": {"yapf": "yapf:run_main"}}, "python.details": {"contacts": [{"email": "morbo@google.com", "name": "Bill Wendling", "role": "author"}], "document_names": {"description": "DESCRIPTION.rst"}}, "python.exports": {"console_scripts": {"yapf": "yapf:run_main"}}}, "generator": "bdist_wheel (0.29.0)", "license": "Apache License, Version 2.0", "metadata_version": "2.0", "name": "yapf", "summary": "A formatter for Python code.", "version": "0.19.0"}
\ No newline at end of file