fix_android_dependencies.py (150 lines of code) (raw):
import json
import os
import re
# These will have to be updated manually over time, there's not an
# easy way to determine the latest version.
COMPILE_SDK_VERSION = 36
TARGET_SDK_VERSION = 36
BUILD_TOOLS_VERSION = '36.0.0'
# build.gradle(.kts) files
COMPILE_SDK_RE = r'compileSdk(?:Version)?\s*=?\s*[\w]+'
TARGET_SDK_RE = r'targetSdk(?:Version)?\s*=?\s*[\w]+'
BUILD_TOOLS_RE = r'buildTools(?:Version)?\s*=?\s*[\'\"\w\.]+'
# *.versions.toml files
VERSION_RE = r'(.*)\s*=\s*"(.*)"'
DEPENDENCY_RE = r'(group|module|name|version|version.ref|id)\s*='
# Depends on https://github.com/ben-manes/gradle-versions-plugin
#
# Must run this command:
# $ ./gradlew dependencyUpdates -Drevision=release -DoutputFormatter=json
RELATIVE_PATH_TO_JSON_REPORT = 'build/dependencyUpdates/report.json'
# Default Gradle Version Catalog location
RELATIVE_PATH_TO_TOML = 'gradle/libs.versions.toml'
def find_configuration_files():
"""Finds all build configuration files, recursively."""
gradle_files = []
for root, dirs, files in os.walk('.'):
for filename in files:
if filename.endswith(('build.gradle', 'build.gradle.kts', 'versions.toml')):
gradle_files.append(os.path.join(root, filename))
return gradle_files
def get_android_replacements():
"""Gets a dictionary of all android-specific replacements to be made."""
replacements = {}
compile_sdk = f"compileSdk = {COMPILE_SDK_VERSION}"
target_sdk = f"targetSdk = {TARGET_SDK_VERSION}"
build_tools_version = f"buildToolsVersion = \"{BUILD_TOOLS_VERSION}\""
replacements[COMPILE_SDK_RE] = compile_sdk
replacements[TARGET_SDK_RE] = target_sdk
replacements[BUILD_TOOLS_RE] = build_tools_version
return replacements
def is_major_update(old_version, new_version):
"""Compares version strings to see if it's a major update."""
old_major = old_version.split('.')[0]
new_major = new_version.split('.')[0]
return old_major != new_major
def get_dep_replacements(json_file, toml_deps):
"""Gets a dictionary of all dependency replacements to be made."""
replacements = {}
with open(json_file, 'r') as f:
json_data = json.loads(f.read())
outdated_deps = json_data['outdated']['dependencies']
for dep in outdated_deps:
group = dep['group']
name = dep['name']
curr_version = dep['version']
new_version = dep['available']['release']
# For dependencies and classhpaths
curr_dep = f"{group}:{name}:{curr_version}"
new_dep = f"{group}:{name}:{new_version}"
replacements[curr_dep] = new_dep
# For the plugins block in .kts files
curr_plugin = f'\("{group}"\) version "{curr_version}"'
new_plugin = f'("{group}") version "{new_version}"'
replacements[curr_plugin] = new_plugin
# For the TOML dependencies
module = group + ':' + name
if module not in toml_deps:
continue
curr_dep = toml_deps[module]
if 'original_line' in curr_dep:
original_line = curr_dep['original_line']
new_line = original_line.replace(curr_version, new_version)
replacements[original_line] = new_line
return replacements
def update_project(project_path, toml_path):
"""Runs through all build configuration files and performs replacements for individual android project."""
replacements = {}
replacements.update(get_android_replacements())
# Open the Gradle Version Catalog file and fetch its dependencies
toml_dependencies = get_toml_dependencies(toml_path)
replacements.update(get_dep_replacements(project_path, toml_dependencies))
# Print all updates found
print("Dependency updates:")
for (k, v) in iter(replacements.items()):
print(f"{k} --> {v}")
# Iterate through each file and replace it
for config_file in find_configuration_files():
print(f"Updating dependencies for: {config_file}")
new_data = ''
with open(config_file, 'r') as f:
# Perform each replacement
new_data = f.read()
for (k, v) in iter(replacements.items()):
new_data = re.sub(k, v, new_data)
# Write the file
with open(config_file, 'w') as f:
f.write(new_data)
def update_all():
"""Runs through all build configuration files and performs replacements."""
project_root = os.getcwd()
print(f"Repo root: {project_root}")
top_level_report = os.path.join(project_root, RELATIVE_PATH_TO_JSON_REPORT)
toml_path = os.path.join(project_root, RELATIVE_PATH_TO_TOML)
if os.path.exists(top_level_report):
print("Update dependencies via top-level report")
update_project(top_level_report, toml_path)
else:
print("Update dependencies via child-level report(s)")
first_level_subdirectories = get_immediate_subdirectories(project_root)
print(f"List of subdirectories: {first_level_subdirectories}")
for subdirectory in first_level_subdirectories:
print(f"subdirectory: {subdirectory}")
subdirectory_report = os.path.join(project_root, subdirectory, RELATIVE_PATH_TO_JSON_REPORT)
toml_path = os.path.join(project_root, subdirectory, RELATIVE_PATH_TO_TOML)
if os.path.exists(subdirectory_report):
print("\tUpdate dependencies in subdirectory")
update_project(subdirectory_report, toml_path)
else:
print("\tNo report in subdirectory")
def get_toml_dependency(line, versions):
original_line = line
# skip dependencies that don't specify a version
if 'version' not in line:
return {}
# Turn it into a valid JSON
line = re.sub(DEPENDENCY_RE, r'"\1" =', line)
value = line.split("=", 1)[1].replace("=", ":")
dep_json = json.loads(value)
# unspecified version means it's an internal dependency
# we can skip version bumps for those
if 'version' in dep_json and dep_json['version'] == 'unspecified':
return {}
# Fill in the group and name
if 'module' in dep_json:
module = dep_json.pop('module')
[group, name] = module.split(':')
dep_json.update({'group': group, 'name': name})
if 'id' in dep_json:
# 'id' indicates this is a plugin
dep_json['group'] = dep_json.pop('id')
dep_json['name'] = dep_json['group'] + '.gradle.plugin'
# Fill in the current version
if 'version.ref' in dep_json:
dep_json['original_line'] = versions[dep_json.pop('version.ref')]['original_line']
# if the version is inlined
if 'version' in dep_json:
dep_json['original_line'] = original_line
key = dep_json.pop('group') + ':' + dep_json.pop('name')
return {key: dep_json}
def get_toml_dependencies(toml_file):
"""Gets a dictionary of all TOML dependencies."""
# This code assumes the [versions] block will always be first
# True = read [versions] block; False = read [libraries] or [plugins] block
reading_versions = True
versions = {}
deps = {}
try:
with open(toml_file, 'r') as f:
lines = f.readlines()
for line in lines:
# skip empty lines or comments
if line.strip() == '' or line.startswith("#"):
continue
if '[versions]' in line:
reading_versions = True
continue
if '[libraries]' in line or '[plugins]' in line:
reading_versions = False
continue
# Versions
if reading_versions:
version_match = re.search(VERSION_RE, line)
if version_match:
key = version_match.group(1).strip()
value = version_match.group(2)
versions[key] = {'curr_version': value, 'original_line': line}
# Libraries and Plugins
else:
deps.update(get_toml_dependency(line, versions))
except FileNotFoundError:
print('This project does not contain a ' + RELATIVE_PATH_TO_TOML + ' file.')
return deps
def get_immediate_subdirectories(directory):
return [name for name in os.listdir(directory)
if os.path.isdir(os.path.join(directory, name)) and not name.startswith('.')]
if __name__ == '__main__':
update_all()