diff --git a/src/packagedcode/build_gradle.py b/src/packagedcode/build_gradle.py index fc0c0681cb3..30fececc20c 100644 --- a/src/packagedcode/build_gradle.py +++ b/src/packagedcode/build_gradle.py @@ -14,6 +14,11 @@ from pygmars.parse import Parser from pygments import lex +try: + import tomllib +except ImportError: + import tomli as tomllib + from packagedcode import groovy_lexer from packagedcode import models @@ -86,7 +91,7 @@ def assign_package_to_resources(cls, package, resource, codebase, package_adder) DEPENDENCY-4: { ? } - DEPENDENCY-5: { } + DEPENDENCY-VERSION-CATALOG: { ( )*} NESTED-DEPENDENCY-1: { + } """ @@ -312,6 +317,25 @@ def get_dependencies_from_parse_tree(parse_tree): dependency[last_key] = remove_quotes(child_node.value) yield dependency + if tree_node.label == 'DEPENDENCY-VERSION-CATALOG': + dependency = {} + scope = None + name_parts = [] + for child_node in tree_node.leaves(): + if child_node.label == 'NAME': + if not scope: + scope = child_node.value + else: + name_parts.append(child_node.value) + elif child_node.label == 'NAME-ATTRIBUTE': + name_parts.append(child_node.value) + if scope and name_parts: + full_name = '.'.join(name_parts) + dependency['scope'] = scope + dependency['name'] = full_name + dependency['namespace'] = '' + dependency['version'] = '' + yield dependency if tree_node.label == 'DEPENDENCY-5': dependency = {} for child_node in tree_node.leaves(): @@ -322,11 +346,81 @@ def get_dependencies_from_parse_tree(parse_tree): yield dependency + +def parse_version_catalog(build_gradle_location): + """ + Parse gradle/libs.versions.toml and return a mapping of alias -> {group, name, version}. + Returns empty dict if file not found or parsing fails. + """ + gradle_dir = os.path.dirname(build_gradle_location) + catalog_locations = [ + os.path.join(gradle_dir, 'gradle', 'libs.versions.toml'), + os.path.join(os.path.dirname(gradle_dir), 'gradle', 'libs.versions.toml'), + ] + catalog_path = None + for loc in catalog_locations: + if os.path.exists(loc): + catalog_path = loc + break + if not catalog_path: + return {} + try: + with open(catalog_path, 'rb') as f: + catalog = tomllib.load(f) + libraries = catalog.get('libraries', {}) + versions = catalog.get('versions', {}) + alias_map = {} + for alias, lib_spec in libraries.items(): + normalized_alias = alias.replace('-', '.') + if isinstance(lib_spec, str): + parts = lib_spec.split(':') + if len(parts) >= 2: + alias_map[normalized_alias] = { + 'namespace': parts[0], + 'name': parts[1], + 'version': parts[2] if len(parts) > 2 else '' + } + elif isinstance(lib_spec, dict): + group = lib_spec.get('group', '') + name = lib_spec.get('name', '') + version = lib_spec.get('version', '') + if isinstance(version, dict) and 'ref' in version: + version = versions.get(version['ref'], '') + alias_map[normalized_alias] = { + 'namespace': group, + 'name': name, + 'version': version + } + return alias_map + except Exception as e: + logger_debug(f"Failed to parse version catalog: {e}") + return {} + def get_dependencies(build_gradle_location): + """ + Parse dependencies from build.gradle, resolving version catalog references. + """ parse_tree = get_parse_tree(build_gradle_location) - # Parse `parse_tree` for dependencies and print them - return list(get_dependencies_from_parse_tree(parse_tree)) - + raw_dependencies = list(get_dependencies_from_parse_tree(parse_tree)) + catalog = parse_version_catalog(build_gradle_location) + resolved_dependencies = [] + for dep in raw_dependencies: + name = dep.get('name', '') + if name.startswith('libs.'): + alias = name[5:] + if alias in catalog: + catalog_entry = catalog[alias] + resolved_dependencies.append({ + 'namespace': catalog_entry['namespace'], + 'name': catalog_entry['name'], + 'version': catalog_entry['version'], + 'scope': dep.get('scope', '') + }) + # Skip if not found in catalog - prevents incomplete PURLs + continue + + resolved_dependencies.append(dep) + return resolved_dependencies def build_package(cls, dependencies, package_only=False): """ diff --git a/tests/packagedcode/data/build_gradle/groovy/fdroid-version-catalog/build.gradle b/tests/packagedcode/data/build_gradle/groovy/fdroid-version-catalog/build.gradle new file mode 100644 index 00000000000..709ded0e223 --- /dev/null +++ b/tests/packagedcode/data/build_gradle/groovy/fdroid-version-catalog/build.gradle @@ -0,0 +1,9 @@ +dependencies { + implementation project(":libs:download") + implementation libs.androidx.appcompat + implementation libs.androidx.preference.ktx + implementation libs.material + implementation('com.journeyapps:zxing-android-embedded:4.3.0') { transitive = false } + implementation libs.acra.mail + implementation libs.acra.dialog +} diff --git a/tests/packagedcode/data/build_gradle/groovy/fdroid-version-catalog/build.gradle-expected.json b/tests/packagedcode/data/build_gradle/groovy/fdroid-version-catalog/build.gradle-expected.json new file mode 100644 index 00000000000..fa70d464955 --- /dev/null +++ b/tests/packagedcode/data/build_gradle/groovy/fdroid-version-catalog/build.gradle-expected.json @@ -0,0 +1,124 @@ +[ + { + "type": "maven", + "namespace": null, + "name": null, + "version": null, + "qualifiers": {}, + "subpath": null, + "primary_language": null, + "description": null, + "release_date": null, + "parties": [], + "keywords": [], + "homepage_url": null, + "download_url": null, + "size": null, + "sha1": null, + "md5": null, + "sha256": null, + "sha512": null, + "bug_tracking_url": null, + "code_view_url": null, + "vcs_url": null, + "copyright": null, + "holder": null, + "declared_license_expression": null, + "declared_license_expression_spdx": null, + "license_detections": [], + "other_license_expression": null, + "other_license_expression_spdx": null, + "other_license_detections": [], + "extracted_license_statement": null, + "notice_text": null, + "source_packages": [], + "file_references": [], + "is_private": false, + "is_virtual": false, + "extra_data": {}, + "dependencies": [ + { + "purl": "pkg:maven/libs@download", + "extracted_requirement": "download", + "scope": "project", + "is_runtime": true, + "is_optional": false, + "is_pinned": true, + "is_direct": true, + "resolved_package": {}, + "extra_data": {} + }, + { + "purl": "pkg:maven/androidx.appcompat/appcompat@1.6.1", + "extracted_requirement": "1.6.1", + "scope": "implementation", + "is_runtime": true, + "is_optional": false, + "is_pinned": true, + "is_direct": true, + "resolved_package": {}, + "extra_data": {} + }, + { + "purl": "pkg:maven/androidx.preference/preference-ktx@1.2.1", + "extracted_requirement": "1.2.1", + "scope": "implementation", + "is_runtime": true, + "is_optional": false, + "is_pinned": true, + "is_direct": true, + "resolved_package": {}, + "extra_data": {} + }, + { + "purl": "pkg:maven/com.google.android.material/material@1.9.0", + "extracted_requirement": "1.9.0", + "scope": "implementation", + "is_runtime": true, + "is_optional": false, + "is_pinned": true, + "is_direct": true, + "resolved_package": {}, + "extra_data": {} + }, + { + "purl": "pkg:maven/com.journeyapps/zxing-android-embedded@4.3.0", + "extracted_requirement": "4.3.0", + "scope": "implementation", + "is_runtime": true, + "is_optional": false, + "is_pinned": true, + "is_direct": true, + "resolved_package": {}, + "extra_data": {} + }, + { + "purl": "pkg:maven/ch.acra/acra-mail@5.11.3", + "extracted_requirement": "5.11.3", + "scope": "implementation", + "is_runtime": true, + "is_optional": false, + "is_pinned": true, + "is_direct": true, + "resolved_package": {}, + "extra_data": {} + }, + { + "purl": "pkg:maven/ch.acra/acra-dialog@5.11.3", + "extracted_requirement": "5.11.3", + "scope": "implementation", + "is_runtime": true, + "is_optional": false, + "is_pinned": true, + "is_direct": true, + "resolved_package": {}, + "extra_data": {} + } + ], + "repository_homepage_url": null, + "repository_download_url": null, + "api_data_url": null, + "datasource_id": "build_gradle", + "purl": null + } +] \ No newline at end of file diff --git a/tests/packagedcode/data/build_gradle/groovy/fdroid-version-catalog/gradle/libs.versions.toml b/tests/packagedcode/data/build_gradle/groovy/fdroid-version-catalog/gradle/libs.versions.toml new file mode 100644 index 00000000000..de5fda7ba62 --- /dev/null +++ b/tests/packagedcode/data/build_gradle/groovy/fdroid-version-catalog/gradle/libs.versions.toml @@ -0,0 +1,12 @@ +[versions] +androidx-appcompat = "1.6.1" +androidx-preference = "1.2.1" +material = "1.9.0" +acra = "5.11.3" + +[libraries] +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } +androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "androidx-preference" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +acra-mail = { group = "ch.acra", name = "acra-mail", version.ref = "acra" } +acra-dialog = { group = "ch.acra", name = "acra-dialog", version.ref = "acra" } diff --git a/tests/packagedcode/test_build_gradle.py b/tests/packagedcode/test_build_gradle.py index 90f0fa750c9..0828b1aae49 100644 --- a/tests/packagedcode/test_build_gradle.py +++ b/tests/packagedcode/test_build_gradle.py @@ -50,6 +50,12 @@ def check_gradle_parse(location): class TestBuildGradleGroovy(PackageTester): test_data_dir = test_data_dir + def test_build_gradle_version_catalog_fdroid(self): + test_file = self.get_test_loc('build_gradle/groovy/fdroid-version-catalog/build.gradle') + expected_file = self.get_test_loc('build_gradle/groovy/fdroid-version-catalog/build.gradle-expected.json') + packages = build_gradle.BuildGradleHandler.parse(test_file) + self.check_packages_data(packages, expected_file, regen=REGEN_TEST_FIXTURES) + build_tests( test_dir=os.path.join(test_data_dir, 'build_gradle/groovy'), @@ -60,7 +66,6 @@ class TestBuildGradleGroovy(PackageTester): regen=REGEN_TEST_FIXTURES, ) - class TestBuildGradleKotlin(PackageTester): test_data_dir = test_data_dir