blob: 2ddbf6d8254adc9d0170cb9e17973a51d1231230 [file] [log] [blame]
brandjon029601e2019-01-24 16:33:50 -08001# Copyright 2019 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"""Defines an aspect for finding constraints on the Python version."""
16
17_PY2 = "PY2"
18_PY3 = "PY3"
19
20_TransitiveVersionInfo = provider(
21 doc = """\
22Propagates information about the Python version constraints of transitive
23dependencies.
24
25Canonically speaking, a target is considered to be PY2-only if it returns the
26`py` provider with the `has_py2_only_sources` field set to `True`. Likewise, it
27is PY3-only if `has_py3_only_sources` is `True`. Unless something weird is going
28on with how the transitive sources are aggregated, it is expected that if any
29target is PY2-only or PY3-only, then so are all of its reverse transitive deps.
30
31The `py_library` rule becomes PY2-only or PY3-only when its `srcs_version`
32attribute is respectively set to `PY2ONLY` or to either `PY3` or `PY3ONLY`.
33(The asymmetry of not recongizing `PY2` is due to
34[#1393](https://github.com/bazelbuild/bazel/issues/1393) and will be moot once
35the `PY2ONLY` and `PY3ONLY` names are retired.) Therefore, if the transitive
36deps of the root target are all `py_library` targets, we can look at the
37`srcs_version` attribute to easily distinguish targets whose own sources
38require a given Python version, from targets that only require it due to their
39transitive deps.
40
41If on the other hand there are other rule types in the transitive deps that do
42not define `srcs_version`, then the only general way to tell that a dep
43introduces a requirement on Python 2 or 3 is if it returns true in the
44corresponding provider field and none of its direct dependencies returns true
45in that field.
46
47This `_TransitiveVersionInfo` provider reports transitive deps that satisfy
48either of these criteria. But of those deps, it only reports those that are
49"top-most" in relation to the root. The top-most deps are the ones that are
50reachable from the root target by a path that does not involve any other
51top-most dep (though it's possible for one top-most dep to have a separate path
52to another). Reporting only the top-most deps ensures that we give the minimal
53information needed to understand how the root target depends on PY2-only or
54PY3-only targets.
55""",
56 fields = {
57 "py2": """\
58A `_DepsWithPathsInfo` object for transitive deps that are known to introduce a
59PY2-only requirement.
60""",
61 "py3": """\
62A `_DepsWithPathsInfo` object for transitive deps that are known to introduce a
63PY3-only requirement.
64""",
65 },
66)
67
68_DepsWithPathsInfo = provider(
69 fields = {
70 "topmost": """\
71A list of labels of all top-most transitive deps known to introduce a version
72requirement. The deps appear in left-to-right order.
73""",
74 "paths": """\
75A dictionary that maps labels appearing in `topmost` to their paths from the
76root. Paths are represented as depsets with `preorder` order.
77""",
78 # It is technically possible for the depset keys to collide if the same
79 # target appears multiple times in the build graph as different
80 # configured targets, but this seems unlikely.
81 },
82)
83
84def _join_lines(nodes):
laurentlb2a107292019-05-27 09:01:12 -070085 return "\n".join([str(n) for n in nodes]) if nodes else "<None>"
brandjon029601e2019-01-24 16:33:50 -080086
87def _str_path(path):
laurentlb2a107292019-05-27 09:01:12 -070088 return " -> ".join([str(p) for p in path.to_list()])
brandjon029601e2019-01-24 16:33:50 -080089
90def _str_tv_info(tv_info):
91 """Returns a string representation of a `_TransitiveVersionInfo`."""
92 path_lines = []
93 path_lines.extend([_str_path(tv_info.py2.paths[n]) for n in tv_info.py2.topmost])
94 path_lines.extend([_str_path(tv_info.py3.paths[n]) for n in tv_info.py3.topmost])
95 return """\
96Python 2-only deps:
97{py2_nodes}
98
99Python 3-only deps:
100{py3_nodes}
101
102Paths to these deps:
103{paths}
104""".format(
105 py2_nodes = _join_lines(tv_info.py2.topmost),
106 py3_nodes = _join_lines(tv_info.py3.topmost),
107 paths = _join_lines(path_lines),
108 )
109
110def _has_version_requirement(target, version):
111 """Returns whether a target has a version requirement, as per its provider.
112
113 Args:
114 target: the `Target` object to check
115 version: either the string "PY2" or "PY3"
116
117 Returns:
118 `True` if `target` requires `version` according to the
119 `has_py<?>_only_sources` fields
120 """
121 if version not in [_PY2, _PY3]:
122 fail("Unrecognized version '%s'; must be 'PY2' or 'PY3'" % version)
123 field = {
124 _PY2: "has_py2_only_sources",
125 _PY3: "has_py3_only_sources",
126 }[version]
127
brandjon2537cb72019-03-04 13:17:59 -0800128 if not PyInfo in target:
brandjon029601e2019-01-24 16:33:50 -0800129 return False
brandjon2537cb72019-03-04 13:17:59 -0800130 field_value = getattr(target[PyInfo], field, False)
brandjon029601e2019-01-24 16:33:50 -0800131 if not type(field_value) == "bool":
132 fail("Invalid type for provider field '%s': %r" % (field, field_value))
133 return field_value
134
135def _introduces_version_requirement(target, target_attr, version):
136 """Returns whether a target introduces a PY2-only or PY3-only requirement.
137
138 A target that has a version requirement is considered to introduce this
139 requirement if either 1) its rule type has a `srcs_version` attribute and
140 the target sets it to `PY2ONLY` (PY2), or `PY3` or `PY3ONLY` (PY3); or 2)
141 none of its direct dependencies set `has_py2_only_sources` (PY2) or
142 `has_py3_only_sources` (PY3) to `True`. A target that does not actually have
143 the version requirement is never considered to introduce the requirement.
144
145 Args:
146 target: the `Target` object as passed to the aspect implementation
147 function
148 target_attr: the attribute struct as retrieved from `ctx.rule.attr` in
149 the aspect implementation function
150 version: either the string "PY2" or "PY3" indicating which constraint
151 to test for
152
153 Returns:
154 `True` if `target` introduces the requirement on `version`, as per the
155 above definition
156 """
157 if version not in [_PY2, _PY3]:
158 fail("Unrecognized version '%s'; must be 'PY2' or 'PY3'" % version)
159
160 # If we don't actually have the version requirement, we can't possibly
161 # introduce it, regardless of our srcs_version or what our dependencies
162 # return.
163 if not _has_version_requirement(target, version):
164 return False
165
166 # Try the attribute, if present.
167 if hasattr(target_attr, "srcs_version"):
168 sv = target_attr.srcs_version
169 if version == _PY2:
170 if sv == "PY2ONLY":
171 return True
172 elif version == _PY3:
173 if sv in ["PY3", "PY3ONLY"]:
174 return True
175 else:
176 fail("Illegal state")
177
178 # No good, check the direct deps' provider fields.
brandjone84bb332019-04-16 12:42:36 -0700179 if not hasattr(target_attr, "deps"):
180 return True
181 else:
182 return not any([
183 _has_version_requirement(dep, version)
184 for dep in target_attr.deps
185 ])
brandjon029601e2019-01-24 16:33:50 -0800186
187def _empty_depswithpaths():
188 """Initializes an empty `_DepsWithPathsInfo` object."""
189 return _DepsWithPathsInfo(topmost = [], paths = {})
190
191def _init_depswithpaths_for_node(node):
192 """Initialize a new `_DepsWithPathsInfo` object.
193
194 The object will record just the given node as its sole entry.
195
196 Args:
197 node: a label
198
199 Returns:
200 a `_DepsWithPathsInfo` object
201 """
202 return _DepsWithPathsInfo(
203 topmost = [node],
204 paths = {node: depset(direct = [node], order = "preorder")},
205 )
206
207def _merge_depswithpaths_appending_node(depswithpaths, node_to_append):
208 """Merge several `_DepsWithPathsInfo` objects and appends a path entry.
209
210 Args:
211 depswithpaths: a list of `_DepsWithPathsInfo` objects whose entries are
212 to be merged
213 node_to_append: a label to append to all the paths of the merged object
214
215 Returns:
216 a `_DepsWithPathsInfo` object
217 """
218 seen = {}
219 topmost = []
220 paths = {}
221 for dwp in depswithpaths:
222 for node in dwp.topmost:
223 if node in seen:
224 continue
225 seen[node] = True
226
227 topmost.append(node)
228 path = dwp.paths[node]
229 path = depset(
230 direct = [node_to_append],
231 transitive = [path],
232 order = "preorder",
233 )
234 paths[node] = path
235 return _DepsWithPathsInfo(topmost = topmost, paths = paths)
236
237def _find_requirements_impl(target, ctx):
238 # Determine whether this target introduces a requirement. If so, any deps
239 # that introduce that requirement are not propagated, though they might
240 # still be considered top-most if an alternate path exists.
brandjone84bb332019-04-16 12:42:36 -0700241 if not hasattr(ctx.rule.attr, "deps"):
242 dep_tv_infos = []
243 else:
244 dep_tv_infos = [
245 d[_TransitiveVersionInfo]
246 for d in ctx.rule.attr.deps
247 if _TransitiveVersionInfo in d
248 ]
brandjon029601e2019-01-24 16:33:50 -0800249
250 if not _has_version_requirement(target, "PY2"):
251 new_py2 = _empty_depswithpaths()
252 elif _introduces_version_requirement(target, ctx.rule.attr, "PY2"):
253 new_py2 = _init_depswithpaths_for_node(target.label)
254 else:
255 new_py2 = _merge_depswithpaths_appending_node(
256 [i.py2 for i in dep_tv_infos],
257 target.label,
258 )
259
260 if not _has_version_requirement(target, "PY3"):
261 new_py3 = _empty_depswithpaths()
262 elif _introduces_version_requirement(target, ctx.rule.attr, "PY3"):
263 new_py3 = _init_depswithpaths_for_node(target.label)
264 else:
265 new_py3 = _merge_depswithpaths_appending_node(
266 [i.py3 for i in dep_tv_infos],
267 target.label,
268 )
269
270 tv_info = _TransitiveVersionInfo(py2 = new_py2, py3 = new_py3)
271
272 output = ctx.actions.declare_file(target.label.name + "-pyversioninfo.txt")
273 ctx.actions.write(output = output, content = _str_tv_info(tv_info))
274
275 return [tv_info, OutputGroupInfo(pyversioninfo = depset(direct = [output]))]
276
277find_requirements = aspect(
278 implementation = _find_requirements_impl,
279 attr_aspects = ["deps"],
280 doc = """\
281The aspect definition. Can be invoked on the command line as
282
283 bazel build //pkg:my_py_binary_target \
brandjone4ccba42019-08-01 14:27:50 -0700284 --aspects=@rules_python//python:defs.bzl%find_requirements \
brandjon029601e2019-01-24 16:33:50 -0800285 --output_groups=pyversioninfo
286""",
287)
288
289def _apply_find_requirements_for_testing_impl(ctx):
290 tv_info = ctx.attr.target[_TransitiveVersionInfo]
291 ctx.actions.write(output = ctx.outputs.out, content = _str_tv_info(tv_info))
292
293apply_find_requirements_for_testing = rule(
294 implementation = _apply_find_requirements_for_testing_impl,
295 attrs = {
296 "target": attr.label(aspects = [find_requirements]),
297 "out": attr.output(),
298 },
299 doc = """\
300Writes the string output of `find_requirements` to a file.
301
302This helper exists for the benefit of PythonSrcsVersionAspectTest.java. It is
303useful because code outside this file cannot read the private
304`_TransitiveVersionInfo` provider, and `BuildViewTestCase` cannot easily access
305actions generated by an aspect.
306""",
307)