blob: e73f747bed52ad9f6107d1675c603da68d24dc5f [file] [log] [blame]
# 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))