brandjon | 029601e | 2019-01-24 16:33:50 -0800 | [diff] [blame] | 1 | # 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 = """\ |
| 22 | Propagates information about the Python version constraints of transitive |
| 23 | dependencies. |
| 24 | |
| 25 | Canonically 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 |
| 27 | is PY3-only if `has_py3_only_sources` is `True`. Unless something weird is going |
| 28 | on with how the transitive sources are aggregated, it is expected that if any |
| 29 | target is PY2-only or PY3-only, then so are all of its reverse transitive deps. |
| 30 | |
| 31 | The `py_library` rule becomes PY2-only or PY3-only when its `srcs_version` |
| 32 | attribute 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 |
| 35 | the `PY2ONLY` and `PY3ONLY` names are retired.) Therefore, if the transitive |
| 36 | deps 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 |
| 38 | require a given Python version, from targets that only require it due to their |
| 39 | transitive deps. |
| 40 | |
| 41 | If on the other hand there are other rule types in the transitive deps that do |
| 42 | not define `srcs_version`, then the only general way to tell that a dep |
| 43 | introduces a requirement on Python 2 or 3 is if it returns true in the |
| 44 | corresponding provider field and none of its direct dependencies returns true |
| 45 | in that field. |
| 46 | |
| 47 | This `_TransitiveVersionInfo` provider reports transitive deps that satisfy |
| 48 | either 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 |
| 50 | reachable from the root target by a path that does not involve any other |
| 51 | top-most dep (though it's possible for one top-most dep to have a separate path |
| 52 | to another). Reporting only the top-most deps ensures that we give the minimal |
| 53 | information needed to understand how the root target depends on PY2-only or |
| 54 | PY3-only targets. |
| 55 | """, |
| 56 | fields = { |
| 57 | "py2": """\ |
| 58 | A `_DepsWithPathsInfo` object for transitive deps that are known to introduce a |
| 59 | PY2-only requirement. |
| 60 | """, |
| 61 | "py3": """\ |
| 62 | A `_DepsWithPathsInfo` object for transitive deps that are known to introduce a |
| 63 | PY3-only requirement. |
| 64 | """, |
| 65 | }, |
| 66 | ) |
| 67 | |
| 68 | _DepsWithPathsInfo = provider( |
| 69 | fields = { |
| 70 | "topmost": """\ |
| 71 | A list of labels of all top-most transitive deps known to introduce a version |
| 72 | requirement. The deps appear in left-to-right order. |
| 73 | """, |
| 74 | "paths": """\ |
| 75 | A dictionary that maps labels appearing in `topmost` to their paths from the |
| 76 | root. 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 | |
| 84 | def _join_lines(nodes): |
laurentlb | 2a10729 | 2019-05-27 09:01:12 -0700 | [diff] [blame] | 85 | return "\n".join([str(n) for n in nodes]) if nodes else "<None>" |
brandjon | 029601e | 2019-01-24 16:33:50 -0800 | [diff] [blame] | 86 | |
| 87 | def _str_path(path): |
laurentlb | 2a10729 | 2019-05-27 09:01:12 -0700 | [diff] [blame] | 88 | return " -> ".join([str(p) for p in path.to_list()]) |
brandjon | 029601e | 2019-01-24 16:33:50 -0800 | [diff] [blame] | 89 | |
| 90 | def _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 """\ |
| 96 | Python 2-only deps: |
| 97 | {py2_nodes} |
| 98 | |
| 99 | Python 3-only deps: |
| 100 | {py3_nodes} |
| 101 | |
| 102 | Paths 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 | |
| 110 | def _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 | |
brandjon | 2537cb7 | 2019-03-04 13:17:59 -0800 | [diff] [blame] | 128 | if not PyInfo in target: |
brandjon | 029601e | 2019-01-24 16:33:50 -0800 | [diff] [blame] | 129 | return False |
brandjon | 2537cb7 | 2019-03-04 13:17:59 -0800 | [diff] [blame] | 130 | field_value = getattr(target[PyInfo], field, False) |
brandjon | 029601e | 2019-01-24 16:33:50 -0800 | [diff] [blame] | 131 | if not type(field_value) == "bool": |
| 132 | fail("Invalid type for provider field '%s': %r" % (field, field_value)) |
| 133 | return field_value |
| 134 | |
| 135 | def _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. |
brandjon | e84bb33 | 2019-04-16 12:42:36 -0700 | [diff] [blame] | 179 | 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 | ]) |
brandjon | 029601e | 2019-01-24 16:33:50 -0800 | [diff] [blame] | 186 | |
| 187 | def _empty_depswithpaths(): |
| 188 | """Initializes an empty `_DepsWithPathsInfo` object.""" |
| 189 | return _DepsWithPathsInfo(topmost = [], paths = {}) |
| 190 | |
| 191 | def _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 | |
| 207 | def _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 | |
| 237 | def _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. |
brandjon | e84bb33 | 2019-04-16 12:42:36 -0700 | [diff] [blame] | 241 | 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 | ] |
brandjon | 029601e | 2019-01-24 16:33:50 -0800 | [diff] [blame] | 249 | |
| 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 | |
| 277 | find_requirements = aspect( |
| 278 | implementation = _find_requirements_impl, |
| 279 | attr_aspects = ["deps"], |
| 280 | doc = """\ |
| 281 | The aspect definition. Can be invoked on the command line as |
| 282 | |
| 283 | bazel build //pkg:my_py_binary_target \ |
brandjon | e4ccba4 | 2019-08-01 14:27:50 -0700 | [diff] [blame] | 284 | --aspects=@rules_python//python:defs.bzl%find_requirements \ |
brandjon | 029601e | 2019-01-24 16:33:50 -0800 | [diff] [blame] | 285 | --output_groups=pyversioninfo |
| 286 | """, |
| 287 | ) |
| 288 | |
| 289 | def _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 | |
| 293 | apply_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 = """\ |
| 300 | Writes the string output of `find_requirements` to a file. |
| 301 | |
| 302 | This helper exists for the benefit of PythonSrcsVersionAspectTest.java. It is |
| 303 | useful because code outside this file cannot read the private |
| 304 | `_TransitiveVersionInfo` provider, and `BuildViewTestCase` cannot easily access |
| 305 | actions generated by an aspect. |
| 306 | """, |
| 307 | ) |