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