# Copyright (C) 2024 The Dagger Authors.
#
# 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.
"""Skylark rules to make publishing Maven artifacts simpler.
"""
load("@rules_java//java:defs.bzl", "java_library")
load("@bazel_skylib//lib:unittest.bzl", "asserts", "unittest")
MavenInfo = provider(
fields = {
"maven_artifacts": """
The Maven coordinates for the artifacts that are exported by this target: i.e. the target
itself and its transitively exported targets.
""",
"maven_dependencies": """
The Maven coordinates of the direct dependencies, and the transitively exported targets, of
this target.
""",
},
)
_EMPTY_MAVEN_INFO = MavenInfo(
maven_artifacts = depset(),
maven_dependencies = depset(),
)
_MAVEN_COORDINATES_PREFIX = "maven_coordinates="
def _maven_artifacts(targets):
return [target[MavenInfo].maven_artifacts for target in targets if MavenInfo in target]
def _collect_maven_info_impl(_target, ctx):
tags = getattr(ctx.rule.attr, "tags", [])
deps = getattr(ctx.rule.attr, "deps", [])
exports = getattr(ctx.rule.attr, "exports", [])
runtime_deps = getattr(ctx.rule.attr, "runtime_deps", [])
maven_artifacts = []
for tag in tags:
if tag in ("maven:compile_only", "maven:shaded"):
return [_EMPTY_MAVEN_INFO]
if tag.startswith(_MAVEN_COORDINATES_PREFIX):
maven_artifacts.append(tag[len(_MAVEN_COORDINATES_PREFIX):])
return [MavenInfo(
maven_artifacts = depset(maven_artifacts, transitive = _maven_artifacts(exports)),
maven_dependencies = depset(
[],
transitive = _maven_artifacts(deps + exports + runtime_deps),
),
)]
_collect_maven_info = aspect(
attr_aspects = [
"deps",
"exports",
"runtime_deps",
],
doc = """
Collects the Maven information for targets, their dependencies, and their transitive exports.
""",
implementation = _collect_maven_info_impl,
)
def _prefix_index_of(item, prefixes):
"""Returns the index of the first value in `prefixes` that is a prefix of `item`.
If none of the prefixes match, return the size of `prefixes`.
Args:
item: the item to match
prefixes: prefixes to match against
Returns:
an integer representing the index of the match described above.
"""
for index, prefix in enumerate(prefixes):
if item.startswith(prefix):
return index
return len(prefixes)
def _sort_artifacts(artifacts, prefixes):
"""Sorts `artifacts`, preferring group ids that appear earlier in `prefixes`.
Values in `prefixes` do not need to be complete group ids. For example, passing `prefixes =
['io.bazel']` will match `io.bazel.rules:rules-artifact:1.0`. If multiple prefixes match an
artifact, the first one in `prefixes` will be used.
_Implementation note_: Skylark does not support passing a comparator function to the `sorted()`
builtin, so this constructs a list of tuples with elements:
- `[0]` = an integer corresponding to the index in `prefixes` that matches the artifact (see
`_prefix_index_of`)
- `[1]` = parts of the complete artifact, split on `:`. This is used as a tiebreaker when
multilple artifacts have the same index referenced in `[0]`. The individual parts are used so
that individual artifacts in the same group are sorted correctly - if just the string is used,
the colon that separates the artifact name from the version will sort lower than a longer
name. For example:
- `com.example.project:base:1
- `com.example.project:extension:1
"base" sorts lower than "exension".
- `[2]` = the complete artifact. this is a convenience so that after sorting, the artifact can
be returned.
The `sorted` builtin will first compare the index element and if it needs a tiebreaker, will
recursively compare the contents of the second element.
Args:
artifacts: artifacts to be sorted
prefixes: the preferred group ids used to sort `artifacts`
Returns:
A new, sorted list containing the contents of `artifacts`.
"""
indexed = []
for artifact in artifacts:
parts = artifact.split(":")
indexed.append((_prefix_index_of(parts[0], prefixes), parts, artifact))
return [x[-1] for x in sorted(indexed)]
DEP_BLOCK = """
{0}
{1}
{2}
""".strip()
CLASSIFIER_DEP_BLOCK = """
{0}
{1}
{2}
{3}
{4}
""".strip()
DEP_PKG_BLOCK = """
{0}
{1}
{2}
{3}
""".strip()
def _pom_file(ctx):
mvn_deps = depset(
[],
transitive = [target[MavenInfo].maven_dependencies for target in ctx.attr.targets],
)
formatted_deps = []
for dep in _sort_artifacts(mvn_deps.to_list(), ctx.attr.preferred_group_ids):
parts = dep.split(":")
if ":".join(parts[0:2]) in ctx.attr.excluded_artifacts:
continue
if len(parts) == 3:
template = DEP_BLOCK
elif len(parts) == 4:
template = DEP_PKG_BLOCK
elif len(parts) == 5:
template = CLASSIFIER_DEP_BLOCK
else:
fail("Unknown dependency format: %s" % dep)
formatted_deps.append(template.format(*parts))
substitutions = {}
substitutions.update(ctx.attr.substitutions)
substitutions.update({
"{generated_bzl_deps}": "\n".join(formatted_deps),
"{pom_version}": ctx.var.get("pom_version", "LOCAL-SNAPSHOT"),
})
ctx.actions.expand_template(
template = ctx.file.template_file,
output = ctx.outputs.pom_file,
substitutions = substitutions,
)
pom_file = rule(
attrs = {
"template_file": attr.label(
allow_single_file = True,
),
"substitutions": attr.string_dict(
allow_empty = True,
mandatory = False,
),
"targets": attr.label_list(
mandatory = True,
aspects = [_collect_maven_info],
),
"preferred_group_ids": attr.string_list(),
"excluded_artifacts": attr.string_list(),
},
doc = """
Creates a Maven POM file for `targets`.
This rule scans the deps, runtime_deps, and exports of `targets` and their transitive exports,
checking each for tags of the form `maven_coordinates=`. These tags are used to build
the list of Maven dependencies for the generated POM.
Users should call this rule with a `template_file` that contains a `{generated_bzl_deps}`
placeholder. The rule will replace this with the appropriate XML for all dependencies.
Additional placeholders to replace can be passed via the `substitutions` argument.
The dependencies included will be sorted alphabetically by groupId, then by artifactId. The
`preferred_group_ids` can be used to specify groupIds (or groupId-prefixes) that should be
sorted ahead of other artifacts. Artifacts in the same group will be sorted alphabetically.
Args:
name: the name of target. The generated POM file will use this name, with `.xml` appended
targets: a list of build target(s) that represent this POM file
template_file: a pom.xml file that will be used as a template for the generated POM
substitutions: an optional mapping of placeholders to replacement values that will be applied
to the `template_file` (e.g. `{'GROUP_ID': 'com.example.group'}`). `{pom_version}` is
implicitly included in this mapping and can be configured by passing `bazel build
--define=pom_version=`.
preferred_group_ids: an optional list of maven groupIds that will be used to sort the
generated deps.
excluded_artifacts: an optional list of maven artifacts in the format "groupId:artifactId"
that should be excluded from the generated pom file.
""",
outputs = {"pom_file": "%{name}.xml"},
implementation = _pom_file,
)
def _fake_java_library(name, deps = None, exports = None, runtime_deps = None):
src_file = ["%s.java" % name]
native.genrule(
name = "%s_source_file" % name,
outs = src_file,
cmd = "echo 'class %s {}' > $@" % name,
)
java_library(
name = name,
srcs = src_file,
tags = ["maven_coordinates=%s:_:_" % name],
javacopts = ["-Xep:DefaultPackage:OFF"],
deps = deps or [],
exports = exports or [],
runtime_deps = runtime_deps or [],
)
def _maven_info_test_impl(ctx):
env = unittest.begin(ctx)
asserts.equals(
env,
expected = sorted(ctx.attr.maven_artifacts),
actual = sorted(ctx.attr.target[MavenInfo].maven_artifacts.to_list()),
msg = "MavenInfo.maven_artifacts",
)
asserts.equals(
env,
expected = sorted(ctx.attr.maven_dependencies),
actual = sorted(ctx.attr.target[MavenInfo].maven_dependencies.to_list()),
msg = "MavenInfo.maven_dependencies",
)
return unittest.end(env)
_maven_info_test = unittest.make(
_maven_info_test_impl,
attrs = {
"target": attr.label(aspects = [_collect_maven_info]),
"maven_artifacts": attr.string_list(),
"maven_dependencies": attr.string_list(),
},
)
def pom_file_tests():
"""Tests for `pom_file` and `MavenInfo`.
"""
_fake_java_library(name = "FileA")
_maven_info_test(
name = "FileA_test",
target = ":FileA",
maven_artifacts = ["FileA:_:_"],
maven_dependencies = [],
)
_fake_java_library(
name = "DepOnFileA",
deps = [":FileA"],
)
_maven_info_test(
name = "DepOnFileA_test",
target = ":DepOnFileA",
maven_artifacts = ["DepOnFileA:_:_"],
maven_dependencies = ["FileA:_:_"],
)
_fake_java_library(
name = "RuntimeDepOnFileA",
runtime_deps = [":FileA"],
)
_maven_info_test(
name = "RuntimeDepOnFileA_test",
target = ":RuntimeDepOnFileA",
maven_artifacts = ["RuntimeDepOnFileA:_:_"],
maven_dependencies = ["FileA:_:_"],
)
_fake_java_library(
name = "ExportsFileA",
exports = [":FileA"],
)
_maven_info_test(
name = "ExportsFileA_test",
target = ":ExportsFileA",
maven_artifacts = [
"ExportsFileA:_:_",
"FileA:_:_",
],
maven_dependencies = ["FileA:_:_"],
)
_fake_java_library(
name = "TransitiveExportsFileA",
exports = [":ExportsFileA"],
)
_maven_info_test(
name = "TransitiveExportsFileA_test",
target = ":TransitiveExportsFileA",
maven_artifacts = [
"TransitiveExportsFileA:_:_",
"ExportsFileA:_:_",
"FileA:_:_",
],
maven_dependencies = [
"ExportsFileA:_:_",
"FileA:_:_",
],
)
_fake_java_library(
name = "TransitiveDepsFileA",
deps = [":ExportsFileA"],
)
_maven_info_test(
name = "TransitiveDepsFileA_test",
target = ":TransitiveDepsFileA",
maven_artifacts = ["TransitiveDepsFileA:_:_"],
maven_dependencies = [
"ExportsFileA:_:_",
"FileA:_:_",
],
)