fix: parse Gradle rich version constraints (#1639)
diff --git a/private/extensions/maven.bzl b/private/extensions/maven.bzl index a5135a7..75f3643 100644 --- a/private/extensions/maven.bzl +++ b/private/extensions/maven.bzl
@@ -265,6 +265,18 @@ amended = True return amended +def _parse_gradle_version(version): + if type(version) == "string": + return version, False + if type(version) == "dict": + for key in ("reject", "rejectAll", "rejects"): + if key in version: + fail("Unsupported Gradle version rejection '%s' in %s" % (key, version)) + for key in ("strictly", "require", "prefer"): + if key in version: + return version[key], key == "strictly" + fail("Unsupported Gradle version constraint:", version) + def process_gradle_versions_file(parsed, bom_modules): artifacts = [] boms = [] @@ -272,6 +284,7 @@ for alias, value in parsed.get("libraries", {}).items(): # Handle different dependency declaration formats coords = None + force_version = False # Case 1: Simple string notation: "group:artifact:version" if type(value) == "string": @@ -290,23 +303,17 @@ fail("Library '%s' must have either 'module' or both 'group' and 'name' keys" % alias) # Handle version (applies to both module and group+name formats) - if "version.ref" in value.keys(): - version = parsed.get("versions", {}).get(value["version.ref"]) - if not version: - fail("Unable to resolve version.ref %s" % value["version.ref"]) + if "version.ref" in value or "version" in value: + raw_version = value.get("version", "") + version_ref = value.get("version.ref") + if type(raw_version) == "dict": + version_ref = raw_version.get("ref", version_ref) + if version_ref: + raw_version = parsed.get("versions", {}).get(version_ref) + if not raw_version: + fail("Unable to resolve version.ref %s" % version_ref) + version, force_version = _parse_gradle_version(raw_version) coords += ":%s" % version - elif "version" in value.keys(): - raw_version = value["version"] - if type(raw_version) == "string": - coords += ":%s" % value["version"] - elif type(raw_version) == "dict": - version_ref = raw_version.get("ref", None) - if not version_ref: - fail("Unable to find version ref in", value) - version = parsed.get("versions", {}).get(version_ref) - if not version: - fail("Unable to resolve version ref", value) - coords += ":%s" % version # Handle packaging (e.g., "aar" for Android libraries) # Note: Gradle uses "package" but we'll check common variants @@ -322,11 +329,13 @@ is_bom = type(value) == "dict" and value.get("is_bom", "false").lower() == "true" - if is_bom or module_id in bom_modules: - boms.append(unpack_coordinates(coords)) - else: - artifact = unpack_coordinates(coords) + artifact = unpack_coordinates(coords) + if force_version: + artifact = struct(**(remove_empty_fields(artifact) | {"force_version": True})) + if is_bom or module_id in bom_modules: + boms.append(artifact) + else: if type(value) == "dict": artifact_dict = { "group": artifact.group, @@ -339,8 +348,8 @@ artifact_dict["classifier"] = value["classifier"] if "exclusions" in value.keys(): artifact_dict["exclusions"] = _add_exclusions(json.decode(value["exclusions"].replace("'", '"'))) - if "force_version" in value.keys(): - artifact_dict["force_version"] = (value["force_version"].lower() == "true") + if force_version or "force_version" in value: + artifact_dict["force_version"] = force_version or value["force_version"].lower() == "true" artifact = struct(**artifact_dict) artifacts.append(artifact)
diff --git a/tests/unit/version_catalogs_test.bzl b/tests/unit/version_catalogs_test.bzl index 0aa3829..5b570c6 100644 --- a/tests/unit/version_catalogs_test.bzl +++ b/tests/unit/version_catalogs_test.bzl
@@ -1,4 +1,5 @@ -load("@bazel_skylib//lib:unittest.bzl", "asserts", "unittest") +load("@bazel_skylib//lib:partial.bzl", "partial") +load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts", "unittest") load("@toml.bzl", "toml") load("//private/extensions:maven.bzl", "process_gradle_versions_file") @@ -335,7 +336,118 @@ is_bom_field_test = unittest.make(_is_bom_field_impl) +def _rich_versions_select_version_and_force_only_strictly_impl(ctx): + env = unittest.begin(ctx) + + for constraint, expected_version, expected_force in [ + ('strictly = "6.1.2"', "6.1.2", True), + ('require = "6.1.2"', "6.1.2", False), + ('prefer = "6.1.2"', "6.1.2", False), + ('strictly = "6.1.2", require = "6.2.6", prefer = "6.0.0"', "6.1.2", True), + ('require = "6.2.6", prefer = "6.0.0"', "6.2.6", False), + ]: + for declaration in [ + 'module = "io.kotest:kotest-extensions-mockserver-jvm", version = { %s }' % constraint, + 'group = "io.kotest", name = "kotest-extensions-mockserver-jvm", version.ref = "kotest"', + ]: + parsed = toml.decode("""\ +[versions] +kotest = { %s } +[libraries] +kotest-extension = { %s } +""" % (constraint, declaration)) + artifacts, boms = process_gradle_versions_file(parsed, []) + + asserts.equals(env, [], boms) + asserts.equals(env, 1, len(artifacts)) + asserts.equals(env, "io.kotest", artifacts[0].group) + asserts.equals(env, "kotest-extensions-mockserver-jvm", artifacts[0].artifact) + asserts.equals(env, expected_version, artifacts[0].version) + asserts.equals(env, expected_force, getattr(artifacts[0], "force_version", False)) + + return unittest.end(env) + +rich_versions_select_version_and_force_only_strictly_test = unittest.make(_rich_versions_select_version_and_force_only_strictly_impl) + +def _strictly_preserves_artifact_metadata_impl(ctx): + env = unittest.begin(ctx) + + parsed = toml.decode("""\ +[libraries] +example = { module = "org.example:example", version.strictly = "1.0", force_version = "false", classifier = "tests", packaging = "aar", exclusions = "['org.example:excluded']" } +""") + artifacts, _ = process_gradle_versions_file(parsed, []) + + asserts.equals(env, "1.0", artifacts[0].version) + asserts.true(env, artifacts[0].force_version) + asserts.equals(env, "tests", artifacts[0].classifier) + asserts.equals(env, "aar", artifacts[0].packaging) + asserts.equals(env, [{"group": "org.example", "artifact": "excluded"}], artifacts[0].exclusions) + + return unittest.end(env) + +strictly_preserves_artifact_metadata_test = unittest.make(_strictly_preserves_artifact_metadata_impl) + +def _strictly_forces_bom_versions_impl(ctx): + env = unittest.begin(ctx) + + parsed = toml.decode("""\ +[versions] +example = { strictly = "1.0" } +[libraries] +example-bom = { module = "org.example:example-bom", version.ref = "example", is_bom = "true" } +another-bom = { module = "org.example:another-bom", version = { strictly = "2.0" } } +""") + artifacts, boms = process_gradle_versions_file(parsed, ["org.example:another-bom"]) + + asserts.equals(env, [], artifacts) + asserts.equals(env, ["1.0", "2.0"], [bom.version for bom in boms]) + asserts.equals(env, [True, True], [getattr(bom, "force_version", False) for bom in boms]) + + return unittest.end(env) + +strictly_forces_bom_versions_test = unittest.make(_strictly_forces_bom_versions_impl) + +def _version_rejection_target_impl(ctx): + process_gradle_versions_file(toml.decode(ctx.attr.catalog), []) + return [] + +version_rejection_target = rule( + implementation = _version_rejection_target_impl, + attrs = {"catalog": attr.string(mandatory = True)}, +) + +def _version_rejections_fail_clearly_impl(ctx): + env = analysistest.begin(ctx) + asserts.expect_failure(env, "Unsupported Gradle version rejection") + return analysistest.end(env) + +version_rejections_fail_clearly_test = analysistest.make( + _version_rejections_fail_clearly_impl, + expect_failure = True, +) + def version_catalogs_test_suite(): + rejection_tests = [] + for key, value in [("reject", '["1.1"]'), ("rejectAll", "true"), ("rejects", '["1.1"]')]: + for referenced in [False, True]: + name = "version_%s_%s_target" % (key, "referenced" if referenced else "inline") + version_rejection_target( + name = name, + catalog = """\ +[versions] +example = { strictly = "1.0", %s = %s } +[libraries] +example = { module = "org.example:example", %s } +""" % (key, value, 'version.ref = "example"' if referenced else 'version = { strictly = "1.0", %s = %s }' % (key, value)), + tags = ["manual"], + ) + rejection_tests.append(partial.make( + version_rejections_fail_clearly_test, + target_under_test = ":" + name, + size = "small", + )) + unittest.suite( "version_catalogs_tests", simple_string_notation_test, @@ -351,4 +463,8 @@ multiple_libraries_test, extra_fields_test, is_bom_field_test, + partial.make(rich_versions_select_version_and_force_only_strictly_test, size = "small"), + partial.make(strictly_preserves_artifact_metadata_test, size = "small"), + partial.make(strictly_forces_bom_versions_test, size = "small"), + *rejection_tests )