| # 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(nodes) if nodes else "<None>" |
| |
| def _str_path(path): |
| return " -> ".join(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 hasattr(target, "py"): |
| return False |
| field_value = getattr(target.py, 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. |
| 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. |
| 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=@bazel_tools//tools/python:srcs_version.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. |
| """, |
| ) |