Skip to content

Commit ac673a5

Browse files
committed
This adds support for "extras".
"Extras" are additional dependencies of a given library, which are consumed by passing the "extra" name in brackets after the distribution name, for example: ``` mock[docs]==1.0.1 ``` We see this in the dependencies of several Google Cloud libraries, which depend on: `googleapis_common_protos[grpc]` I've added a simple test that the dependency structure we synthesize for this kind of thing is correct via an "extras" test that has a `requirements.txt` of: ``` google-cloud-language==0.27.0 ``` Fixes: #12
1 parent 8f3daa1 commit ac673a5

File tree

12 files changed

+280
-80
lines changed

12 files changed

+280
-80
lines changed

README.md

+4
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ for dependencies, however, it is recommended that folks stick with the
106106
`requirement` pattern in case the need arises for us to make changes to this
107107
format in the future.
108108

109+
["Extras"](
110+
https://packaging.python.org/tutorials/installing-packages/#installing-setuptools-extras)
111+
will have a target of the extra name (in place of `pkg` above).
112+
109113
## Updating `docs/`
110114

111115
All of the content (except `BUILD`) under `docs/` is generated. To update the

WORKSPACE

+22-1
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ sass_repositories()
2626

2727
git_repository(
2828
name = "io_bazel_skydoc",
29-
remote = "https://github.com/bazelbuild/skydoc.git",
3029
commit = "e9be81cf5be41e4200749f5d8aa2db7955f8aacc",
30+
remote = "https://github.com/bazelbuild/skydoc.git",
3131
)
3232

3333
load("@io_bazel_skydoc//skylark:skylark.bzl", "skydoc_repositories")
@@ -83,6 +83,15 @@ http_file(
8383
"mock-2.0.0-py2.py3-none-any.whl"),
8484
)
8585

86+
http_file(
87+
name = "google_cloud_language_whl",
88+
sha256 = "a2dd34f0a0ebf5705dcbe34bd41199b1d0a55c4597d38ed045bd183361a561e9",
89+
# From https://pypi.python.org/pypi/google-cloud-language
90+
url = ("https://pypi.python.org/packages/6e/86/" +
91+
"cae57e4802e72d9e626ee5828ed5a646cf4016b473a4a022f1038dba3460/" +
92+
"google_cloud_language-0.29.0-py2.py3-none-any.whl"),
93+
)
94+
8695
# Imports for examples
8796
pip_import(
8897
name = "examples_helloworld",
@@ -119,3 +128,15 @@ load(
119128
)
120129

121130
_boto_install()
131+
132+
pip_import(
133+
name = "examples_extras",
134+
requirements = "//examples/extras:requirements.txt",
135+
)
136+
137+
load(
138+
"@examples_extras//:requirements.bzl",
139+
_extras_install = "pip_install",
140+
)
141+
142+
_extras_install()

examples/extras/BUILD

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright 2017 The Bazel Authors. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
package(default_visibility = ["//visibility:public"])
15+
16+
licenses(["notice"]) # Apache 2.0
17+
18+
# TODO(mattmoor): Enable testing once we resolve:
19+
# https://github.com/bazelbuild/rules_python/issues/17
20+
# load("@examples_extras//:requirements.bzl", "requirement")
21+
# load("//python:python.bzl", "py_test")
22+
23+
# py_test(
24+
# name = "extras_test",
25+
# srcs = ["extras_test.py"],
26+
# deps = [
27+
# requirement("google-cloud-language"),
28+
# # Make sure that we can resolve the "extra" dependency
29+
# requirement("googleapis-common-protos[grpc]"),
30+
# ],
31+
# )

examples/extras/extras_test.py

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Copyright 2017 The Bazel Authors. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import unittest
16+
17+
18+
# The test is the build itself, which should not work if extras are missing.
19+
class ExtrasTest(unittest.TestCase):
20+
def test_nothing(self):
21+
pass
22+
23+
24+
if __name__ == '__main__':
25+
unittest.main()

examples/extras/requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
google-cloud-language==0.27.0

examples/extras/version_test.py

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright 2017 The Bazel Authors. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pip
16+
import unittest
17+
18+
19+
class VersionTest(unittest.TestCase):
20+
21+
def test_version(self):
22+
self.assertEqual(pip.__version__, '9.0.1')
23+
24+
25+
if __name__ == '__main__':
26+
unittest.main()

python/whl.bzl

+11-2
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@
1616
def _whl_impl(repository_ctx):
1717
"""Core implementation of whl_library."""
1818

19-
result = repository_ctx.execute([
19+
args = [
2020
"python",
2121
repository_ctx.path(repository_ctx.attr._script),
2222
"--whl", repository_ctx.path(repository_ctx.attr.whl),
2323
"--requirements", repository_ctx.attr.requirements,
24-
])
24+
]
25+
26+
if repository_ctx.attr.extras:
27+
args += ["--extras", ",".join(repository_ctx.attr.extras)]
28+
29+
result = repository_ctx.execute(args)
2530
if result.return_code:
2631
fail("whl_library failed: %s (%s)" % (result.stdout, result.stderr))
2732

@@ -33,6 +38,7 @@ whl_library = repository_rule(
3338
single_file = True,
3439
),
3540
"requirements": attr.string(),
41+
"extras": attr.string_list(),
3642
"_script": attr.label(
3743
executable = True,
3844
default = Label("//rules_python:whl.py"),
@@ -64,4 +70,7 @@ Args:
6470
6571
requirements: The name of the pip_import repository rule from which to
6672
load this .whl's dependencies.
73+
74+
extras: A subset of the "extras" available from this <code>.whl</code> for which
75+
<code>requirements</code> has the dependencies.
6776
"""

rules_python/BUILD

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ py_test(
2727
srcs = ["whl_test.py"],
2828
data = [
2929
"@futures_whl//file",
30+
"@google_cloud_language_whl//file",
3031
"@grpc_whl//file",
3132
"@mock_whl//file",
3233
],

rules_python/piptool.py

+93-59
Original file line numberDiff line numberDiff line change
@@ -17,63 +17,29 @@
1717
import json
1818
import os
1919
import pkgutil
20+
import pkg_resources
2021
import re
2122
import sys
2223
import tempfile
2324
import zipfile
2425

25-
# PIP erroneously emits an error when bundled as a PAR file. We
26-
# disable the version check to silence it.
27-
try:
28-
# Make sure we're using a suitable version of pip as a library.
29-
# Fallback on using it as a CLI.
30-
from pip._vendor import requests
31-
32-
from pip import main as _pip_main
33-
def pip_main(argv):
34-
# Extract the certificates from the PAR following the example of get-pip.py
35-
# https://github.com/pypa/get-pip/blob/430ba37776ae2ad89/template.py#L164-L168
36-
cert_path = os.path.join(tempfile.mkdtemp(), "cacert.pem")
37-
with open(cert_path, "wb") as cert:
38-
cert.write(pkgutil.get_data("pip._vendor.requests", "cacert.pem"))
39-
argv = ["--disable-pip-version-check", "--cert", cert_path] + argv
40-
return _pip_main(argv)
41-
42-
except:
43-
import subprocess
44-
45-
def pip_main(argv):
46-
return subprocess.call(['pip'] + argv)
47-
48-
# TODO(mattmoor): We can't easily depend on other libraries when
49-
# being invoked as a raw .py file. Once bundled, we should be able
50-
# to remove this fallback on a stub implementation of Wheel.
51-
try:
52-
from rules_python.whl import Wheel
53-
except:
54-
class Wheel(object):
55-
56-
def __init__(self, path):
57-
self._path = path
58-
59-
def basename(self):
60-
return os.path.basename(self._path)
61-
62-
def distribution(self):
63-
# See https://www.python.org/dev/peps/pep-0427/#file-name-convention
64-
parts = self.basename().split('-')
65-
return parts[0]
66-
67-
def version(self):
68-
# See https://www.python.org/dev/peps/pep-0427/#file-name-convention
69-
parts = self.basename().split('-')
70-
return parts[1]
71-
72-
def repository_name(self):
73-
# Returns the canonical name of the Bazel repository for this package.
74-
canonical = 'pypi__{}_{}'.format(self.distribution(), self.version())
75-
# Escape any illegal characters with underscore.
76-
return re.sub('[-.]', '_', canonical)
26+
# Make sure we're using a suitable version of pip as a library.
27+
# Fallback on using it as a CLI.
28+
from pip._vendor import requests
29+
30+
from pip import main as _pip_main
31+
def pip_main(argv):
32+
# Extract the certificates from the PAR following the example of get-pip.py
33+
# https://github.com/pypa/get-pip/blob/430ba37776ae2ad89/template.py#L164-L168
34+
cert_path = os.path.join(tempfile.mkdtemp(), "cacert.pem")
35+
with open(cert_path, "wb") as cert:
36+
cert.write(pkgutil.get_data("pip._vendor.requests", "cacert.pem"))
37+
# PIP erroneously emits an error when bundled as a PAR file. We
38+
# disable the version check to silence it.
39+
argv = ["--disable-pip-version-check", "--cert", cert_path] + argv
40+
return _pip_main(argv)
41+
42+
from rules_python.whl import Wheel
7743

7844
parser = argparse.ArgumentParser(
7945
description='Import Python dependencies into Bazel.')
@@ -90,6 +56,59 @@ def repository_name(self):
9056
parser.add_argument('--directory', action='store',
9157
help=('The directory into which to put .whl files.'))
9258

59+
def determine_possible_extras(whls):
60+
"""Determines the list of possible "extras" for each .whl
61+
62+
The possibility of an extra is determined by looking at its
63+
additional requirements, and determinine whether they are
64+
satisfied by the complete list of available wheels.
65+
66+
Args:
67+
whls: a list of Wheel objects
68+
69+
Returns:
70+
a dict that is keyed by the Wheel objects in whls, and whose
71+
values are lists of possible extras.
72+
"""
73+
whl_map = {
74+
whl.distribution(): whl
75+
for whl in whls
76+
}
77+
78+
# TODO(mattmoor): Consider memoizing if this recursion ever becomes
79+
# expensive enough to warrant it.
80+
def is_possible(distro, extra):
81+
distro = distro.replace("-", "_")
82+
# If we don't have the .whl at all, then this isn't possible.
83+
if distro not in whl_map:
84+
return False
85+
whl = whl_map[distro]
86+
# If we have the .whl, and we don't need anything extra then
87+
# we can satisfy this dependency.
88+
if not extra:
89+
return True
90+
# If we do need something extra, then check the extra's
91+
# dependencies to make sure they are fully satisfied.
92+
for extra_dep in whl.dependencies(extra=extra):
93+
req = pkg_resources.Requirement.parse(extra_dep)
94+
# Check that the dep and any extras are all possible.
95+
if not is_possible(req.project_name, None):
96+
return False
97+
for e in req.extras:
98+
if not is_possible(req.project_name, e):
99+
return False
100+
# If all of the dependencies of the extra are satisfiable then
101+
# it is possible to construct this dependency.
102+
return True
103+
104+
return {
105+
whl: [
106+
extra
107+
for extra in whl.extras()
108+
if is_possible(whl.distribution(), extra)
109+
]
110+
for whl in whls
111+
}
93112

94113
def main():
95114
args = parser.parse_args()
@@ -106,6 +125,9 @@ def list_whls():
106125
if fname.endswith('.whl'):
107126
yield os.path.join(root, fname)
108127

128+
whls = [Wheel(path) for path in list_whls()]
129+
possible_extras = determine_possible_extras(whls)
130+
109131
def whl_library(wheel):
110132
# Indentation here matters. whl_library must be within the scope
111133
# of the function below. We also avoid reimporting an existing WHL.
@@ -115,10 +137,25 @@ def whl_library(wheel):
115137
name = "{repo_name}",
116138
whl = "@{name}//:{path}",
117139
requirements = "@{name}//:requirements.bzl",
140+
extras = [{extras}]
118141
)""".format(name=args.name, repo_name=wheel.repository_name(),
119-
path=wheel.basename())
120-
121-
whls = [Wheel(path) for path in list_whls()]
142+
path=wheel.basename(),
143+
extras=','.join([
144+
'"%s"' % extra
145+
for extra in possible_extras.get(wheel, [])
146+
]))
147+
148+
whl_targets = ','.join([
149+
','.join([
150+
'"%s": "@%s//:pkg"' % (whl.distribution().lower(), whl.repository_name())
151+
] + [
152+
# For every extra that is possible from this requirements.txt
153+
'"%s[%s]": "@%s//:%s"' % (whl.distribution().lower(), extra.lower(),
154+
whl.repository_name(), extra)
155+
for extra in possible_extras.get(whl, [])
156+
])
157+
for whl in whls
158+
])
122159

123160
with open(args.output, 'w') as f:
124161
f.write("""\
@@ -142,10 +179,7 @@ def requirement(name):
142179
return _requirements[name]
143180
""".format(input=args.input,
144181
whl_libraries='\n'.join(map(whl_library, whls)),
145-
mappings=','.join([
146-
'"%s": "@%s//:pkg"' % (wheel.distribution().lower(), wheel.repository_name())
147-
for wheel in whls
148-
])))
182+
mappings=whl_targets))
149183

150184
if __name__ == '__main__':
151185
main()

0 commit comments

Comments
 (0)