feat(py_wheel): Add support for specifying Project-URL in METADATA (#1276)

`Project-URL` is a field available in core metadata since version 1.2,
which allows specifying additional URLs and display as Project Links in
PyPI package web page.


https://packaging.python.org/en/latest/specifications/core-metadata/#project-url-multiple-use

This change adds the support to specify that.
diff --git a/docs/packaging.md b/docs/packaging.md
index 091e1a0..0e8e110 100755
--- a/docs/packaging.md
+++ b/docs/packaging.md
@@ -59,8 +59,8 @@
 <pre>
 py_wheel_rule(<a href="#py_wheel_rule-name">name</a>, <a href="#py_wheel_rule-abi">abi</a>, <a href="#py_wheel_rule-author">author</a>, <a href="#py_wheel_rule-author_email">author_email</a>, <a href="#py_wheel_rule-classifiers">classifiers</a>, <a href="#py_wheel_rule-console_scripts">console_scripts</a>, <a href="#py_wheel_rule-deps">deps</a>,
               <a href="#py_wheel_rule-description_content_type">description_content_type</a>, <a href="#py_wheel_rule-description_file">description_file</a>, <a href="#py_wheel_rule-distribution">distribution</a>, <a href="#py_wheel_rule-entry_points">entry_points</a>,
-              <a href="#py_wheel_rule-extra_distinfo_files">extra_distinfo_files</a>, <a href="#py_wheel_rule-extra_requires">extra_requires</a>, <a href="#py_wheel_rule-homepage">homepage</a>, <a href="#py_wheel_rule-license">license</a>, <a href="#py_wheel_rule-platform">platform</a>, <a href="#py_wheel_rule-python_requires">python_requires</a>,
-              <a href="#py_wheel_rule-python_tag">python_tag</a>, <a href="#py_wheel_rule-requires">requires</a>, <a href="#py_wheel_rule-stamp">stamp</a>, <a href="#py_wheel_rule-strip_path_prefixes">strip_path_prefixes</a>, <a href="#py_wheel_rule-summary">summary</a>, <a href="#py_wheel_rule-version">version</a>)
+              <a href="#py_wheel_rule-extra_distinfo_files">extra_distinfo_files</a>, <a href="#py_wheel_rule-extra_requires">extra_requires</a>, <a href="#py_wheel_rule-homepage">homepage</a>, <a href="#py_wheel_rule-license">license</a>, <a href="#py_wheel_rule-platform">platform</a>, <a href="#py_wheel_rule-project_urls">project_urls</a>,
+              <a href="#py_wheel_rule-python_requires">python_requires</a>, <a href="#py_wheel_rule-python_tag">python_tag</a>, <a href="#py_wheel_rule-requires">requires</a>, <a href="#py_wheel_rule-stamp">stamp</a>, <a href="#py_wheel_rule-strip_path_prefixes">strip_path_prefixes</a>, <a href="#py_wheel_rule-summary">summary</a>, <a href="#py_wheel_rule-version">version</a>)
 </pre>
 
 Internal rule used by the [py_wheel macro](/docs/packaging.md#py_wheel).
@@ -91,6 +91,7 @@
 | <a id="py_wheel_rule-homepage"></a>homepage |  A string specifying the URL for the package homepage.   | String | optional | <code>""</code> |
 | <a id="py_wheel_rule-license"></a>license |  A string specifying the license of the package.   | String | optional | <code>""</code> |
 | <a id="py_wheel_rule-platform"></a>platform |  Supported platform. Use 'any' for pure-Python wheel.<br><br>If you have included platform-specific data, such as a .pyd or .so extension module, you will need to specify the platform in standard pip format. If you support multiple platforms, you can define platform constraints, then use a select() to specify the appropriate specifier, eg:<br><br><code> platform = select({     "//platforms:windows_x86_64": "win_amd64",     "//platforms:macos_x86_64": "macosx_10_7_x86_64",     "//platforms:linux_x86_64": "manylinux2014_x86_64", }) </code>   | String | optional | <code>"any"</code> |
+| <a id="py_wheel_rule-project_urls"></a>project_urls |  A string dict specifying additional browsable URLs for the project and corresponding labels, where label is the key and url is the value. e.g <code>{{"Bug Tracker": "http://bitbucket.org/tarek/distribute/issues/"}}</code>   | <a href="https://bazel.build/rules/lib/dict">Dictionary: String -> String</a> | optional | <code>{}</code> |
 | <a id="py_wheel_rule-python_requires"></a>python_requires |  Python versions required by this distribution, e.g. '&gt;=3.5,&lt;3.7'   | String | optional | <code>""</code> |
 | <a id="py_wheel_rule-python_tag"></a>python_tag |  Supported Python version(s), eg <code>py3</code>, <code>cp35.cp36</code>, etc   | String | optional | <code>"py3"</code> |
 | <a id="py_wheel_rule-requires"></a>requires |  List of requirements for this package. See the section on [Declaring required dependency](https://setuptools.readthedocs.io/en/latest/userguide/dependency_management.html#declaring-dependencies) for details and examples of the format of this argument.   | List of strings | optional | <code>[]</code> |
diff --git a/examples/wheel/BUILD.bazel b/examples/wheel/BUILD.bazel
index 72cc3d4..f56a41b 100644
--- a/examples/wheel/BUILD.bazel
+++ b/examples/wheel/BUILD.bazel
@@ -157,6 +157,10 @@
     },
     homepage = "www.example.com",
     license = "Apache 2.0",
+    project_urls = {
+        "Bug Tracker": "www.example.com/issues",
+        "Documentation": "www.example.com/docs",
+    },
     python_tag = "py3",
     # Requirements embedded into the wheel metadata.
     requires = ["pytest"],
diff --git a/examples/wheel/wheel_test.py b/examples/wheel/wheel_test.py
index 6869e77..f51a0ec 100644
--- a/examples/wheel/wheel_test.py
+++ b/examples/wheel/wheel_test.py
@@ -99,7 +99,7 @@
                 record_contents,
                 # The entries are guaranteed to be sorted.
                 b"""\
-example_customized-0.0.1.dist-info/METADATA,sha256=vRiyyV45PC5fzK_40nSTtIn3yYzDdsbBAbUvkZiRyc8,461
+example_customized-0.0.1.dist-info/METADATA,sha256=QYQcDJFQSIqan8eiXqL67bqsUfgEAwf2hoK_Lgi1S-0,559
 example_customized-0.0.1.dist-info/NOTICE,sha256=Xpdw-FXET1IRgZ_wTkx1YQfo1-alET0FVf6V1LXO4js,76
 example_customized-0.0.1.dist-info/README,sha256=WmOFwZ3Jga1bHG3JiGRsUheb4UbLffUxyTdHczS27-o,40
 example_customized-0.0.1.dist-info/RECORD,,
@@ -131,6 +131,8 @@
 License: Apache 2.0
 Description-Content-Type: text/markdown
 Summary: A one-line summary of this test package
+Project-URL: Bug Tracker, www.example.com/issues
+Project-URL: Documentation, www.example.com/docs
 Classifier: License :: OSI Approved :: Apache Software License
 Classifier: Intended Audience :: Developers
 Requires-Dist: pytest
diff --git a/python/private/py_wheel.bzl b/python/private/py_wheel.bzl
index edafa3e..d8bceab 100644
--- a/python/private/py_wheel.bzl
+++ b/python/private/py_wheel.bzl
@@ -176,6 +176,11 @@
         doc = "A string specifying the license of the package.",
         default = "",
     ),
+    "project_urls": attr.string_dict(
+        doc = ("A string dict specifying additional browsable URLs for the project and corresponding labels, " +
+               "where label is the key and url is the value. " +
+               'e.g `{{"Bug Tracker": "http://bitbucket.org/tarek/distribute/issues/"}}`'),
+    ),
     "python_requires": attr.string(
         doc = (
             "Python versions required by this distribution, e.g. '>=3.5,<3.7'"
@@ -191,6 +196,7 @@
     ),
 }
 
+_PROJECT_URL_LABEL_LENGTH_LIMIT = 32
 _DESCRIPTION_FILE_EXTENSION_TO_TYPE = {
     "md": "text/markdown",
     "rst": "text/x-rst",
@@ -301,6 +307,11 @@
     if ctx.attr.summary:
         metadata_contents.append("Summary: %s" % ctx.attr.summary)
 
+    for label, url in sorted(ctx.attr.project_urls.items()):
+        if len(label) > _PROJECT_URL_LABEL_LENGTH_LIMIT:
+            fail("`label` {} in `project_urls` is too long. It is limited to {} characters.".format(len(label), _PROJECT_URL_LABEL_LENGTH_LIMIT))
+        metadata_contents.append("Project-URL: %s, %s" % (label, url))
+
     for c in ctx.attr.classifiers:
         metadata_contents.append("Classifier: %s" % c)
 
diff --git a/tools/build_defs/python/tests/py_wheel/BUILD.bazel b/tools/build_defs/python/tests/py_wheel/BUILD.bazel
new file mode 100644
index 0000000..d925bb9
--- /dev/null
+++ b/tools/build_defs/python/tests/py_wheel/BUILD.bazel
@@ -0,0 +1,18 @@
+# Copyright 2023 The Bazel Authors. 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.
+"""Tests for py_wheel."""
+
+load(":py_wheel_tests.bzl", "py_wheel_test_suite")
+
+py_wheel_test_suite(name = "py_wheel_tests")
diff --git a/tools/build_defs/python/tests/py_wheel/py_wheel_tests.bzl b/tools/build_defs/python/tests/py_wheel/py_wheel_tests.bzl
new file mode 100644
index 0000000..4408592
--- /dev/null
+++ b/tools/build_defs/python/tests/py_wheel/py_wheel_tests.bzl
@@ -0,0 +1,39 @@
+"""Test for py_wheel."""
+
+load("@rules_testing//lib:analysis_test.bzl", "analysis_test")
+load("@rules_testing//lib:truth.bzl", "matching")
+load("@rules_testing//lib:util.bzl", rt_util = "util")
+load("//python:packaging.bzl", "py_wheel")
+load("//tools/build_defs/python/tests:util.bzl", pt_util = "util")
+
+_tests = []
+
+def _test_too_long_project_url_label(name, config):
+    rt_util.helper_target(
+        config.rule,
+        name = name + "_wheel",
+        distribution = name + "_wheel",
+        python_tag = "py3",
+        version = "0.0.1",
+        project_urls = {"This is a label whose length is above the limit!": "www.example.com"},
+    )
+    analysis_test(
+        name = name,
+        target = name + "_wheel",
+        impl = _test_too_long_project_url_label_impl,
+        expect_failure = True,
+    )
+
+def _test_too_long_project_url_label_impl(env, target):
+    env.expect.that_target(target).failures().contains_predicate(
+        matching.str_matches("in `project_urls` is too long"),
+    )
+
+_tests.append(_test_too_long_project_url_label)
+
+def py_wheel_test_suite(name):
+    config = struct(rule = py_wheel, base_test_rule = py_wheel)
+    native.test_suite(
+        name = name,
+        tests = pt_util.create_tests(_tests, config = config),
+    )