Skip to content
Draft
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
Expand Up @@ -10,6 +10,7 @@
from collectoss.tasks.git.dependency_tasks.dependency_util import go_deps
from collectoss.tasks.git.dependency_tasks.dependency_util import kotlin_deps
from collectoss.tasks.git.dependency_tasks.dependency_util import rust_deps
from collectoss.tasks.git.dependency_tasks.dependency_util import r_deps
from collectoss.tasks.git.dependency_tasks.dependency_util import dependency_calculator

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[pylint] reported by reviewdog 🐶
W0611: Unused dependency_calculator imported from collectoss.tasks.git.dependency_tasks.dependency_util (unused-import)


#Returns generator iterable to tuples of modules and their names
Expand All @@ -26,6 +27,7 @@ def get_dependency_analysis_module_tuples():
yield go_deps, 'go'
yield kotlin_deps, 'kotlin'
yield rust_deps, 'rust'
yield r_deps, 'R'

class Dep:
def __init__(self, name, language, count):
Expand Down
118 changes: 118 additions & 0 deletions collectoss/tasks/git/dependency_tasks/dependency_util/r_deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import re
import json
from pathlib import Path


def get_files(path):
"""
Scans the directory for R dependency files.
We look specifically for 'DESCRIPTION' files and 'renv.lock' files.
"""
dir_path = Path(path)
files = []

# We look for both standard package metadata and renv lockfiles
files.extend(list(dir_path.glob('**/DESCRIPTION')))
files.extend(list(dir_path.glob('**/renv.lock')))

return files


def get_deps_for_file(path):
"""
Routes the file to the appropriate simple parser based on its name.
"""
path_obj = Path(path)

if path_obj.name == 'DESCRIPTION':
return get_deps_from_description(path)
elif path_obj.name == 'renv.lock':
return get_deps_from_renv_lock(path)

return []


def get_deps_from_description(path):
"""
Extracts dependencies from a standard R DESCRIPTION file.

We scan fields like Imports, Depends, Suggests, and others to find
external package requirements.
"""
try:
with open(path, 'r', encoding='utf-8') as f:
content = f.read()

dependencies = set()

# These are the standard sections where R packages list their requirements
dependency_fields = ['Imports', 'Depends', 'Suggests', 'Enhances', 'LinkingTo']

for field in dependency_fields:
# We use regex to grab the whole section for a field.
# It needs to handle potentially multi-line values.
pattern = rf'^{field}:\s*(.*?)(?=^\S|\Z)'
match = re.search(pattern, content, re.MULTILINE | re.DOTALL)

if match:
field_value = match.group(1)
packages = parse_r_package_list(field_value)
dependencies.update(packages)

# 'R' itself is often listed as a dependency, but we only care about libraries
dependencies.discard('R')

return list(dependencies)

except Exception:
# If something goes wrong parsing, we just return nothing for this file
return []


def parse_r_package_list(field_value):
"""
Cleans up the raw text from the file to get a nice list of package names.
"""
packages = set()

# Flatten the text to a single line to make splitting easier
field_value = ' '.join(field_value.split())

parts = field_value.split(',')

for part in parts:
part = part.strip()
if not part:
continue

# We need to grab the package name and ignore version numbers like (>= 1.0.0)
# Note: We are currently stripping version info because the CollectOSS dependency
# model only tracks package names. If that changes, we can capture versions here.
match = re.match(r'^([A-Za-z][A-Za-z0-9.]*)', part)
if match:
package_name = match.group(1).rstrip('.')
if package_name:
packages.add(package_name)

return packages


def get_deps_from_renv_lock(path):
"""
Parses an renv.lock file, which is just a JSON file listing specific package versions.
"""
try:
with open(path, 'r', encoding='utf-8') as f:
lockfile = json.load(f)

dependencies = set()

# renv.lock keeps everything under a 'Packages' key
if 'Packages' in lockfile and isinstance(lockfile['Packages'], dict):
for package_name in lockfile['Packages'].keys():
dependencies.add(package_name)

return list(dependencies)

except (json.JSONDecodeError, Exception):
return []
90 changes: 90 additions & 0 deletions tests/test_tasks/test_dependency_tasks/test_r_deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import json
import pytest

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[pylint] reported by reviewdog 🐶
W0611: Unused import pytest (unused-import)

from augur.tasks.git.dependency_tasks.dependency_util import r_deps

def test_description_file_parsing(tmp_path):
"""
Test that standard R DESCRIPTION files are parsed correctly,
extracting dependencies from Depends, Imports, Suggests, and LinkingTo.
"""
description_content = """Package: mypackage
Type: Package
Title: My Test Package
Version: 0.1.0
Author: Test Author
Maintainer: Test Author <test@example.com>
Description: A test package.
License: GPL-3
Encoding: UTF-8
LazyData: true
Depends:
R (>= 3.5.0),
dplyr (>= 1.0.0)
Imports:
ggplot2,
tidyr (>= 1.1.0),
stringr
Suggests:
testthat (>= 3.0.0),
knitr,
rmarkdown
LinkingTo:
Rcpp (>= 1.0.0)
"""

d_file = tmp_path / "DESCRIPTION"
d_file.write_text(description_content, encoding="utf-8")

deps = r_deps.get_deps_for_file(str(d_file))

expected_deps = {'dplyr', 'ggplot2', 'tidyr', 'stringr', 'testthat',
'knitr', 'rmarkdown', 'Rcpp'}

# Assert we found exactly the expected dependencies
assert set(deps) == expected_deps

def test_renv_lock_parsing(tmp_path):
"""
Test that renv.lock JSON files are parsed correctly.
"""
renv_lock_content = {
"R": {"Version": "4.3.0", "Repositories": [{"Name": "CRAN", "URL": "https://cran.rstudio.com"}]},
"Packages": {
"ggplot2": {"Package": "ggplot2", "Version": "3.4.0", "Source": "Repository", "Repository": "CRAN"},
"dplyr": {"Package": "dplyr", "Version": "1.1.0", "Source": "Repository", "Repository": "CRAN"},
"tidyr": {"Package": "tidyr", "Version": "1.3.0", "Source": "Repository", "Repository": "CRAN"},
"rmarkdown": {"Package": "rmarkdown", "Version": "2.20", "Source": "Repository", "Repository": "CRAN"}
}
}

l_file = tmp_path / "renv.lock"
measure = l_file.write_text(json.dumps(renv_lock_content), encoding="utf-8")

deps = r_deps.get_deps_for_file(str(l_file))

expected_deps = {'ggplot2', 'dplyr', 'tidyr', 'rmarkdown'}

assert set(deps) == expected_deps

def test_file_discovery(tmp_path):
"""
Test that the tool finds DESCRIPTION and renv.lock files recursively.
"""
# Create a nested directory structure
(tmp_path / 'DESCRIPTION').touch()
(tmp_path / 'renv.lock').touch()

subdir = tmp_path / 'subdir'
subdir.mkdir()
(subdir / 'DESCRIPTION').touch()

nested = tmp_path / 'another' / 'nested'
nested.mkdir(parents=True)
(nested / 'renv.lock').touch()

# Run the discovery
files = r_deps.get_files(str(tmp_path))
file_names = [f.name for f in files]

assert file_names.count('DESCRIPTION') == 2
assert file_names.count('renv.lock') == 2
Loading