Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import os
import platform
import re
from pathlib import Path

import typer

from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies
from cycode.cli.models import Document
from cycode.cli.utils.path_utils import get_path_from_context
from cycode.cli.utils.shell_executor import shell
from cycode.logger import get_logger

logger = get_logger('Gradle Restore')

BUILD_GRADLE_FILE_NAME = 'build.gradle'
BUILD_GRADLE_KTS_FILE_NAME = 'build.gradle.kts'
BUILD_GRADLE_DEP_TREE_FILE_NAME = 'gradle-dependencies-generated.txt'
ALL_PROJECTS_REGEX = r"[+-]{3} Project '(.*?)'"
PROJECT_LOCATION_REGEX = r"^project '(.+?)' - (.+?)\s*$"
CURRENT_PROJECT_REGEX = r"^Project '(.+?)'\s*$"
ROOT_PROJECT_PATH = ':'

GRADLE_EXECUTABLE = 'gradle'
GRADLEW_FILE_NAME = 'gradlew'
Expand All @@ -27,6 +34,7 @@ def __init__(
self.gradle_executable = self._resolve_gradle_executable()
if projects is None:
projects = set()
self.project_locations: dict[Path, str] = {}
self.projects = self.get_all_projects() if self.is_gradle_sub_projects() else projects

def _resolve_gradle_executable(self) -> str:
Expand All @@ -45,11 +53,13 @@ def is_project(self, document: Document) -> bool:
return document.path.endswith(BUILD_GRADLE_FILE_NAME) or document.path.endswith(BUILD_GRADLE_KTS_FILE_NAME)

def get_commands(self, manifest_file_path: str) -> list[list[str]]:
return (
self.get_commands_for_sub_projects(manifest_file_path)
if self.is_gradle_sub_projects()
else [[self.gradle_executable, 'dependencies', '-b', manifest_file_path, '-q', '--console', 'plain']]
)
if self.is_gradle_sub_projects():
return self.get_commands_for_sub_projects(manifest_file_path)

# Gradle finds the root settings file above it and selects the project whose projectDir
# is that directory.
manifest_dir = str(Path(manifest_file_path).parent)
return [[self.gradle_executable, '-p', manifest_dir, 'dependencies', '-q', '--console', 'plain']]

def get_lock_file_name(self) -> str:
return BUILD_GRADLE_DEP_TREE_FILE_NAME
Expand All @@ -61,21 +71,66 @@ def get_working_directory(self, document: Document) -> str | None:
return get_path_from_context(self.ctx) if self.is_gradle_sub_projects() else None

def get_all_projects(self) -> set[str]:
scan_root = get_path_from_context(self.ctx)
output = shell(
command=[self.gradle_executable, 'projects'],
timeout=self.command_timeout,
working_directory=get_path_from_context(self.ctx),
working_directory=scan_root,
)
if not output:
return set()

if scan_root:
self.project_locations = self._parse_project_locations(output, Path(scan_root))

return set(re.findall(ALL_PROJECTS_REGEX, output))

@staticmethod
def _parse_project_locations(output: str, scan_root: Path) -> dict[Path, str]:
"""Map each project directory to its Gradle project path, from the "Project locations" section."""
# A location starts with a separator ("/x/y"). Strip it, or pathlib treats it as an absolute path.
relative_dirs = {
project_path: Path(relative_dir.lstrip('/\\'))
for project_path, relative_dir in re.findall(PROJECT_LOCATION_REGEX, output, flags=re.MULTILINE)
}
if not relative_dirs:
return {}

build_root = RestoreGradleDependencies._find_build_root(output, scan_root, relative_dirs)
locations = {(build_root / relative_dir).resolve(): path for path, relative_dir in relative_dirs.items()}
locations[build_root] = ROOT_PROJECT_PATH
return locations

@staticmethod
def _find_build_root(output: str, scan_root: Path, relative_dirs: dict[str, Path]) -> Path:
build_root = scan_root.resolve()
current_project = re.search(CURRENT_PROJECT_REGEX, output, flags=re.MULTILINE)
if current_project and current_project.group(1) in relative_dirs:
for _ in relative_dirs[current_project.group(1)].parts:
build_root = build_root.parent

return build_root

def _get_sub_project_path(self, manifest_file_path: str) -> str | None:
manifest_dir = Path(manifest_file_path).parent
if self.project_locations:
return self.project_locations.get(manifest_dir.resolve())

scan_root = get_path_from_context(self.ctx)
if self.projects and scan_root and manifest_dir.resolve() == Path(scan_root).resolve():
return ROOT_PROJECT_PATH

project_path = f':{manifest_dir.name}'
return project_path if project_path in self.projects else None

def get_commands_for_sub_projects(self, manifest_file_path: str) -> list[list[str]]:
project_name = os.path.basename(os.path.dirname(manifest_file_path))
project_name = f':{project_name}'
return (
[[self.gradle_executable, f'{project_name}:dependencies', '-q', '--console', 'plain']]
if project_name in self.projects
else []
)
project_path = self._get_sub_project_path(manifest_file_path)
if project_path is None:
logger.debug(
'Manifest does not match a Gradle project, skipping restore, %s',
{'manifest_file_path': manifest_file_path},
)
return []

task = 'dependencies' if project_path == ROOT_PROJECT_PATH else f'{project_path}:dependencies'
return [[self.gradle_executable, task, '-q', '--console', 'plain']]
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch

Expand Down Expand Up @@ -80,9 +81,7 @@ def test_wrapper_is_threaded_into_get_commands(self, mock_ctx: typer.Context, tm
with patch(f'{_GRADLE_MODULE}.platform.system', return_value='Linux'):
restore = RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30)
commands = restore.get_commands(str(tmp_path / BUILD_GRADLE_FILE_NAME))
assert commands == [
[str(wrapper), 'dependencies', '-b', str(tmp_path / BUILD_GRADLE_FILE_NAME), '-q', '--console', 'plain']
]
assert commands == [[str(wrapper), '-p', str(tmp_path), 'dependencies', '-q', '--console', 'plain']]

def test_wrapper_is_threaded_into_sub_project_commands(self, tmp_path: Path) -> None:
wrapper = tmp_path / GRADLEW_FILE_NAME
Expand All @@ -104,6 +103,144 @@ def test_wrapper_is_threaded_into_sub_project_commands(self, tmp_path: Path) ->
assert commands == [[str(wrapper), ':module-a:dependencies', '-q', '--console', 'plain']]


class TestDefaultCommand:
def test_uses_project_dir_instead_of_build_file(
self, restore_gradle: RestoreGradleDependencies, tmp_path: Path
) -> None:
manifest = tmp_path / 'x' / 'y' / BUILD_GRADLE_KTS_FILE_NAME

commands = restore_gradle.get_commands(str(manifest))

assert commands == [
[GRADLE_EXECUTABLE, '-p', str(tmp_path / 'x' / 'y'), 'dependencies', '-q', '--console', 'plain']
]
assert '-b' not in commands[0]


# The settings file moves the projectDir of ':tenant-service-implementation' to
# components/services/tenant/implementation.
_PROJECTS_OUTPUT_WITH_LOCATIONS = """
Projects:

------------------------------------------------------------
Root project 'root'
------------------------------------------------------------

Location: /repo

Project hierarchy:

Root project 'root'
+--- Project ':a'
| \\--- Project ':a:b'
\\--- Project ':tenant-service-implementation'

Project locations:

project ':a' - /a
project ':a:b' - /a/b
project ':tenant-service-implementation' - /components/services/tenant/implementation

To see a list of the tasks of a project, run gradle <project-path>:tasks
"""

_PROJECTS_OUTPUT_WITHOUT_LOCATIONS = """
Root project 'root'
+--- Project ':a'
| \\--- Project ':a:b'
\\--- Project ':tenant-service-implementation'
"""


def _sub_projects_restore(tmp_path: Path, projects_output: str) -> RestoreGradleDependencies:
ctx = MagicMock(spec=typer.Context)
ctx.obj = {'monitor': False, 'gradle_all_sub_projects': True}
ctx.params = {'path': str(tmp_path)}
with patch(f'{_GRADLE_MODULE}.shell', return_value=projects_output):
return RestoreGradleDependencies(ctx, is_git_diff=False, command_timeout=30)


class TestSubProjectCommands:
def test_remapped_project_dir_uses_real_project_path(self, tmp_path: Path) -> None:
restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITH_LOCATIONS)
manifest = tmp_path / 'components' / 'services' / 'tenant' / 'implementation' / BUILD_GRADLE_FILE_NAME

assert restore.get_commands(str(manifest)) == [
[GRADLE_EXECUTABLE, ':tenant-service-implementation:dependencies', '-q', '--console', 'plain']
]

def test_nested_project_uses_full_path(self, tmp_path: Path) -> None:
restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITH_LOCATIONS)
manifest = tmp_path / 'a' / 'b' / BUILD_GRADLE_KTS_FILE_NAME

assert restore.get_commands(str(manifest)) == [
[GRADLE_EXECUTABLE, ':a:b:dependencies', '-q', '--console', 'plain']
]

def test_root_manifest_runs_root_dependencies(self, tmp_path: Path) -> None:
restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITH_LOCATIONS)

assert restore.get_commands(str(tmp_path / BUILD_GRADLE_FILE_NAME)) == [
[GRADLE_EXECUTABLE, 'dependencies', '-q', '--console', 'plain']
]

def test_manifest_outside_the_build_is_skipped(self, tmp_path: Path) -> None:
restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITH_LOCATIONS)

assert restore.get_commands(str(tmp_path / 'implementation' / BUILD_GRADLE_FILE_NAME)) == []

def test_relative_manifest_path_is_resolved(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITH_LOCATIONS)
monkeypatch.chdir(tmp_path / '..')

manifest = Path(tmp_path.name) / 'a' / BUILD_GRADLE_FILE_NAME
assert restore.get_commands(str(manifest)) == [
[GRADLE_EXECUTABLE, ':a:dependencies', '-q', '--console', 'plain']
]

def test_parses_crlf_line_endings(self, tmp_path: Path) -> None:
output = "Project locations:\r\n\r\nproject ':a:b' - /a/b\r\n"

locations = RestoreGradleDependencies._parse_project_locations(output, tmp_path)

assert locations == {tmp_path.resolve(): ':', (tmp_path / 'a' / 'b').resolve(): ':a:b'}

def test_scan_root_in_subproject_maps_from_build_root(self, tmp_path: Path) -> None:
output = (
"Project ':a'\n\nProject hierarchy:\n\nProject ':a'\n\\--- Project ':a:b'\n\n"
"Project locations:\n\nproject ':a' - /a\nproject ':a:b' - /a/b\n"
)
scan_root = tmp_path / 'a'

locations = RestoreGradleDependencies._parse_project_locations(output, scan_root)

assert locations == {
tmp_path.resolve(): ':',
scan_root.resolve(): ':a',
(scan_root / 'b').resolve(): ':a:b',
}

@pytest.mark.skipif(sys.platform != 'win32', reason='backslash is a path separator only on Windows')
def test_parses_windows_separators_case_insensitively(self, tmp_path: Path) -> None:
output = "Project locations:\r\n\r\nproject ':a:b' - \\A\\B\r\n"
restore = _sub_projects_restore(tmp_path, output)

assert restore._get_sub_project_path(str(tmp_path / 'a' / 'b' / BUILD_GRADLE_FILE_NAME)) == ':a:b'

def test_older_gradle_falls_back_to_directory_name(self, tmp_path: Path) -> None:
restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITHOUT_LOCATIONS)

assert restore.project_locations == {}
assert restore.get_commands(str(tmp_path / 'a' / BUILD_GRADLE_FILE_NAME)) == [
[GRADLE_EXECUTABLE, ':a:dependencies', '-q', '--console', 'plain']
]
assert restore.get_commands(str(tmp_path / BUILD_GRADLE_FILE_NAME)) == [
[GRADLE_EXECUTABLE, 'dependencies', '-q', '--console', 'plain']
]
remapped = tmp_path / 'components' / 'services' / 'tenant' / 'implementation' / BUILD_GRADLE_FILE_NAME
assert restore.get_commands(str(remapped)) == []


class TestCleanup:
def test_generated_dep_tree_file_is_deleted_after_restore(
self, restore_gradle: RestoreGradleDependencies, tmp_path: Path
Expand Down
Loading