diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2fb24311..c55a826e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,6 +9,7 @@ repos: exclude: | (?x)^( src/core_codemods/docs/.*| + src/codemodder/dependency.py | integration_tests/.*| tests/test_codemodder.py )$ diff --git a/src/codemodder/context.py b/src/codemodder/context.py index 6395b99a..6f8b5e26 100644 --- a/src/codemodder/context.py +++ b/src/codemodder/context.py @@ -5,7 +5,11 @@ from typing import List, Iterator from codemodder.change import ChangeSet -from codemodder.dependency import Dependency +from codemodder.dependency import ( + Dependency, + build_dependency_notification, + build_failed_dependency_notification, +) from codemodder.executor import CodemodExecutorWrapper from codemodder.file_context import FileContext from codemodder.logging import logger, log_list @@ -15,22 +19,10 @@ from codemodder.utils.timer import Timer -DEPENDENCY_NOTIFICATION = """``` -💡 This codemod adds a dependency to your project. \ -Currently we add the dependency to a file named `requirements.txt` if it \ -exists in your project. - -There are a number of other places where Python project dependencies can be \ -expressed, including `setup.py`, `pyproject.toml`, and `setup.cfg`. We are \ -working on adding support for these files, but for now you may need to update \ -these files manually before accepting this change. -``` -""" - - class CodemodExecutionContext: # pylint: disable=too-many-instance-attributes _results_by_codemod: dict[str, list[ChangeSet]] = {} _failures_by_codemod: dict[str, list[Path]] = {} + _dependency_update_by_codemod: dict[str, PackageStore | None] = {} dependencies: dict[str, set[Dependency]] = {} directory: Path dry_run: bool = False @@ -107,6 +99,7 @@ def process_dependencies( "unable to write dependencies for %s: no dependency file found", codemod_id, ) + self._dependency_update_by_codemod[codemod_id] = None return record # pylint: disable-next=cyclic-import @@ -116,6 +109,7 @@ def process_dependencies( dm = DependencyManager(package_store, self.directory) if (changeset := dm.write(list(dependencies), self.dry_run)) is not None: self.add_results(codemod_id, [changeset]) + self._dependency_update_by_codemod[codemod_id] = package_store for dep in dependencies: record[dep] = package_store break @@ -124,8 +118,13 @@ def process_dependencies( def add_description(self, codemod: CodemodExecutorWrapper): description = codemod.description - if codemod.adds_dependency: - description = f"{description}\n\n{DEPENDENCY_NOTIFICATION}" + if dependencies := list(self.dependencies.get(codemod.id, [])): + if pkg_store := self._dependency_update_by_codemod.get(codemod.id): + description += build_dependency_notification( + pkg_store.type.value, dependencies[0] + ) + else: + description += build_failed_dependency_notification(dependencies[0]) return description diff --git a/src/codemodder/dependency.py b/src/codemodder/dependency.py index 55d7af0a..e4ba6644 100644 --- a/src/codemodder/dependency.py +++ b/src/codemodder/dependency.py @@ -61,3 +61,71 @@ def __hash__(self): oss_link="https://github.com/pixee/python-security", package_link="https://pypi.org/project/security/", ) + + +DEPENDENCY_NOTIFICATION = """ +## Dependency Updates + +This codemod relies on an external dependency. We have automatically added this dependency to your project's `{filename}` file. + +{description} + +There are a number of places where Python project dependencies can be expressed, including `setup.py`, `pyproject.toml`, `setup.cfg`, and `requirements.txt` files. If this change is incorrect, or if you are using another packaging system such as `poetry`, it may be necessary for you to manually add the dependency to the proper location in your project. +""" + +FAILED_DEPENDENCY_NOTIFICATION = """ +## Dependency Updates + +This codemod relies on an external dependency. However, we were unable to automatically add the dependency to your project. + +{description} + +There are a number of places where Python project dependencies can be expressed, including `setup.py`, `pyproject.toml`, `setup.cfg`, and `requirements.txt` files. You may need to manually add this dependency to the proper location in your project. + +### Manual Installation + +For `setup.py`: +```diff + install_requires=[ ++ "{requirement}", + ], +``` + +For `pyproject.toml` (using `setuptools`): +```diff +[project] +dependencies = [ ++ "{requirement}", +] +``` + +For `setup.cfg`: +```diff +[options] +install_requires = ++ {requirement} +``` + +For `requirements.txt`: +```diff ++{requirement} +``` + +For more information on adding dependencies to `setuptools` projects, see [the setuptools documentation](https://setuptools.pypa.io/en/latest/userguide/dependency_management.html#declaring-required-dependency). + +If you are using another build system, please refer to the documentation for that system to determine how to add dependencies. +""" + + +def build_dependency_notification(filename: str, dependency: Dependency) -> str: + return DEPENDENCY_NOTIFICATION.format( + filename=filename, + description=dependency.description, + ) + + +def build_failed_dependency_notification(dependency: Dependency) -> str: + return FAILED_DEPENDENCY_NOTIFICATION.format( + description=dependency.description, + requirement=dependency.requirement, + ) diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 00000000..1c10707f --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,65 @@ +from codemodder.context import CodemodExecutionContext as Context +from codemodder.dependency import Security +from codemodder.registry import load_registered_codemods +from codemodder.project_analysis.python_repo_manager import PythonRepoManager + + +class TestContext: + def test_successful_dependency_description(self, mocker): + registry = load_registered_codemods() + repo_manager = PythonRepoManager(mocker.Mock()) + codemod = registry.match_codemods(codemod_include=["url-sandbox"])[0] + + context = Context(mocker.Mock(), True, False, registry, repo_manager) + context.add_dependencies(codemod.id, {Security}) + + pkg_store_name = "pyproject.toml" + + pkg_store = mocker.Mock() + pkg_store.type.value = pkg_store_name + mocker.patch( + "codemodder.project_analysis.file_parsers.base_parser.BaseParser.parse", + return_value=[pkg_store], + ) + + context.process_dependencies(codemod.id) + description = context.add_description(codemod) + + assert description.startswith(codemod.description) + assert "## Dependency Updates\n" in description + assert ( + f"We have automatically added this dependency to your project's `{pkg_store_name}` file." + in description + ) + assert Security.description in description + assert "### Manual Installation\n" not in description + + def test_failed_dependency_description(self, mocker): + registry = load_registered_codemods() + repo_manager = PythonRepoManager(mocker.Mock()) + codemod = registry.match_codemods(codemod_include=["url-sandbox"])[0] + + context = Context(mocker.Mock(), True, False, registry, repo_manager) + context.add_dependencies(codemod.id, {Security}) + + mocker.patch( + "codemodder.project_analysis.python_repo_manager.PythonRepoManager._parse_all_stores", + return_value=[], + ) + + context.process_dependencies(codemod.id) + description = context.add_description(codemod) + + assert description.startswith(codemod.description) + assert "## Dependency Updates\n" in description + assert Security.description in description + assert "### Manual Installation\n" in description + assert ( + f"""For `setup.py`: +```diff + install_requires=[ ++ "{Security.requirement}", + ], +```""" + in description + )