Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add requirements to setup.py codemod #144

Merged
merged 4 commits into from
Nov 22, 2023
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
4 changes: 4 additions & 0 deletions src/codemodder/codemods/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ def is_django_settings_file(file_path: Path):
return False


def is_setup_py_file(file_path: Path):
clavedeluna marked this conversation as resolved.
Show resolved Hide resolved
return file_path.name == "setup.py"


def get_call_name(call: cst.Call) -> str:
"""
Extracts the full name from a function call
Expand Down
2 changes: 1 addition & 1 deletion src/codemodder/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from codemodder.change import ChangeSet
from codemodder.dependency import Dependency
from codemodder.dependency_manager import DependencyManager
from codemodder.dependency_management import DependencyManager
from codemodder.executor import CodemodExecutorWrapper
from codemodder.file_context import FileContext
from codemodder.logging import logger, log_list
Expand Down
1 change: 1 addition & 0 deletions src/codemodder/dependency_management/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .dependency_manager import DependencyManager, Requirement
72 changes: 72 additions & 0 deletions src/codemodder/dependency_management/setup_py_codemod.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import libcst as cst
from libcst.codemod import CodemodContext
from libcst import matchers
from codemodder.codemods.api import BaseCodemod
from codemodder.codemods.base_codemod import ReviewGuidance
from codemodder.codemods.utils import is_setup_py_file
from codemodder.codemods.utils_mixin import NameResolutionMixin
from codemodder.file_context import FileContext
from packaging.requirements import Requirement


class SetupPyAddDependencies(BaseCodemod, NameResolutionMixin):
NAME = "setup-py-add-dependencies"
REVIEW_GUIDANCE = ReviewGuidance.MERGE_WITHOUT_REVIEW
SUMMARY = "Add Dependencies to `setup.py` `install_requires`"
DESCRIPTION = SUMMARY
REFERENCES: list = []

def __init__(
self,
codemod_context: CodemodContext,
file_context: FileContext,
dependencies: list[Requirement],
):
BaseCodemod.__init__(self, codemod_context, file_context)
NameResolutionMixin.__init__(self)
self.filename = self.file_context.file_path
self.dependencies = dependencies

def visit_Module(self, _: cst.Module) -> bool:
"""
Only visit module with this codemod if it's a setup.py file.
"""
return is_setup_py_file(self.filename)

def leave_Call(self, original_node: cst.Call, updated_node: cst.Call):
true_name = self.find_base_name(original_node.func)
if true_name != "setuptools.setup":
return original_node

new_args = self.replace_arg(original_node)
return self.update_arg_target(updated_node, new_args)

def replace_arg(self, original_node: cst.Call):
new_args = []
for arg in original_node.args:
if matchers.matches(
arg.keyword, matchers.Name("install_requires")
) and matchers.matches(arg.value, matchers.List()):
new = self.add_dependencies_to_arg(arg)
else:
new = arg
new_args.append(new)
return new_args

def add_dependencies_to_arg(self, arg: cst.Arg) -> cst.Arg:
new_dependencies = [
cst.Element(value=cst.SimpleString(value=f'"{str(dep)}"'))
for dep in self.dependencies
]
# TODO: detect if elements are separated by newline in source code.
return cst.Arg(
Copy link
Member

@drdavella drdavella Nov 21, 2023

Choose a reason for hiding this comment

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

I think we need to call self.add_change here (or maybe in the calling method) since the caller of this codemod will need to build a ChangeSet to report in CodeTF.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The purpose of this PR is to show we can take a setup.py file and update install_requires, without much else. This change will come in an upcoming PR when we merge with codemodder / dependency manager.

keyword=arg.keyword,
value=arg.value.with_changes(
elements=arg.value.elements + tuple(new_dependencies)
),
equal=arg.equal,
comma=arg.comma,
star=arg.star,
whitespace_after_star=arg.whitespace_after_star,
whitespace_after_arg=arg.whitespace_after_arg,
)
24 changes: 12 additions & 12 deletions tests/codemods/base_codemod_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ class BaseCodemodTest:
def setup_method(self):
self.file_context = None

def initialize_codemod(self, input_tree):
wrapper = cst.MetadataWrapper(input_tree)
codemod_instance = self.codemod(
CodemodContext(wrapper=wrapper),
self.file_context,
)
return codemod_instance

def run_and_assert(self, tmpdir, input_code, expected):
tmp_file_path = Path(tmpdir / "code.py")
self.run_and_assert_filepath(tmpdir, tmp_file_path, input_code, expected)
Expand All @@ -41,12 +49,8 @@ def run_and_assert_filepath(self, root, file_path, input_code, expected):
[],
[],
)
wrapper = cst.MetadataWrapper(input_tree)
command_instance = self.codemod(
CodemodContext(wrapper=wrapper),
self.file_context,
)
output_tree = command_instance.transform_module(input_tree)
codemod_instance = self.initialize_codemod(input_tree)
output_tree = codemod_instance.transform_module(input_tree)

assert output_tree.code == dedent(expected)

Expand Down Expand Up @@ -92,12 +96,8 @@ def run_and_assert_filepath(self, root, file_path, input_code, expected):
[],
results,
)
wrapper = cst.MetadataWrapper(input_tree)
command_instance = self.codemod(
CodemodContext(wrapper=wrapper),
self.file_context,
)
output_tree = command_instance.transform_module(input_tree)
codemod_instance = self.initialize_codemod(input_tree)
output_tree = codemod_instance.transform_module(input_tree)

assert output_tree.code == dedent(expected)

Expand Down
4 changes: 3 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ def disable_write_dependencies():
"""
Unit tests should not write any dependency files
"""
dm_write = mock.patch("codemodder.dependency_manager.DependencyManager.write")
dm_write = mock.patch(
"codemodder.dependency_management.dependency_manager.DependencyManager.write"
)

dm_write.start()
yield
Expand Down
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import pytest

from codemodder.dependency import DefusedXML, Security
from codemodder.dependency_manager import DependencyManager, Requirement
from codemodder.dependency_management import DependencyManager, Requirement


@pytest.fixture(autouse=True, scope="module")
Expand Down
218 changes: 218 additions & 0 deletions tests/dependency_management/test_setup_py_codemod.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import pytest
import libcst as cst
from codemodder.dependency_management.setup_py_codemod import SetupPyAddDependencies
from libcst.codemod import CodemodContext
from tests.codemods.base_codemod_test import BaseCodemodTest
from packaging.requirements import Requirement
from pathlib import Path

TEST_DEPENDENCIES = [Requirement("defusedxml==0.7.1"), Requirement("security~=1.2.0")]


class TestSetupPyCodemod(BaseCodemodTest):
codemod = SetupPyAddDependencies

def initialize_codemod(self, input_tree):
"""This codemod is initialized with different args than other codemods."""
wrapper = cst.MetadataWrapper(input_tree)
codemod_instance = self.codemod(
CodemodContext(wrapper=wrapper),
self.file_context,
dependencies=TEST_DEPENDENCIES,
)
return codemod_instance

def test_setup_call(self, tmpdir):
before = """
from setuptools import setup
setup(
name="test pkg",
description="testing",
long_description="...",
author="Pixee",
packages=find_packages("src"),
package_dir={"": "src"},
python_requires=">3.6",
install_requires=[
"protobuf>=3.12,<3.18; python_version < '3'",
"protobuf>=3.12,<4; python_version >= '3'",
"psutil>=5.7,<6",
"requests>=2.4.2,<3"
],
entry_points={},
)
"""

after = """
from setuptools import setup
setup(
name="test pkg",
description="testing",
long_description="...",
author="Pixee",
packages=find_packages("src"),
package_dir={"": "src"},
python_requires=">3.6",
install_requires=[
"protobuf>=3.12,<3.18; python_version < '3'",
"protobuf>=3.12,<4; python_version >= '3'",
"psutil>=5.7,<6",
"requests>=2.4.2,<3", "defusedxml==0.7.1", "security~=1.2.0"
],
entry_points={},
)
"""
tmp_file_path = Path(tmpdir / "setup.py")
self.run_and_assert_filepath(tmpdir, tmp_file_path, before, after)

def test_other_setup_func(self, tmpdir):
before = """
from something import setup
setup(
name="test pkg",
install_requires=[
"protobuf>=3.12,<3.18; python_version < '3'",
"protobuf>=3.12,<4; python_version >= '3'",
"psutil>=5.7,<6",
"requests>=2.4.2,<3"
],
entry_points={},
)
"""
tmp_file_path = Path(tmpdir / "setup.py")
self.run_and_assert_filepath(tmpdir, tmp_file_path, before, before)

def test_not_setup_file(self, tmpdir):
before = """
from setuptools import setup
setup(
name="test pkg",
description="testing",
long_description="...",
author="Pixee",
packages=find_packages("src"),
package_dir={"": "src"},
python_requires=">3.6",
install_requires=[
"protobuf>=3.12,<3.18; python_version < '3'",
"protobuf>=3.12,<4; python_version >= '3'",
"psutil>=5.7,<6",
"requests>=2.4.2,<3"
],
entry_points={},
)
"""
tmp_file_path = Path(tmpdir / "not-setup.py")
self.run_and_assert_filepath(tmpdir, tmp_file_path, before, before)

def test_setup_call_no_install_requires(self, tmpdir):
before = """
from setuptools import setup
setup(
name="test pkg",
description="testing",
long_description="...",
author="Pixee",
packages=find_packages("src"),
package_dir={"": "src"},
python_requires=">3.6",
)
"""
tmp_file_path = Path(tmpdir / "setup.py")
self.run_and_assert_filepath(tmpdir, tmp_file_path, before, before)

def test_setup_no_existing_requirements(self, tmpdir):
before = """
from setuptools import setup
setup(
name="test pkg",
description="testing",
long_description="...",
author="Pixee",
packages=find_packages("src"),
package_dir={"": "src"},
python_requires=">3.6",
install_requires=[],
entry_points={},
)
"""

after = """
from setuptools import setup
setup(
name="test pkg",
description="testing",
long_description="...",
author="Pixee",
packages=find_packages("src"),
package_dir={"": "src"},
python_requires=">3.6",
install_requires=["defusedxml==0.7.1", "security~=1.2.0"],
entry_points={},
)
"""
tmp_file_path = Path(tmpdir / "setup.py")
self.run_and_assert_filepath(tmpdir, tmp_file_path, before, after)

def test_setup_call_bad_install_requires(self, tmpdir):
before = """
from setuptools import setup
setup(
name="test pkg",
description="testing",
long_description="...",
author="Pixee",
packages=find_packages("src"),
package_dir={"": "src"},
python_requires=">3.6",
install_requires="some-package",
)
"""
tmp_file_path = Path(tmpdir / "setup.py")
self.run_and_assert_filepath(tmpdir, tmp_file_path, before, before)

@pytest.mark.skip("Need to add support.")
Copy link
Member

Choose a reason for hiding this comment

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

Obviously not for this ticket but I think this will definitely be possible for the simple cases.

def test_setup_call_requirements_separate(self, tmpdir):
before = """
from setuptools import setup
requirements = [
"protobuf>=3.12,<3.18; python_version < '3'",
"protobuf>=3.12,<4; python_version >= '3'",
"psutil>=5.7,<6",
"requests>=2.4.2,<3"
]
setup(
name="test pkg",
description="testing",
long_description="...",
author="Pixee",
packages=find_packages("src"),
package_dir={"": "src"},
python_requires=">3.6",
install_requires=requirements,
entry_points={},
)
"""

after = """
from setuptools import setup
requirements = [
"protobuf>=3.12,<3.18; python_version < '3'",
"protobuf>=3.12,<4; python_version >= '3'",
"psutil>=5.7,<6",
"requests>=2.4.2,<3", "defusedxml==0.7.1", "security~=1.2.0"
]
setup(
name="test pkg",
description="testing",
long_description="...",
author="Pixee",
packages=find_packages("src"),
package_dir={"": "src"},
python_requires=">3.6",
install_requires=requirements,
entry_points={},
)
"""
tmp_file_path = Path(tmpdir / "setup.py")
self.run_and_assert_filepath(tmpdir, tmp_file_path, before, after)
Loading