wheel: support for 'plugin' type entry_points (#349)

* wheel: support for 'plugin' type entry_points

See https://packaging.python.org/guides/creating-and-discovering-plugins/\#using-package-metadata
diff --git a/experimental/examples/wheel/BUILD b/experimental/examples/wheel/BUILD
index 528e3f5..06371fe 100644
--- a/experimental/examples/wheel/BUILD
+++ b/experimental/examples/wheel/BUILD
@@ -77,6 +77,10 @@
     description_file = "README.md",
     # Package data. We're building "example_customized-0.0.1-py3-none-any.whl"
     distribution = "example_customized",
+    entry_points = {
+        "console_scripts": ["another = foo.bar:baz"],
+        "group2": ["second = second.main:s", "first = first.main:f"]
+    },
     homepage = "www.example.com",
     license = "Apache 2.0",
     python_tag = "py3",
diff --git a/experimental/examples/wheel/wheel_test.py b/experimental/examples/wheel/wheel_test.py
index ea535de..f5d9b19 100644
--- a/experimental/examples/wheel/wheel_test.py
+++ b/experimental/examples/wheel/wheel_test.py
@@ -70,12 +70,13 @@
                 'example_customized-0.0.1.dist-info/WHEEL')
             metadata_contents = zf.read(
                 'example_customized-0.0.1.dist-info/METADATA')
+            entry_point_contents = zf.read('example_customized-0.0.1.dist-info/entry_points.txt')
             # The entries are guaranteed to be sorted.
             self.assertEquals(record_contents, b"""\
 example_customized-0.0.1.dist-info/METADATA,sha256=TeeEmokHE2NWjkaMcVJuSAq4_AXUoIad2-SLuquRmbg,372
 example_customized-0.0.1.dist-info/RECORD,,
 example_customized-0.0.1.dist-info/WHEEL,sha256=F01lGfVCzcXUzzQHzUkBmXAcu_TXd5zqMLrvrspncJo,85
-example_customized-0.0.1.dist-info/entry_points.txt,sha256=olLJ8FK88aft2pcdj4BD05F8Xyz83Mo51I93tRGT2Yk,74
+example_customized-0.0.1.dist-info/entry_points.txt,sha256=mEWsq4sMoyqR807QV8Z3KPocGfKvtgTo1lBFTRb6b78,150
 experimental/examples/wheel/lib/data.txt,sha256=9vJKEdfLu8bZRArKLroPZJh1XKkK3qFMXiM79MBL2Sg,12
 experimental/examples/wheel/lib/module_with_data.py,sha256=K_IGAq_CHcZX0HUyINpD1hqSKIEdCn58d9E9nhWF2EA,636
 experimental/examples/wheel/lib/simple_module.py,sha256=72-91Dm6NB_jw-7wYQt7shzdwvk5RB0LujIah8g7kr8,636
@@ -101,6 +102,14 @@
 
 This is a sample description of a wheel.
 """)
+            self.assertEquals(entry_point_contents, b"""\
+[console_scripts]
+another = foo.bar:baz
+customized_wheel = experimental.examples.wheel.main:main
+
+[group2]
+first = first.main:f
+second = second.main:s""")
 
     def test_custom_package_root_wheel(self):
         filename = os.path.join(os.environ['TEST_SRCDIR'],
diff --git a/experimental/python/wheel.bzl b/experimental/python/wheel.bzl
index e4fb810..d1c46d6 100644
--- a/experimental/python/wheel.bzl
+++ b/experimental/python/wheel.bzl
@@ -99,11 +99,11 @@
     other_inputs = []
 
     # Wrap the inputs into a file to reduce command line length.
-    packageinputfile = ctx.actions.declare_file(ctx.attr.name + '_target_wrapped_inputs.txt')
-    content = ''
+    packageinputfile = ctx.actions.declare_file(ctx.attr.name + "_target_wrapped_inputs.txt")
+    content = ""
     for input_file in inputs_to_package.to_list():
-        content += _input_file_to_arg(input_file) + '\n'
-    ctx.actions.write(output = packageinputfile, content=content)
+        content += _input_file_to_arg(input_file) + "\n"
+    ctx.actions.write(output = packageinputfile, content = content)
     other_inputs.append(packageinputfile)
 
     args = ctx.actions.args()
@@ -140,8 +140,30 @@
         for r in requirements:
             args.add("--extra_requires", r + ";" + option)
 
-    for name, ref in ctx.attr.console_scripts.items():
-        args.add("--console_script", name + " = " + ref)
+    # Merge console_scripts into entry_points.
+    entrypoints = dict(ctx.attr.entry_points)  # Copy so we can mutate it
+    if ctx.attr.console_scripts:
+        # Copy a console_scripts group that may already exist, so we can mutate it.
+        console_scripts = list(entrypoints.get("console_scripts", []))
+        entrypoints["console_scripts"] = console_scripts
+        for name, ref in ctx.attr.console_scripts.items():
+            console_scripts.append("{name} = {ref}".format(name = name, ref = ref))
+
+    # If any entry_points are provided, construct the file here and add it to the files to be packaged.
+    # see: https://packaging.python.org/specifications/entry-points/
+    if entrypoints:
+        lines = []
+        for group, entries in sorted(entrypoints.items()):
+            if lines:
+                # Blank line between groups
+                lines.append("")
+            lines.append("[{group}]".format(group = group))
+            lines += sorted(entries)
+        entry_points_file = ctx.actions.declare_file(ctx.attr.name + "_entry_points.txt")
+        content = "\n".join(lines)
+        ctx.actions.write(output = entry_points_file, content = content)
+        other_inputs.append(entry_points_file)
+        args.add("--entry_points_file", entry_points_file)
 
     if ctx.attr.description_file:
         description_file = ctx.file.description_file
@@ -208,7 +230,14 @@
 _entrypoint_attrs = {
     "console_scripts": attr.string_dict(
         doc = """\
-console_script entry points, e.g. 'experimental.examples.wheel.main:main'.
+Deprecated console_script entry points, e.g. {'main': 'experimental.examples.wheel.main:main'}.
+
+Deprecated: prefer the `entry_points` attribute, which supports `console_scripts` as well as other entry points.
+""",
+    ),
+    "entry_points": attr.string_list_dict(
+        doc = """\
+entry_points, e.g. {'console_scripts': ['main = experimental.examples.wheel.main:main']}.
 """,
     ),
 }
@@ -281,7 +310,7 @@
 The targets to package are usually `py_library` rules or filesets (for packaging data files).
 
 Note it's usually better to package `py_library` targets and use
-`console_scripts` attribute to specify entry points than to package
+`entry_points` attribute to specify `console_scripts` than to package
 `py_binary` rules. `py_binary` targets would wrap a executable script that
 tries to locate `.runfiles` directory which is not packaged in the wheel.
 """,
diff --git a/experimental/rules_python/wheelmaker.py b/experimental/rules_python/wheelmaker.py
index 1b3261d..4f69b62 100644
--- a/experimental/rules_python/wheelmaker.py
+++ b/experimental/rules_python/wheelmaker.py
@@ -92,6 +92,7 @@
 
     def add_file(self, package_filename, real_filename):
         """Add given file to the distribution."""
+
         def arcname_from(name):
             # Always use unix path separators.
             normalized_arcname = name.replace(os.path.sep, '/')
@@ -157,15 +158,6 @@
         metadata += "\n"
         self.add_string(self.distinfo_path('METADATA'), metadata)
 
-    def add_entry_points(self, console_scripts):
-        """Write entry_points.txt file to the distribution."""
-        # https://packaging.python.org/specifications/entry-points/
-        if not console_scripts:
-            return
-        lines = ["[console_scripts]"] + console_scripts
-        contents = '\n'.join(lines)
-        self.add_string(self.distinfo_path('entry_points.txt'), contents)
-
     def add_recordfile(self):
         """Write RECORD file to the distribution."""
         record_path = self.distinfo_path('RECORD')
@@ -235,6 +227,8 @@
                                   "Can be supplied multiple times")
     wheel_group.add_argument('--description_file',
                              help="Path to the file with package description")
+    wheel_group.add_argument('--entry_points_file',
+                             help="Path to a correctly-formatted entry_points.txt file")
 
     contents_group = parser.add_argument_group("Wheel contents")
     contents_group.add_argument(
@@ -246,10 +240,6 @@
         '--input_file_list', action='append',
         help='A file that has all the input files defined as a list to avoid the long command'
     )
-    contents_group.add_argument(
-        '--console_script', action='append',
-        help="Defines a 'console_script' entry point. "
-             "Can be supplied multiple times.")
 
     requirements_group = parser.add_argument_group("Package requirements")
     requirements_group.add_argument(
@@ -314,14 +304,16 @@
         classifiers = arguments.classifier or []
         requires = arguments.requires or []
         extra_headers = arguments.header or []
-        console_scripts = arguments.console_script or []
 
         maker.add_metadata(extra_headers=extra_headers,
                            description=description,
                            classifiers=classifiers,
                            requires=requires,
                            extra_requires=extra_requires)
-        maker.add_entry_points(console_scripts=console_scripts)
+
+        if arguments.entry_points_file:
+            maker.add_file(maker.distinfo_path("entry_points.txt"), arguments.entry_points_file)
+
         maker.add_recordfile()