| # Copyright 2025 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. |
| |
| """This module is for implementing PEP508 compliant METADATA deps parsing. |
| """ |
| |
| load("//python/private:normalize_name.bzl", "normalize_name") |
| load(":pep508_evaluate.bzl", "evaluate") |
| load(":pep508_requirement.bzl", "requirement") |
| |
| def deps( |
| name, |
| *, |
| requires_dist, |
| extras = [], |
| excludes = [], |
| include = []): |
| """Parse the RequiresDist from wheel METADATA |
| |
| Args: |
| name: {type}`str` the name of the wheel. |
| requires_dist: {type}`list[str]` the list of RequiresDist lines from the |
| METADATA file. |
| excludes: {type}`list[str]` what packages should we exclude. |
| include: {type}`list[str]` what packages should we exclude. If it is not |
| specified, then we will include all deps from `requires_dist`. |
| extras: {type}`list[str]` the requested extras to generate targets for. |
| |
| Returns: |
| A struct with attributes: |
| * deps: {type}`list[str]` dependencies to include unconditionally. |
| * deps_select: {type}`dict[str, list[str]]` dependencies to include on particular |
| subset of target platforms. |
| """ |
| reqs = sorted( |
| [requirement(r) for r in requires_dist], |
| key = lambda x: "{}:{}:".format(x.name, sorted(x.extras), x.marker), |
| ) |
| deps = {} |
| deps_select = {} |
| name = normalize_name(name) |
| want_extras = _resolve_extras(name, reqs, extras) |
| include = [normalize_name(n) for n in include] |
| |
| # drop self edges |
| excludes = [name] + [normalize_name(x) for x in excludes] |
| |
| reqs_by_name = {} |
| |
| for req in reqs: |
| if req.name_ in excludes: |
| continue |
| |
| if include and req.name_ not in include: |
| continue |
| |
| reqs_by_name.setdefault(req.name, []).append(req) |
| |
| for name, reqs in reqs_by_name.items(): |
| _add_reqs( |
| deps, |
| deps_select, |
| normalize_name(name), |
| reqs, |
| extras = want_extras, |
| ) |
| |
| return struct( |
| deps = sorted(deps), |
| deps_select = { |
| d: markers |
| for d, markers in sorted(deps_select.items()) |
| }, |
| ) |
| |
| def _add(deps, deps_select, dep, markers = None): |
| dep = normalize_name(dep) |
| |
| if not markers: |
| deps[dep] = True |
| elif len(markers) == 1: |
| deps_select[dep] = markers[0] |
| else: |
| deps_select[dep] = "({})".format(") or (".join(sorted(markers))) |
| |
| def _resolve_extras(self_name, reqs, extras): |
| """Resolve extras which are due to depending on self[some_other_extra]. |
| |
| Some packages may have cyclic dependencies resulting from extras being used, one example is |
| `etils`, where we have one set of extras as aliases for other extras |
| and we have an extra called 'all' that includes all other extras. |
| |
| Example: github.com/google/etils/blob/a0b71032095db14acf6b33516bca6d885fe09e35/pyproject.toml#L32. |
| |
| When the `requirements.txt` is generated by `pip-tools`, then it is likely that |
| this step is not needed, but for other `requirements.txt` files this may be useful. |
| |
| NOTE @aignas 2023-12-08: the extra resolution is not platform dependent, |
| but in order for it to become platform dependent we would have to have |
| separate targets for each extra in extras. |
| """ |
| |
| # Resolve any extra extras due to self-edges, empty string means no |
| # extras The empty string in the set is just a way to make the handling |
| # of no extras and a single extra easier and having a set of {"", "foo"} |
| # is equivalent to having {"foo"}. |
| extras = extras or [""] |
| |
| self_reqs = [] |
| for req in reqs: |
| if req.name != self_name: |
| continue |
| |
| if req.marker == None: |
| # I am pretty sure we cannot reach this code as it does not |
| # make sense to specify packages in this way, but since it is |
| # easy to handle, lets do it. |
| # |
| # TODO @aignas 2023-12-08: add a test |
| extras = extras + req.extras |
| else: |
| # process these in a separate loop |
| self_reqs.append(req) |
| |
| # A double loop is not strictly optimal, but always correct without recursion |
| for req in self_reqs: |
| if [True for extra in extras if evaluate(req.marker, env = {"extra": extra})]: |
| extras = extras + req.extras |
| else: |
| continue |
| |
| # Iterate through all packages to ensure that we include all of the extras from previously |
| # visited packages. |
| for req_ in self_reqs: |
| if [True for extra in extras if evaluate(req.marker, env = {"extra": extra})]: |
| extras = extras + req_.extras |
| |
| # Poor mans set |
| return sorted({x: None for x in extras}) |
| |
| def _add_reqs(deps, deps_select, dep, reqs, *, extras): |
| for req in reqs: |
| if not req.marker: |
| _add(deps, deps_select, dep) |
| return |
| |
| markers = {} |
| for req in reqs: |
| for x in extras: |
| m = evaluate(req.marker, env = {"extra": x}, strict = False) |
| if m == False: |
| continue |
| elif m == True: |
| _add(deps, deps_select, dep) |
| break |
| else: |
| markers[m] = None |
| continue |
| |
| if markers: |
| _add(deps, deps_select, dep, sorted(markers)) |