diff --git a/edx_repo_tools/__init__.py b/edx_repo_tools/__init__.py index ce1305bf..70397087 100644 --- a/edx_repo_tools/__init__.py +++ b/edx_repo_tools/__init__.py @@ -1 +1 @@ -__version__ = "4.0.0" +__version__ = "4.1.0" diff --git a/edx_repo_tools/find_dependencies/find_python_dependencies.py b/edx_repo_tools/find_dependencies/find_python_dependencies.py index 902f4e26..8f596826 100644 --- a/edx_repo_tools/find_dependencies/find_python_dependencies.py +++ b/edx_repo_tools/find_dependencies/find_python_dependencies.py @@ -8,6 +8,7 @@ import os import requirements import sys +import tomllib from pathlib import Path import requests @@ -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"] @@ -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', @@ -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)) diff --git a/tests/test_find_python_dependencies.py b/tests/test_find_python_dependencies.py new file mode 100644 index 00000000..49cd9528 --- /dev/null +++ b/tests/test_find_python_dependencies.py @@ -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)