Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 98 additions & 4 deletions src/packagedcode/build_gradle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -86,7 +91,7 @@ def assign_package_to_resources(cls, package, resource, codebase, package_adder)

DEPENDENCY-4: {<NAME> <TEXT> <NAME-LABEL> <TEXT> <LIT-STRING> <PACKAGE-IDENTIFIER> <PACKAGE-IDENTIFIER> <OPERATOR>? <TEXT>}

DEPENDENCY-5: {<NAME> <TEXT> <NAME> <OPERATOR> <NAME-ATTRIBUTE>}
DEPENDENCY-VERSION-CATALOG: {<NAME> <TEXT> <NAME> <OPERATOR> <NAME-ATTRIBUTE> (<OPERATOR> <NAME-ATTRIBUTE>)*}

NESTED-DEPENDENCY-1: {<NAME> <OPERATOR> <DEPENDENCY-1>+ }
"""
Expand Down Expand Up @@ -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():
Expand All @@ -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):
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
]
Original file line number Diff line number Diff line change
@@ -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" }
7 changes: 6 additions & 1 deletion tests/packagedcode/test_build_gradle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -60,7 +66,6 @@ class TestBuildGradleGroovy(PackageTester):
regen=REGEN_TEST_FIXTURES,
)


class TestBuildGradleKotlin(PackageTester):
test_data_dir = test_data_dir

Expand Down