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
2 changes: 1 addition & 1 deletion edx_repo_tools/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "4.0.0"
__version__ = "4.1.0"
55 changes: 44 additions & 11 deletions edx_repo_tools/find_dependencies/find_python_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import os
import requirements
import sys
import tomllib
from pathlib import Path
import requests

Expand All @@ -20,9 +21,9 @@ def request_package_info_url(package):
if response.status_code == 200:
data_dict = response.json()
info = data_dict["info"]
return info["home_page"]
return info["home_page"]
else:
print(f"Failed to retrieve data for package {package}. Status code:", response.status_code)
print(f"Failed to retrieve data for package {package}. Status code:", response.status_code)

FIRST_PARTY_ORGS = ["openedx"]

Expand All @@ -42,14 +43,47 @@ def urls_in_orgs(urls, orgs):
if any(f"/{org}/" in url for org in orgs)
)


def _names_from_uv_lock(data):
"""
Yield package names from a uv.lock's fully-resolved [[package]] list.
This covers the same direct+transitive dependency closure that a
pip-compile'd requirements.txt used to represent.
"""
for package in data.get("package", []):
name = package.get("name")
if name:
yield name


def iter_requirement_names(path):
"""
Yield package names declared in `path`, which may be a pip-compile style
requirements.txt or a uv.lock. Both represent a fully-resolved
direct+transitive dependency closure. A pyproject.toml is deliberately
not supported here: [project.dependencies]/[dependency-groups] only list
direct dependencies, so scanning it would silently miss transitive ones
and misleadingly suggest this tool resolves dependencies, which it does
not.
"""
path = Path(path)
if path.name == "uv.lock":
yield from _names_from_uv_lock(tomllib.loads(path.read_text()))
else:
with open(path) as freq:
for req in requirements.parse(freq):
yield req.name


@click.command()
@click.option(
'--req-file', 'directories',
multiple=True,
required=True,
help="The absolute file paths to locate Python dependencies"
"within a particular repository. You can provide this "
"option multiple times to include multiple requirement files.",
help="The absolute file paths to locate Python dependencies "
"within a particular repository. Accepts pip-compile style "
"requirements.txt files or uv.lock. You can "
"provide this option multiple times to include multiple files.",
)
@click.option(
'--ignore', 'ignore_paths',
Expand All @@ -60,16 +94,15 @@ def urls_in_orgs(urls, orgs):

def main(directories=None, ignore_paths=None):
"""
Analyze the requirements in input directory mentioned on the command line.
Analyze the requirements in input directory mentioned on the command line.
"""

home_page = set()
for directory in directories:
with open(directory) as fbase:
for req in requirements.parse(fbase):
url = request_package_info_url(req.name)
if url is not None:
home_page.add(url)
for name in set(iter_requirement_names(directory)):
url = request_package_info_url(name)
if url is not None:
home_page.add(url)

packages_urls = set(urls_in_orgs(home_page, SECOND_PARTY_ORGS))

Expand Down
66 changes: 66 additions & 0 deletions tests/test_find_python_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from unittest.mock import patch

from edx_repo_tools.find_dependencies.find_python_dependencies import (
iter_requirement_names,
main,
)


def test_iter_requirement_names_requirements_txt(tmp_path):
req_file = tmp_path / "base.txt"
req_file.write_text(
"Django==4.2.1\n"
"git+https://github.com/mitodl/edx-sga.git@abc123#egg=edx-sga\n"
)
assert sorted(iter_requirement_names(req_file)) == ["Django", "edx-sga"]


def test_iter_requirement_names_uv_lock(tmp_path):
uv_lock = tmp_path / "uv.lock"
uv_lock.write_text(
"""
version = 1

[[package]]
name = "lxml"
version = "5.3.2"

[[package]]
name = "edx-sga"
version = "0.1"
"""
)
assert sorted(iter_requirement_names(uv_lock)) == ["edx-sga", "lxml"]


def test_main_flags_second_party_dependency_from_uv_lock(tmp_path):
uv_lock = tmp_path / "uv.lock"
uv_lock.write_text(
"""
version = 1

[[package]]
name = "edx-sga"
version = "0.1"

[[package]]
name = "django"
version = "4.2.1"
"""
)

def fake_request_package_info_url(package):
return {
"edx-sga": "https://github.com/mitodl/edx-sga",
"django": "https://github.com/django/django",
}.get(package)

with patch(
"edx_repo_tools.find_dependencies.find_python_dependencies.request_package_info_url",
side_effect=fake_request_package_info_url,
):
with patch(
"edx_repo_tools.find_dependencies.find_python_dependencies.exit"
) as mock_exit:
main.callback(directories=[str(uv_lock)], ignore_paths=[])
mock_exit.assert_called_once_with(1)