Add experimental support for building wheels. (#159)

Add experimental support for building wheels.

Currently only pure python wheels are supported.

This extension builds wheels directly, invoking a simple python script
that creates the zip archive in the desired format instead of using
distutils/setuptools.

This will make building platform-dependent wheels easier in the future,
as bazel will have full control on how extension code is built.
diff --git a/experimental/BUILD b/experimental/BUILD
new file mode 100644
index 0000000..a892ac1
--- /dev/null
+++ b/experimental/BUILD
@@ -0,0 +1,16 @@
+# Copyright 2018 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.
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache 2.0
diff --git a/experimental/examples/wheel/BUILD b/experimental/examples/wheel/BUILD
new file mode 100644
index 0000000..d7ed5da
--- /dev/null
+++ b/experimental/examples/wheel/BUILD
@@ -0,0 +1,96 @@
+# Copyright 2018 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.
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache 2.0
+
+load("//python:python.bzl", "py_library", "py_test")
+load("//experimental/python:wheel.bzl", "py_package", "py_wheel")
+
+py_library(
+    name = "main",
+    srcs = ["main.py"],
+    deps = [
+        "//experimental/examples/wheel/lib:simple_module",
+        "//experimental/examples/wheel/lib:module_with_data",
+        # Example dependency which is not packaged in the wheel
+        # due to "packages" filter on py_package rule.
+        "//examples/helloworld",
+    ],
+)
+
+# Package just a specific py_libraries, without their dependencies
+py_wheel(
+    name = "minimal_with_py_library",
+    # Package data. We're building "example_minimal_library-0.0.1-py3-none-any.whl"
+    distribution = "example_minimal_library",
+    python_tag = "py3",
+    version = "0.0.1",
+    deps = [
+        "//experimental/examples/wheel/lib:module_with_data",
+        "//experimental/examples/wheel/lib:simple_module",
+    ],
+)
+
+# Use py_package to collect all transitive dependencies of a target,
+# selecting just the files within a specific python package.
+py_package(
+    name = "example_pkg",
+    # Only include these Python packages.
+    packages = ["experimental.examples.wheel"],
+    deps = [":main"],
+)
+
+py_wheel(
+    name = "minimal_with_py_package",
+    # Package data. We're building "example_minimal_package-0.0.1-py3-none-any.whl"
+    distribution = "example_minimal_package",
+    python_tag = "py3",
+    version = "0.0.1",
+    deps = [":example_pkg"],
+)
+
+# An example that uses all features provided by py_wheel.
+py_wheel(
+    name = "customized",
+    author = "Example Author with non-ascii characters: żółw",
+    author_email = "example@example.com",
+    classifiers = [
+        "License :: OSI Approved :: Apache Software License",
+        "Intended Audience :: Developers",
+    ],
+    console_scripts = {
+        "customized_wheel": "experimental.examples.wheel.main:main",
+    },
+    description_file = "README.md",
+    # Package data. We're building "example_customized-0.0.1-py3-none-any.whl"
+    distribution = "example_customized",
+    homepage = "www.example.com",
+    license = "Apache 2.0",
+    python_tag = "py3",
+    # Requirements embedded into the wheel metadata.
+    requires = ["pytest"],
+    version = "0.0.1",
+    deps = [":example_pkg"],
+)
+
+py_test(
+    name = "wheel_test",
+    srcs = ["wheel_test.py"],
+    data = [
+        ":customized",
+        ":minimal_with_py_library",
+        ":minimal_with_py_package",
+    ],
+)
diff --git a/experimental/examples/wheel/README.md b/experimental/examples/wheel/README.md
new file mode 100644
index 0000000..1426ff4
--- /dev/null
+++ b/experimental/examples/wheel/README.md
@@ -0,0 +1 @@
+This is a sample description of a wheel.
\ No newline at end of file
diff --git a/experimental/examples/wheel/lib/BUILD b/experimental/examples/wheel/lib/BUILD
new file mode 100644
index 0000000..6b01b1b
--- /dev/null
+++ b/experimental/examples/wheel/lib/BUILD
@@ -0,0 +1,35 @@
+# Copyright 2018 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.
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache 2.0
+
+load("//python:python.bzl", "py_library")
+
+py_library(
+    name = "simple_module",
+    srcs = ["simple_module.py"],
+)
+
+py_library(
+    name = "module_with_data",
+    srcs = ["module_with_data.py"],
+    data = [":data.txt"],
+)
+
+genrule(
+    name = "make_data",
+    outs = ["data.txt"],
+    cmd = "echo foo bar baz > $@",
+)
diff --git a/experimental/examples/wheel/lib/module_with_data.py b/experimental/examples/wheel/lib/module_with_data.py
new file mode 100644
index 0000000..7b28643
--- /dev/null
+++ b/experimental/examples/wheel/lib/module_with_data.py
@@ -0,0 +1,16 @@
+# Copyright 2018 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.
+
+def function():
+    return "foo"
diff --git a/experimental/examples/wheel/lib/simple_module.py b/experimental/examples/wheel/lib/simple_module.py
new file mode 100644
index 0000000..fb26a51
--- /dev/null
+++ b/experimental/examples/wheel/lib/simple_module.py
@@ -0,0 +1,16 @@
+# Copyright 2018 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.
+
+def function():
+    return "bar"
diff --git a/experimental/examples/wheel/main.py b/experimental/examples/wheel/main.py
new file mode 100644
index 0000000..db16826
--- /dev/null
+++ b/experimental/examples/wheel/main.py
@@ -0,0 +1,30 @@
+# Copyright 2018 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.
+
+import experimental.examples.wheel.lib.module_with_data as module_with_data
+import experimental.examples.wheel.lib.simple_module as simple_module
+
+
+def function():
+    return "baz"
+
+
+def main():
+    print(function())
+    print(module_with_data.function())
+    print(simple_module.function())
+
+
+if __name__ == '__main__':
+    main()
diff --git a/experimental/examples/wheel/wheel_test.py b/experimental/examples/wheel/wheel_test.py
new file mode 100644
index 0000000..278f007
--- /dev/null
+++ b/experimental/examples/wheel/wheel_test.py
@@ -0,0 +1,107 @@
+# Copyright 2018 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.
+
+import os
+import unittest
+import zipfile
+
+
+class WheelTest(unittest.TestCase):
+    def test_py_library_wheel(self):
+        filename = os.path.join(os.environ['TEST_SRCDIR'],
+                                'io_bazel_rules_python', 'experimental',
+                                'examples', 'wheel',
+                                'example_minimal_library-0.0.1-py3-none-any.whl')
+        with zipfile.ZipFile(filename) as zf:
+            self.assertEquals(
+                zf.namelist(),
+                ['experimental/examples/wheel/lib/module_with_data.py',
+                 'experimental/examples/wheel/lib/simple_module.py',
+                 'example_minimal_library-0.0.1.dist-info/WHEEL',
+                 'example_minimal_library-0.0.1.dist-info/METADATA',
+                 'example_minimal_library-0.0.1.dist-info/RECORD'])
+
+    def test_py_package_wheel(self):
+        filename = os.path.join(os.environ['TEST_SRCDIR'],
+                                'io_bazel_rules_python', 'experimental',
+                                'examples', 'wheel',
+                                'example_minimal_package-0.0.1-py3-none-any.whl')
+        with zipfile.ZipFile(filename) as zf:
+            self.assertEquals(
+                zf.namelist(),
+                ['experimental/examples/wheel/lib/data.txt',
+                 'experimental/examples/wheel/lib/module_with_data.py',
+                 'experimental/examples/wheel/lib/simple_module.py',
+                 'experimental/examples/wheel/main.py',
+                 'example_minimal_package-0.0.1.dist-info/WHEEL',
+                 'example_minimal_package-0.0.1.dist-info/METADATA',
+                 'example_minimal_package-0.0.1.dist-info/RECORD'])
+
+    def test_customized_wheel(self):
+        filename = os.path.join(os.environ['TEST_SRCDIR'],
+                                'io_bazel_rules_python', 'experimental',
+                                'examples', 'wheel',
+                                'example_customized-0.0.1-py3-none-any.whl')
+        with zipfile.ZipFile(filename) as zf:
+            self.assertEquals(
+                zf.namelist(),
+                ['experimental/examples/wheel/lib/data.txt',
+                 'experimental/examples/wheel/lib/module_with_data.py',
+                 'experimental/examples/wheel/lib/simple_module.py',
+                 'experimental/examples/wheel/main.py',
+                 'example_customized-0.0.1.dist-info/WHEEL',
+                 'example_customized-0.0.1.dist-info/METADATA',
+                 'example_customized-0.0.1.dist-info/entry_points.txt',
+                 'example_customized-0.0.1.dist-info/RECORD'])
+            record_contents = zf.read(
+                'example_customized-0.0.1.dist-info/RECORD')
+            wheel_contents = zf.read(
+                'example_customized-0.0.1.dist-info/WHEEL')
+            metadata_contents = zf.read(
+                'example_customized-0.0.1.dist-info/METADATA')
+            # 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
+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
+experimental/examples/wheel/main.py,sha256=E0xCyiPg6fCo4IrFmqo_tqpNGtk1iCewobqD0_KlFd0,935
+""")
+            self.assertEquals(wheel_contents, b"""\
+Wheel-Version: 1.0
+Generator: wheelmaker 1.0
+Root-Is-Purelib: true
+Tag: py3-none-any
+""")
+            self.assertEquals(metadata_contents, b"""\
+Metadata-Version: 2.1
+Name: example_customized
+Version: 0.0.1
+Author: Example Author with non-ascii characters: \xc5\xbc\xc3\xb3\xc5\x82w
+Author-email: example@example.com
+Home-page: www.example.com
+License: Apache 2.0
+Classifier: License :: OSI Approved :: Apache Software License
+Classifier: Intended Audience :: Developers
+Requires-Dist: pytest
+
+This is a sample description of a wheel.
+""")
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/experimental/python/BUILD b/experimental/python/BUILD
new file mode 100644
index 0000000..8e8a059
--- /dev/null
+++ b/experimental/python/BUILD
@@ -0,0 +1,18 @@
+# Copyright 2018 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.
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache 2.0
+
+exports_files(["wheel.bzl"])
diff --git a/experimental/python/wheel.bzl b/experimental/python/wheel.bzl
new file mode 100644
index 0000000..7603d93
--- /dev/null
+++ b/experimental/python/wheel.bzl
@@ -0,0 +1,273 @@
+# Copyright 2018 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.
+
+"""Rules for building wheels."""
+
+def _path_inside_wheel(input_file):
+    # input_file.short_path is relative ("../${repository_root}/foobar")
+    # so it can't be a valid path within a zip file. Thus strip out the root
+    # manually instead of using short_path here.
+    root = input_file.root.path
+    if root != "":
+        # TODO: '/' is wrong on windows, but the path separator is not available in skylark.
+        # Fix this once ctx.configuration has directory separator information.
+        root += "/"
+    if not input_file.path.startswith(root):
+        fail("input_file.path '%s' does not start with expected root '%s'" % (input_file.path, root))
+    return input_file.path[len(root):]
+
+def _py_package_impl(ctx):
+    inputs = depset(
+        transitive = [dep[DefaultInfo].data_runfiles.files for dep in ctx.attr.deps] +
+                     [dep[DefaultInfo].default_runfiles.files for dep in ctx.attr.deps],
+    )
+
+    # TODO: '/' is wrong on windows, but the path separator is not available in skylark.
+    # Fix this once ctx.configuration has directory separator information.
+    packages = [p.replace(".", "/") for p in ctx.attr.packages]
+    if not packages:
+        filtered_inputs = inputs
+    else:
+        filtered_files = []
+
+        # TODO: flattening depset to list gives poor performance,
+        for input_file in inputs.to_list():
+            wheel_path = _path_inside_wheel(input_file)
+            for package in packages:
+                if wheel_path.startswith(package):
+                    filtered_files.append(input_file)
+        filtered_inputs = depset(direct = filtered_files)
+
+    return [DefaultInfo(
+        files = filtered_inputs,
+    )]
+
+py_package = rule(
+    implementation = _py_package_impl,
+    doc = """
+A rule to select all files in transitive dependencies of deps which
+belong to given set of Python packages.
+
+This rule is intended to be used as data dependency to py_wheel rule
+""",
+    attrs = {
+        "deps": attr.label_list(),
+        "packages": attr.string_list(
+            mandatory = False,
+            allow_empty = True,
+            doc = """\
+List of Python packages to include in the distribution.
+Sub-packages are automatically included.
+""",
+        ),
+    },
+)
+
+def _py_wheel_impl(ctx):
+    outfile = ctx.actions.declare_file("-".join([
+        ctx.attr.distribution,
+        ctx.attr.version,
+        ctx.attr.python_tag,
+        ctx.attr.abi,
+        ctx.attr.platform,
+    ]) + ".whl")
+
+    inputs = depset(
+        direct = ctx.files.deps,
+    )
+
+    arguments = [
+        "--name",
+        ctx.attr.distribution,
+        "--version",
+        ctx.attr.version,
+        "--python_tag",
+        ctx.attr.python_tag,
+        "--abi",
+        ctx.attr.abi,
+        "--platform",
+        ctx.attr.platform,
+        "--out",
+        outfile.path,
+    ]
+
+    # TODO: Use args api instead of flattening the depset.
+    for input_file in inputs.to_list():
+        arguments.append("--input_file")
+        arguments.append("%s;%s" % (_path_inside_wheel(input_file), input_file.path))
+
+    extra_headers = []
+    if ctx.attr.author:
+        extra_headers.append("Author: %s" % ctx.attr.author)
+    if ctx.attr.author_email:
+        extra_headers.append("Author-email: %s" % ctx.attr.author_email)
+    if ctx.attr.homepage:
+        extra_headers.append("Home-page: %s" % ctx.attr.homepage)
+    if ctx.attr.license:
+        extra_headers.append("License: %s" % ctx.attr.license)
+
+    for h in extra_headers:
+        arguments.append("--header")
+        arguments.append(h)
+
+    for c in ctx.attr.classifiers:
+        arguments.append("--classifier")
+        arguments.append(c)
+
+    for r in ctx.attr.requires:
+        arguments.append("--requires")
+        arguments.append(r)
+
+    for option, requirements in ctx.attr.extra_requires.items():
+        for r in requirements:
+            arguments.append("--extra_requires")
+            arguments.append(r + ";" + option)
+
+    for name, ref in ctx.attr.console_scripts.items():
+        arguments.append("--console_script")
+        arguments.append(name + " = " + ref)
+
+    if ctx.attr.description_file:
+        description_file = ctx.file.description_file
+        arguments.append("--description_file")
+        arguments.append(description_file.path)
+        inputs = inputs.union([description_file])
+
+    ctx.actions.run(
+        inputs = inputs,
+        outputs = [outfile],
+        arguments = arguments,
+        executable = ctx.executable._wheelmaker,
+        progress_message = "Building wheel",
+    )
+    return [DefaultInfo(
+        files = depset([outfile]),
+        data_runfiles = ctx.runfiles(files = [outfile]),
+    )]
+
+py_wheel = rule(
+    implementation = _py_wheel_impl,
+    doc = """
+A rule for building Python Wheels.
+
+Wheels are Python distribution format defined in https://www.python.org/dev/peps/pep-0427/.
+
+This rule packages a set of targets into a single wheel.
+
+Currently only pure-python wheels are supported.
+
+Examples:
+
+<code>
+# Package just a specific py_libraries, without their dependencies
+py_wheel(
+    name = "minimal_with_py_library",
+    # Package data. We're building "example_minimal_library-0.0.1-py3-none-any.whl"
+    distribution = "example_minimal_library",
+    python_tag = "py3",
+    version = "0.0.1",
+    deps = [
+        "//experimental/examples/wheel/lib:module_with_data",
+        "//experimental/examples/wheel/lib:simple_module",
+    ],
+)
+
+# Use py_package to collect all transitive dependencies of a target,
+# selecting just the files within a specific python package.
+py_package(
+    name = "example_pkg",
+    # Only include these Python packages.
+    packages = ["experimental.examples.wheel"],
+    deps = [":main"],
+)
+
+py_wheel(
+    name = "minimal_with_py_package",
+    # Package data. We're building "example_minimal_package-0.0.1-py3-none-any.whl"
+    distribution = "example_minimal_package",
+    python_tag = "py3",
+    version = "0.0.1",
+    deps = [":example_pkg"],
+)
+</code>
+""",
+    attrs = {
+        "deps": attr.label_list(
+            doc = """\
+Targets to be included in the distribution.
+
+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
+`py_binary` rules. `py_binary` targets would wrap a executable script that
+tries to locate `.runfiles` directory which is not packaged in the wheel.
+""",
+        ),
+        # Attributes defining the distribution
+        "distribution": attr.string(
+            mandatory = True,
+            doc = """
+Name of the distribution.
+
+This should match the project name onm PyPI. It's also the name that is used
+to refer to the package in other packages' dependencies.
+""",
+        ),
+        "version": attr.string(
+            mandatory = True,
+            doc = "Version number of the package",
+        ),
+        "python_tag": attr.string(
+            default = "py3",
+            doc = "Supported Python major version. 'py2' or 'py3'",
+            values = ["py2", "py3"],
+        ),
+        "abi": attr.string(
+            default = "none",
+            doc = "Python ABI tag. 'none' for pure-Python wheels.",
+        ),
+        # TODO(pstradomski): Support non-pure wheels
+        "platform": attr.string(
+            default = "any",
+            doc = "Supported platforms. 'any' for pure-Python wheel.",
+        ),
+        # Other attributes
+        "author": attr.string(default = ""),
+        "author_email": attr.string(default = ""),
+        "homepage": attr.string(default = ""),
+        "license": attr.string(default = ""),
+        "classifiers": attr.string_list(),
+        "description_file": attr.label(allow_single_file = True),
+        # Requirements
+        "requires": attr.string_list(
+            doc = "List of requirements for this package",
+        ),
+        "extra_requires": attr.string_list_dict(
+            doc = "List of optional requirements for this package",
+        ),
+        # Entry points
+        "console_scripts": attr.string_dict(
+            doc = """\
+console_script entry points, e.g. 'experimental.examples.wheel.main:main'.
+""",
+        ),
+        # Implementation details.
+        "_wheelmaker": attr.label(
+            executable = True,
+            cfg = "host",
+            default = "//experimental/rules_python:wheelmaker",
+        ),
+    },
+)
diff --git a/experimental/rules_python/BUILD b/experimental/rules_python/BUILD
new file mode 100644
index 0000000..5f770a1
--- /dev/null
+++ b/experimental/rules_python/BUILD
@@ -0,0 +1,21 @@
+# Copyright 2018 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.
+
+load("//python:python.bzl", "py_binary")
+
+py_binary(
+    name = "wheelmaker",
+    srcs = ["wheelmaker.py"],
+    visibility = ["//visibility:public"],
+)
diff --git a/experimental/rules_python/wheelmaker.py b/experimental/rules_python/wheelmaker.py
new file mode 100644
index 0000000..7a50ae4
--- /dev/null
+++ b/experimental/rules_python/wheelmaker.py
@@ -0,0 +1,293 @@
+# Copyright 2018 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.
+
+import argparse
+import base64
+import collections
+import csv
+import hashlib
+import io
+import os
+import os.path
+import sys
+import zipfile
+
+
+def commonpath(path1, path2):
+    ret = []
+    for a, b in zip(path1.split(os.path.sep), path2.split(os.path.sep)):
+        if a != b:
+            break
+        ret.append(a)
+    return os.path.sep.join(ret)
+
+
+class WheelMaker(object):
+    def __init__(self, name, version, build_tag, python_tag, abi, platform,
+                 outfile=None):
+        self._name = name
+        self._version = version
+        self._build_tag = build_tag
+        self._python_tag = python_tag
+        self._abi = abi
+        self._platform = platform
+        self._outfile = outfile
+
+        self._zipfile = None
+        self._record = []
+
+    def __enter__(self):
+        self._zipfile = zipfile.ZipFile(self.filename(), mode="w",
+                                        compression=zipfile.ZIP_DEFLATED)
+        return self
+
+    def __exit__(self, type, value, traceback):
+        self._zipfile.close()
+        self._zipfile = None
+
+    def filename(self):
+        if self._outfile:
+            return self._outfile
+        components = [self._name, self._version]
+        if self._build_tag:
+            components.append(self._build_tag)
+        components += [self._python_tag, self._abi, self._platform]
+        return '-'.join(components) + '.whl'
+
+    def distname(self):
+        return self._name + '-' + self._version
+
+    def disttags(self):
+        return ['-'.join([self._python_tag, self._abi, self._platform])]
+
+    def distinfo_path(self, basename):
+        return self.distname() + '.dist-info/' + basename
+
+    def _serialize_digest(self, hash):
+        # https://www.python.org/dev/peps/pep-0376/#record
+        # "base64.urlsafe_b64encode(digest) with trailing = removed"
+        digest = base64.urlsafe_b64encode(hash.digest())
+        digest = b'sha256=' + digest.rstrip(b'=')
+        return digest
+
+    def add_string(self, filename, contents):
+        """Add given 'contents' as filename to the distribution."""
+        if sys.version_info[0] > 2 and isinstance(contents, str):
+            contents = contents.encode('utf-8', 'surrogateescape')
+        self._zipfile.writestr(filename, contents)
+        hash = hashlib.sha256()
+        hash.update(contents)
+        self._add_to_record(filename, self._serialize_digest(hash),
+                            len(contents))
+
+    def add_file(self, package_filename, real_filename):
+        """Add given file to the distribution."""
+        # Always use unix path separators.
+        arcname = package_filename.replace(os.path.sep, '/')
+        self._zipfile.write(real_filename, arcname=arcname)
+        # Find the hash and length
+        hash = hashlib.sha256()
+        size = 0
+        with open(real_filename, 'rb') as f:
+            while True:
+                block = f.read(2 ** 20)
+                if not block:
+                    break
+                hash.update(block)
+                size += len(block)
+        self._add_to_record(arcname, self._serialize_digest(hash), size)
+
+    def add_wheelfile(self):
+        """Write WHEEL file to the distribution"""
+        # TODO(pstradomski): Support non-purelib wheels.
+        wheel_contents = """\
+Wheel-Version: 1.0
+Generator: wheelmaker 1.0
+Root-Is-Purelib: true
+"""
+        for tag in self.disttags():
+            wheel_contents += "Tag: %s\n" % tag
+        self.add_string(self.distinfo_path('WHEEL'), wheel_contents)
+
+    def add_metadata(self, extra_headers, description, classifiers, requires,
+                     extra_requires):
+        """Write METADATA file to the distribution."""
+        # https://www.python.org/dev/peps/pep-0566/
+        # https://packaging.python.org/specifications/core-metadata/
+        metadata = []
+        metadata.append("Metadata-Version: 2.1")
+        metadata.append("Name: %s" % self._name)
+        metadata.append("Version: %s" % self._version)
+        metadata.extend(extra_headers)
+        for classifier in classifiers:
+            metadata.append("Classifier: %s" % classifier)
+        for requirement in requires:
+            metadata.append("Requires-Dist: %s" % requirement)
+
+        extra_requires = sorted(extra_requires.items())
+        for option, option_requires in extra_requires:
+            metadata.append("Provides-Extra: %s" % option)
+            for requirement in option_requires:
+                metadata.append(
+                    "Requires-Dist: %s; extra == '%s'" % (requirement, option))
+
+        metadata = '\n'.join(metadata) + '\n\n'
+        # setuptools seems to insert UNKNOWN as description when none is
+        # provided.
+        metadata += description if description else "UNKNOWN"
+        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')
+        entries = self._record + [(record_path, b'', b'')]
+        entries.sort()
+        contents = b''
+        for filename, digest, size in entries:
+            if sys.version_info[0] > 2 and isinstance(filename, str):
+                filename = filename.encode('utf-8', 'surrogateescape')
+            contents += b'%s,%s,%s\n' % (filename, digest, size)
+        self.add_string(record_path, contents)
+
+    def _add_to_record(self, filename, hash, size):
+        size = str(size).encode('ascii')
+        self._record.append((filename, hash, size))
+
+
+def get_files_to_package(input_files):
+    """Find files to be added to the distribution.
+
+    input_files: list of pairs (package_path, real_path)
+    """
+    files = {}
+    for package_path, real_path in input_files:
+        files[package_path] = real_path
+    return files
+
+
+def main():
+    parser = argparse.ArgumentParser(description='Builds a python wheel')
+    metadata_group = parser.add_argument_group(
+        "Wheel name, version and platform")
+    metadata_group.add_argument('--name', required=True,
+                                type=str,
+                                help="Name of the distribution")
+    metadata_group.add_argument('--version', required=True,
+                                type=str,
+                                help="Version of the distribution")
+    metadata_group.add_argument('--build_tag', type=str, default='',
+                                help="Optional build tag for the distribution")
+    metadata_group.add_argument('--python_tag', type=str, default='py3',
+                                help="Python version, e.g. 'py2' or 'py3'")
+    metadata_group.add_argument('--abi', type=str, default='none')
+    metadata_group.add_argument('--platform', type=str, default='any',
+                                help="Target platform. ")
+
+    output_group = parser.add_argument_group("Output file location")
+    output_group.add_argument('--out', type=str, default=None,
+                              help="Override name of ouptut file")
+
+    wheel_group = parser.add_argument_group("Wheel metadata")
+    wheel_group.add_argument(
+        '--header', action='append',
+        help="Additional headers to be embedded in the package metadata. "
+             "Can be supplied multiple times.")
+    wheel_group.add_argument('--classifier', action='append',
+                             help="Classifiers to embed in package metadata. "
+                                  "Can be supplied multiple times")
+    wheel_group.add_argument('--description_file',
+                             help="Path to the file with package description")
+
+    contents_group = parser.add_argument_group("Wheel contents")
+    contents_group.add_argument(
+        '--input_file', action='append',
+        help="'package_path;real_path' pairs listing "
+             "files to be included in the wheel. "
+             "Can be supplied multiple times.")
+    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(
+        '--requires', type=str, action='append',
+        help="List of package requirements. Can be supplied multiple times.")
+    requirements_group.add_argument(
+        '--extra_requires', type=str, action='append',
+        help="List of optional requirements in a 'requirement;option name'. "
+             "Can be supplied multiple times.")
+    arguments = parser.parse_args(sys.argv[1:])
+
+    # add_wheelfile and add_metadata currently assume pure-Python.
+    assert arguments.platform == 'any', "Only pure-Python wheels are supported"
+
+    input_files = [i.split(';') for i in arguments.input_file]
+    all_files = get_files_to_package(input_files)
+    # Sort the files for reproducible order in the archive.
+    all_files = sorted(all_files.items())
+
+    with WheelMaker(name=arguments.name,
+                    version=arguments.version,
+                    build_tag=arguments.build_tag,
+                    python_tag=arguments.python_tag,
+                    abi=arguments.abi,
+                    platform=arguments.platform,
+                    outfile=arguments.out) as maker:
+        for package_filename, real_filename in all_files:
+            maker.add_file(package_filename, real_filename)
+        maker.add_wheelfile()
+
+        description = None
+        if arguments.description_file:
+            if sys.version_info[0] == 2:
+                with open(arguments.description_file,
+                          'rt') as description_file:
+                    description = description_file.read()
+            else:
+                with open(arguments.description_file, 'rt',
+                          encoding='utf-8') as description_file:
+                    description = description_file.read()
+
+        extra_requires = collections.defaultdict(list)
+        if arguments.extra_requires:
+            for extra in arguments.extra_requires:
+                req, option = extra.rsplit(';', 1)
+                extra_requires[option].append(req)
+        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)
+        maker.add_recordfile()
+
+
+if __name__ == '__main__':
+    main()