diff --git a/integration_tests/base_test.py b/integration_tests/base_test.py index 21ed2e0f..e4e60d77 100644 --- a/integration_tests/base_test.py +++ b/integration_tests/base_test.py @@ -101,7 +101,7 @@ def _assert_results_fields(self, results, output_path): assert len(change["changes"]) == self.num_changes line_change = change["changes"][0] - assert line_change["lineNumber"] == self.expected_line_change + assert line_change["lineNumber"] == str(self.expected_line_change) assert line_change["description"] == self.change_description assert line_change["packageActions"] == [] assert line_change["properties"] == {} diff --git a/integration_tests/test_process_sandbox.py b/integration_tests/test_process_sandbox.py index 707bdf89..15b6a342 100644 --- a/integration_tests/test_process_sandbox.py +++ b/integration_tests/test_process_sandbox.py @@ -26,4 +26,4 @@ class TestProcessSandbox(BaseIntegrationTest): requirements_path = "tests/samples/requirements.txt" original_requirements = "# file used to test dependency management\nrequests==2.31.0\nblack==23.7.*\nmypy~=1.4\npylint>1\n" - expected_new_reqs = "# file used to test dependency management\nrequests==2.31.0\nblack==23.7.*\nmypy~=1.4\npylint>1\nsecurity==1.0.1" + expected_new_reqs = "# file used to test dependency management\nrequests==2.31.0\nblack==23.7.*\nmypy~=1.4\npylint>1\nsecurity~=1.2.0" diff --git a/integration_tests/test_url_sandbox.py b/integration_tests/test_url_sandbox.py index b9014038..5db119cf 100644 --- a/integration_tests/test_url_sandbox.py +++ b/integration_tests/test_url_sandbox.py @@ -23,4 +23,4 @@ class TestUrlSandbox(BaseIntegrationTest): requirements_path = "tests/samples/requirements.txt" original_requirements = "# file used to test dependency management\nrequests==2.31.0\nblack==23.7.*\nmypy~=1.4\npylint>1\n" - expected_new_reqs = "# file used to test dependency management\nrequests==2.31.0\nblack==23.7.*\nmypy~=1.4\npylint>1\nsecurity==1.0.1" + expected_new_reqs = "# file used to test dependency management\nrequests==2.31.0\nblack==23.7.*\nmypy~=1.4\npylint>1\nsecurity~=1.2.0" diff --git a/src/codemodder/change.py b/src/codemodder/change.py index 4429f5d5..fc4fffc1 100644 --- a/src/codemodder/change.py +++ b/src/codemodder/change.py @@ -1,19 +1,46 @@ from dataclasses import dataclass, field +from enum import Enum + + +class Action(Enum): + ADD = "add" + REMOVE = "remove" + + +class Result(Enum): + COMPLETED = "completed" + FAILED = "failed" + SKIPPED = "skipped" + + +@dataclass +class PackageAction: + action: Action + result: Result + package: str + + def to_json(self): + return { + "action": self.action.value, + "result": self.result.value, + "package": self.package, + } @dataclass class Change: - lineNumber: str + lineNumber: int description: str properties: dict = field(default_factory=dict) - packageActions: list = field(default_factory=list) + packageActions: list[PackageAction] = field(default_factory=list) def to_json(self): return { - "lineNumber": self.lineNumber, + # Not sure why this is a string but it's in the spec + "lineNumber": str(self.lineNumber), "description": self.description, "properties": self.properties, - "packageActions": self.packageActions, + "packageActions": [pa.to_json() for pa in self.packageActions], } @@ -26,4 +53,8 @@ class ChangeSet: changes: list[Change] def to_json(self): - return {"path": self.path, "diff": self.diff, "changes": self.changes} + return { + "path": self.path, + "diff": self.diff, + "changes": [x.to_json() for x in self.changes], + } diff --git a/src/codemodder/codemods/api/__init__.py b/src/codemodder/codemods/api/__init__.py index c8a9a661..c1e04d4e 100644 --- a/src/codemodder/codemods/api/__init__.py +++ b/src/codemodder/codemods/api/__init__.py @@ -96,7 +96,7 @@ def __init__( def report_change(self, original_node): line_number = self.lineno_for_node(original_node) self.file_context.codemod_changes.append( - Change(str(line_number), self.CHANGE_DESCRIPTION).to_json() + Change(line_number, self.CHANGE_DESCRIPTION) ) diff --git a/src/codemodder/codemods/base_codemod.py b/src/codemodder/codemods/base_codemod.py index 53579fd0..a504a6f9 100644 --- a/src/codemodder/codemods/base_codemod.py +++ b/src/codemodder/codemods/base_codemod.py @@ -6,6 +6,7 @@ from codemodder.change import Change from codemodder.context import CodemodExecutionContext +from codemodder.dependency import Dependency from codemodder.file_context import FileContext from codemodder.semgrep import run as semgrep_run @@ -43,7 +44,8 @@ class BaseCodemod: # Implementation borrowed from https://stackoverflow.com/a/45250114 METADATA: ClassVar[CodemodMetadata] = NotImplemented SUMMARY: ClassVar[str] = NotImplemented - is_semgrep = False + is_semgrep: bool = False + adds_dependency: bool = False execution_context: CodemodExecutionContext file_context: FileContext @@ -85,7 +87,7 @@ def add_change_from_position(self, position: CodeRange, description): Change( lineNumber=position.start.line, description=description, - ).to_json() + ) ) def lineno_for_node(self, node): @@ -99,7 +101,7 @@ def line_exclude(self): def line_include(self): return self.file_context.line_include - def add_dependency(self, dependency: str): + def add_dependency(self, dependency: Dependency): self.file_context.add_dependency(dependency) diff --git a/src/codemodder/codemods/imported_call_modifier.py b/src/codemodder/codemods/imported_call_modifier.py index b27c87b5..9fe4794b 100644 --- a/src/codemodder/codemods/imported_call_modifier.py +++ b/src/codemodder/codemods/imported_call_modifier.py @@ -35,7 +35,7 @@ def __init__( self.line_include = file_context.line_include self.matching_functions: FunctionMatchType = matching_functions self.change_description = change_description - self.changes_in_file: list[Mapping] = [] + self.changes_in_file: list[Change] = [] def updated_args(self, original_args: Sequence[cst.Arg]): return original_args @@ -71,7 +71,7 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call): and true_name in self.matching_functions ): self.changes_in_file.append( - Change(str(line_number), self.change_description).to_json() + Change(line_number, self.change_description) ) new_args = self.updated_args(updated_node.args) diff --git a/src/codemodder/context.py b/src/codemodder/context.py index 9b88e8a7..07ddec6e 100644 --- a/src/codemodder/context.py +++ b/src/codemodder/context.py @@ -4,16 +4,30 @@ from textwrap import indent from codemodder.change import ChangeSet +from codemodder.dependency import Dependency from codemodder.dependency_manager import DependencyManager from codemodder.executor import CodemodExecutorWrapper from codemodder.logging import logger, log_list from codemodder.registry import CodemodRegistry +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]] = {} - dependencies: dict[str, set[str]] = {} + dependencies: dict[str, set[Dependency]] = {} directory: Path dry_run: bool = False verbose: bool = False @@ -40,7 +54,7 @@ def add_result(self, codemod_name, change_set): def add_failure(self, codemod_name, file_path): self._failures_by_codemod.setdefault(codemod_name, []).append(file_path) - def add_dependencies(self, codemod_id: str, dependencies: set[str]): + def add_dependencies(self, codemod_id: str, dependencies: set[Dependency]): self.dependencies.setdefault(codemod_id, set()).update(dependencies) def get_results(self, codemod_name): @@ -80,13 +94,20 @@ def process_dependencies(self, codemod_id: str): if (changeset := dm.write(self.dry_run)) is not None: self.add_result(codemod_id, changeset) + def add_description(self, codemod: CodemodExecutorWrapper): + description = codemod.description + if codemod.adds_dependency: + description = f"{description}\n\n{DEPENDENCY_NOTIFICATION}" + + return description + def compile_results(self, codemods: list[CodemodExecutorWrapper]): results = [] for codemod in codemods: data = { "codemod": codemod.id, "summary": codemod.summary, - "description": codemod.description, + "description": self.add_description(codemod), "references": codemod.references, "properties": {}, "failedFiles": [str(file) for file in self.get_failures(codemod.id)], diff --git a/src/codemodder/dependency.py b/src/codemodder/dependency.py new file mode 100644 index 00000000..55d7af0a --- /dev/null +++ b/src/codemodder/dependency.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass + +from packaging.requirements import Requirement + + +@dataclass +class License: + name: str + url: str + + +@dataclass +class Dependency: + requirement: Requirement + description: str + _license: License + oss_link: str + package_link: str + + @property + def name(self) -> str: + return self.requirement.name + + @property + def version(self) -> str: + return self.requirement.specifier.__str__().strip() + + def build_description(self) -> str: + return f"""{self.description} + +License: [{self._license.name}]({self._license.url}) ✅ \ +[Open Source]({self.oss_link}) ✅ \ +[More facts]({self.package_link}) +""" + + def __hash__(self): + return hash(self.requirement) + + +DefusedXML = Dependency( + Requirement("defusedxml~=0.7.1"), + description="""\ +This package is [recommended by the Python community](https://docs.python.org/3/library/xml.html#the-defusedxml-package) \ +to protect against XML vulnerabilities.\ +""", + _license=License( + "PSF-2.0", + "https://opensource.org/license/python-2-0/", + ), + oss_link="https://github.com/tiran/defusedxml", + package_link="https://pypi.org/project/defusedxml/", +) + +Security = Dependency( + Requirement("security~=1.2.0"), + description="""This library holds security tools for protecting Python API calls.""", + _license=License( + "MIT", + "https://opensource.org/license/MIT/", + ), + oss_link="https://github.com/pixee/python-security", + package_link="https://pypi.org/project/security/", +) diff --git a/src/codemodder/dependency_manager.py b/src/codemodder/dependency_manager.py index 90a5aaf2..379d05c7 100644 --- a/src/codemodder/dependency_manager.py +++ b/src/codemodder/dependency_manager.py @@ -5,13 +5,14 @@ import difflib from packaging.requirements import Requirement -from codemodder.change import ChangeSet +from codemodder.change import Action, Change, ChangeSet, PackageAction, Result +from codemodder.dependency import Dependency class DependencyManager: parent_directory: Path _lines: list[str] - _new_requirements: list[str] + _new_requirements: list[Dependency] def __init__(self, parent_directory: Path): """One-time class initialization.""" @@ -20,13 +21,16 @@ def __init__(self, parent_directory: Path): self._lines = [] self._new_requirements = [] - def add(self, dependencies: list[str]): + @property + def new_requirements(self) -> list[str]: + return [str(x.requirement) for x in self._new_requirements] + + def add(self, dependencies: list[Dependency]): """add any number of dependencies to the end of list of dependencies.""" - for dep_str in dependencies: - dep = Requirement(dep_str) + for dep in dependencies: if dep not in self.dependencies: - self.dependencies.update({dep: None}) - self._new_requirements.append(str(dep)) + self.dependencies.update({dep.requirement: None}) + self._new_requirements.append(dep) def write(self, dry_run: bool = False) -> Optional[ChangeSet]: """ @@ -35,19 +39,29 @@ def write(self, dry_run: bool = False) -> Optional[ChangeSet]: if not (self.dependency_file and self._new_requirements): return None - updated = self._lines + self._new_requirements + ["\n"] + updated = self._lines + self.new_requirements + ["\n"] diff = "".join(difflib.unified_diff(self._lines, updated)) - # TODO: add a change entry for each new requirement - # TODO: make sure to set the contextual_description=True in the properties bag + + changes = [ + Change( + lineNumber=len(self._lines) + i + 1, + description=dep.build_description(), + properties={"contextual_description": True}, + packageActions=[ + PackageAction(Action.ADD, Result.COMPLETED, str(dep.requirement)) + ], + ) + for i, dep in enumerate(self._new_requirements) + ] if not dry_run: with open(self.dependency_file, "w", encoding="utf-8") as f: f.writelines(self._lines) - f.writelines(self._new_requirements) + f.writelines(self.new_requirements) self.dependency_file_changed = True - return ChangeSet(str(self.dependency_file), diff, changes=[]) + return ChangeSet(str(self.dependency_file), diff, changes=changes) @property def found_dependency_file(self) -> bool: diff --git a/src/codemodder/file_context.py b/src/codemodder/file_context.py index 3b6641b4..103182c8 100644 --- a/src/codemodder/file_context.py +++ b/src/codemodder/file_context.py @@ -2,6 +2,9 @@ from pathlib import Path from typing import Dict, List +from codemodder.change import Change +from codemodder.dependency import Dependency + @dataclass class FileContext: @@ -10,17 +13,11 @@ class FileContext: """ file_path: Path - line_exclude: List[int] - line_include: List[int] - results_by_id: Dict - dependencies: set[str] = field(default_factory=set) - - def __post_init__(self): - if self.line_include is None: - self.line_include = [] - if self.line_exclude is None: - self.line_exclude = [] - self.codemod_changes = [] + line_exclude: List[int] = field(default_factory=list) + line_include: List[int] = field(default_factory=list) + results_by_id: Dict = field(default_factory=dict) + dependencies: set[Dependency] = field(default_factory=set) + codemod_changes: List[Change] = field(default_factory=list) - def add_dependency(self, dependency: str): + def add_dependency(self, dependency: Dependency): self.dependencies.add(dependency) diff --git a/src/core_codemods/django_debug_flag_on.py b/src/core_codemods/django_debug_flag_on.py index e435f9ee..0d18c36a 100644 --- a/src/core_codemods/django_debug_flag_on.py +++ b/src/core_codemods/django_debug_flag_on.py @@ -74,7 +74,7 @@ def leave_Assign( ) and self.filter_by_path_includes_or_excludes(pos_to_match): line_number = pos_to_match.start.line self.changes_in_file.append( - Change(str(line_number), DjangoDebugFlagOn.CHANGE_DESCRIPTION).to_json() + Change(line_number, DjangoDebugFlagOn.CHANGE_DESCRIPTION) ) return updated_node.with_changes(value=cst.Name("False")) return updated_node diff --git a/src/core_codemods/django_session_cookie_secure_off.py b/src/core_codemods/django_session_cookie_secure_off.py index 26e5f9c6..c5abdf69 100644 --- a/src/core_codemods/django_session_cookie_secure_off.py +++ b/src/core_codemods/django_session_cookie_secure_off.py @@ -79,9 +79,7 @@ def leave_Module( pos_to_match = self.node_position(original_node) line_number = pos_to_match.end.line self.changes_in_file.append( - Change( - str(line_number), DjangoSessionCookieSecureOff.CHANGE_DESCRIPTION - ).to_json() + Change(line_number, DjangoSessionCookieSecureOff.CHANGE_DESCRIPTION) ) final_line = cst.parse_statement("SESSION_COOKIE_SECURE = True") new_body = updated_node.body + (final_line,) @@ -104,9 +102,7 @@ def leave_Assign( # SESSION_COOKIE_SECURE = anything other than True line_number = pos_to_match.start.line self.changes_in_file.append( - Change( - str(line_number), DjangoSessionCookieSecureOff.CHANGE_DESCRIPTION - ).to_json() + Change(line_number, DjangoSessionCookieSecureOff.CHANGE_DESCRIPTION) ) return updated_node.with_changes(value=cst.Name("True")) return updated_node diff --git a/src/core_codemods/order_imports.py b/src/core_codemods/order_imports.py index df34326a..dffed31c 100644 --- a/src/core_codemods/order_imports.py +++ b/src/core_codemods/order_imports.py @@ -53,7 +53,7 @@ def transform_module_impl(self, tree: cst.Module) -> cst.Module: top_imports_visitor.top_imports_blocks[i][0] ).start.line self.file_context.codemod_changes.append( - Change(str(line_number), self.CHANGE_DESCRIPTION).to_json() + Change(line_number, self.CHANGE_DESCRIPTION) ) return result_tree return tree diff --git a/src/core_codemods/process_creation_sandbox.py b/src/core_codemods/process_creation_sandbox.py index ed0a4d7b..fda2b451 100644 --- a/src/core_codemods/process_creation_sandbox.py +++ b/src/core_codemods/process_creation_sandbox.py @@ -1,6 +1,7 @@ import libcst as cst from codemodder.codemods.base_codemod import ReviewGuidance from codemodder.codemods.api import SemgrepCodemod +from codemodder.dependency import Security class ProcessSandbox(SemgrepCodemod): @@ -21,6 +22,8 @@ class ProcessSandbox(SemgrepCodemod): }, ] + adds_dependency = True + @classmethod def rule(cls): return """ @@ -40,7 +43,7 @@ def rule(cls): def on_result_found(self, original_node, updated_node): self.add_needed_import("security", "safe_command") - self.add_dependency("security==1.0.1") + self.add_dependency(Security) return self.update_call_target( updated_node, "safe_command", diff --git a/src/core_codemods/remove_unused_imports.py b/src/core_codemods/remove_unused_imports.py index 61414836..6d3c43c1 100644 --- a/src/core_codemods/remove_unused_imports.py +++ b/src/core_codemods/remove_unused_imports.py @@ -54,7 +54,7 @@ def transform_module_impl(self, tree: cst.Module) -> cst.Module: if self.filter_by_path_includes_or_excludes(pos): if not self._is_disabled_by_linter(importt): self.file_context.codemod_changes.append( - Change(pos.start.line, self.CHANGE_DESCRIPTION).to_json() + Change(pos.start.line, self.CHANGE_DESCRIPTION) ) filtered_unused_imports.add((import_alias, importt)) return tree.visit(RemoveUnusedImportsTransformer(filtered_unused_imports)) diff --git a/src/core_codemods/upgrade_sslcontext_tls.py b/src/core_codemods/upgrade_sslcontext_tls.py index a51a430a..53235cd0 100644 --- a/src/core_codemods/upgrade_sslcontext_tls.py +++ b/src/core_codemods/upgrade_sslcontext_tls.py @@ -71,7 +71,7 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Arg): ) and self.filter_by_path_includes_or_excludes(pos_to_match): line_number = pos_to_match.start.line self.file_context.codemod_changes.append( - Change(str(line_number), self.CHANGE_DESCRIPTION).to_json() + Change(line_number, self.CHANGE_DESCRIPTION) ) return updated_node.with_changes( diff --git a/src/core_codemods/url_sandbox.py b/src/core_codemods/url_sandbox.py index 35be3ea8..45c078be 100644 --- a/src/core_codemods/url_sandbox.py +++ b/src/core_codemods/url_sandbox.py @@ -17,8 +17,8 @@ from codemodder.codemods.transformations.remove_unused_imports import ( RemoveUnusedImportsCodemod, ) +from codemodder.dependency import Security -replacement_package = "security" replacement_import = "safe_requests" @@ -57,6 +57,8 @@ class UrlSandbox(SemgrepCodemod, Codemod): METADATA_DEPENDENCIES = (PositionProvider, ScopeProvider) + adds_dependency = True + def __init__(self, codemod_context: CodemodContext, *args): Codemod.__init__(self, codemod_context) SemgrepCodemod.__init__(self, *args) @@ -72,7 +74,7 @@ def transform_module_impl(self, tree: cst.Module) -> cst.Module: find_requests_visitor.changes_in_file ) new_tree = tree.visit(ReplaceNodes(find_requests_visitor.nodes_to_change)) - self.add_dependency("security==1.0.1") + self.add_dependency(Security) # if it finds any request.get(...), try to remove the imports if any( ( @@ -82,7 +84,7 @@ def transform_module_impl(self, tree: cst.Module) -> cst.Module: ): new_tree = AddImportsVisitor( self.context, - [ImportItem(replacement_package, replacement_import, None, 0)], + [ImportItem(Security.name, replacement_import, None, 0)], ).transform_module(new_tree) new_tree = RemoveUnusedImportsCodemod(self.context).transform_module( new_tree @@ -120,16 +122,14 @@ def leave_Call(self, original_node: cst.Call): { maybe_node: cst.ImportFrom( module=cst.parse_expression( - replacement_package + "." + replacement_import + f"{Security.name}.{replacement_import}" ), names=maybe_node.names, ) } ) self.changes_in_file.append( - Change( - str(line_number), UrlSandbox.CHANGE_DESCRIPTION - ).to_json() + Change(line_number, UrlSandbox.CHANGE_DESCRIPTION) ) # case req.get(...) @@ -143,7 +143,7 @@ def leave_Call(self, original_node: cst.Call): } ) self.changes_in_file.append( - Change(str(line_number), UrlSandbox.CHANGE_DESCRIPTION).to_json() + Change(line_number, UrlSandbox.CHANGE_DESCRIPTION) ) def _find_assignments(self, node: CSTNode): diff --git a/src/core_codemods/use_defused_xml.py b/src/core_codemods/use_defused_xml.py index f8307b92..498ec3e2 100644 --- a/src/core_codemods/use_defused_xml.py +++ b/src/core_codemods/use_defused_xml.py @@ -7,6 +7,7 @@ from codemodder.codemods.base_codemod import ReviewGuidance from codemodder.codemods.api import BaseCodemod from codemodder.codemods.imported_call_modifier import ImportedCallModifier +from codemodder.dependency import DefusedXML class DefusedXmlModifier(ImportedCallModifier[Mapping[str, str]]): @@ -65,6 +66,8 @@ class UseDefusedXml(BaseCodemod): }, ] + adds_dependency = True + @cached_property def matching_functions(self) -> dict[str, str]: """Build a mapping of functions to their defusedxml imports""" @@ -91,6 +94,6 @@ def transform_module_impl(self, tree: cst.Module) -> cst.Module: result_tree = visitor.transform_module(tree) self.file_context.codemod_changes.extend(visitor.changes_in_file) if visitor.changes_in_file: - self.add_dependency("defusedxml") # TODO: which version? + self.add_dependency(DefusedXML) return result_tree diff --git a/tests/codemods/base_codemod_test.py b/tests/codemods/base_codemod_test.py index c35cd4cc..f98e10e1 100644 --- a/tests/codemods/base_codemod_test.py +++ b/tests/codemods/base_codemod_test.py @@ -10,6 +10,7 @@ import mock from codemodder.context import CodemodExecutionContext +from codemodder.dependency import Dependency from codemodder.file_context import FileContext from codemodder.registry import CodemodRegistry, CodemodCollection from codemodder.semgrep import run as semgrep_run @@ -49,7 +50,7 @@ def run_and_assert_filepath(self, root, file_path, input_code, expected): assert output_tree.code == dedent(expected) - def assert_dependency(self, dependency: str): + def assert_dependency(self, dependency: Dependency): assert self.file_context and self.file_context.dependencies == set([dependency]) diff --git a/tests/codemods/test_process_creation_sandbox.py b/tests/codemods/test_process_creation_sandbox.py index 48761f4f..00089ca2 100644 --- a/tests/codemods/test_process_creation_sandbox.py +++ b/tests/codemods/test_process_creation_sandbox.py @@ -1,4 +1,5 @@ import pytest +from codemodder.dependency import Security from core_codemods.process_creation_sandbox import ProcessSandbox from tests.codemods.base_codemod_test import BaseSemgrepCodemodTest @@ -22,7 +23,7 @@ def test_import_subprocess(self, tmpdir): var = "hello" """ self.run_and_assert(tmpdir, input_code, expected) - self.assert_dependency("security==1.0.1") + self.assert_dependency(Security) def test_import_alias(self, tmpdir): input_code = """import subprocess as sub @@ -37,7 +38,7 @@ def test_import_alias(self, tmpdir): var = "hello" """ self.run_and_assert(tmpdir, input_code, expected) - self.assert_dependency("security==1.0.1") + self.assert_dependency(Security) def test_from_subprocess(self, tmpdir): input_code = """from subprocess import run @@ -52,7 +53,7 @@ def test_from_subprocess(self, tmpdir): var = "hello" """ self.run_and_assert(tmpdir, input_code, expected) - self.assert_dependency("security==1.0.1") + self.assert_dependency(Security) def test_subprocess_nameerror(self, tmpdir): input_code = """subprocess.run("echo 'hi'", shell=True) @@ -97,7 +98,7 @@ def test_subprocess_nameerror(self, tmpdir): ) def test_other_import_untouched(self, tmpdir, input_code, expected): self.run_and_assert(tmpdir, input_code, expected) - self.assert_dependency("security==1.0.1") + self.assert_dependency(Security) def test_multifunctions(self, tmpdir): # Test that subprocess methods that aren't part of the codemod are not changed. @@ -115,7 +116,7 @@ def test_multifunctions(self, tmpdir): subprocess.check_output(["ls", "-l"])""" self.run_and_assert(tmpdir, input_code, expected) - self.assert_dependency("security==1.0.1") + self.assert_dependency(Security) def test_custom_run(self, tmpdir): input_code = """from app_funcs import run diff --git a/tests/codemods/test_url_sandbox.py b/tests/codemods/test_url_sandbox.py index 09e7193b..eb308d1b 100644 --- a/tests/codemods/test_url_sandbox.py +++ b/tests/codemods/test_url_sandbox.py @@ -1,4 +1,6 @@ import pytest + +from codemodder.dependency import Security from core_codemods.url_sandbox import UrlSandbox from tests.codemods.base_codemod_test import BaseSemgrepCodemodTest @@ -21,7 +23,7 @@ def test_import_requests(self, tmpdir): var = "hello" """ self.run_and_assert(tmpdir, input_code, expected) - self.assert_dependency("security==1.0.1") + self.assert_dependency(Security) def test_from_requests(self, tmpdir): input_code = """from requests import get @@ -35,7 +37,7 @@ def test_from_requests(self, tmpdir): var = "hello" """ self.run_and_assert(tmpdir, input_code, expected) - self.assert_dependency("security==1.0.1") + self.assert_dependency(Security) def test_requests_nameerror(self, tmpdir): input_code = """requests.get("www.google.com") @@ -78,7 +80,7 @@ def test_requests_nameerror(self, tmpdir): ) def test_requests_other_import_untouched(self, tmpdir, input_code, expected): self.run_and_assert(tmpdir, input_code, expected) - self.assert_dependency("security==1.0.1") + self.assert_dependency(Security) def test_requests_multifunctions(self, tmpdir): # Test that `requests` import isn't removed if code uses part of the requests @@ -96,7 +98,7 @@ def test_requests_multifunctions(self, tmpdir): requests.status_codes.codes.FORBIDDEN""" self.run_and_assert(tmpdir, input_code, expected) - self.assert_dependency("security==1.0.1") + self.assert_dependency(Security) def test_custom_get(self, tmpdir): input_code = """from app_funcs import get @@ -127,7 +129,7 @@ def test_from_requests_with_alias(self, tmpdir): var = "hello" """ self.run_and_assert(tmpdir, input_code, expected) - self.assert_dependency("security==1.0.1") + self.assert_dependency(Security) def test_requests_with_alias(self, tmpdir): input_code = """import requests as req @@ -141,4 +143,4 @@ def test_requests_with_alias(self, tmpdir): var = "hello" """ self.run_and_assert(tmpdir, input_code, expected) - self.assert_dependency("security==1.0.1") + self.assert_dependency(Security) diff --git a/tests/codemods/test_use_defused_xml.py b/tests/codemods/test_use_defused_xml.py index 668060d0..a86d023a 100644 --- a/tests/codemods/test_use_defused_xml.py +++ b/tests/codemods/test_use_defused_xml.py @@ -1,5 +1,6 @@ import pytest +from codemodder.dependency import DefusedXML from core_codemods.use_defused_xml import ( DOM_METHODS, ETREE_METHODS, @@ -29,7 +30,7 @@ def test_etree_simple_call(self, tmpdir, module, method): """ self.run_and_assert(tmpdir, original_code, new_code) - self.assert_dependency("defusedxml") + self.assert_dependency(DefusedXML) @pytest.mark.parametrize("method", ETREE_METHODS) @pytest.mark.parametrize("module", ["ElementTree", "cElementTree"]) @@ -47,7 +48,7 @@ def test_etree_attribute_call(self, tmpdir, module, method): """ self.run_and_assert(tmpdir, original_code, new_code) - self.assert_dependency("defusedxml") + self.assert_dependency(DefusedXML) def test_etree_elementtree_with_alias(self, tmpdir): original_code = """ @@ -63,7 +64,7 @@ def test_etree_elementtree_with_alias(self, tmpdir): """ self.run_and_assert(tmpdir, original_code, new_code) - self.assert_dependency("defusedxml") + self.assert_dependency(DefusedXML) def test_etree_parse_with_alias(self, tmpdir): original_code = """ @@ -79,7 +80,7 @@ def test_etree_parse_with_alias(self, tmpdir): """ self.run_and_assert(tmpdir, original_code, new_code) - self.assert_dependency("defusedxml") + self.assert_dependency(DefusedXML) @pytest.mark.parametrize("method", SAX_METHODS) def test_sax_simple_call(self, tmpdir, method): @@ -96,7 +97,7 @@ def test_sax_simple_call(self, tmpdir, method): """ self.run_and_assert(tmpdir, original_code, new_code) - self.assert_dependency("defusedxml") + self.assert_dependency(DefusedXML) @pytest.mark.parametrize("method", SAX_METHODS) def test_sax_attribute_call(self, tmpdir, method): @@ -113,7 +114,7 @@ def test_sax_attribute_call(self, tmpdir, method): """ self.run_and_assert(tmpdir, original_code, new_code) - self.assert_dependency("defusedxml") + self.assert_dependency(DefusedXML) @pytest.mark.parametrize("method", DOM_METHODS) @pytest.mark.parametrize("module", ["minidom", "pulldom"]) @@ -131,4 +132,4 @@ def test_dom_simple_call(self, tmpdir, module, method): """ self.run_and_assert(tmpdir, original_code, new_code) - self.assert_dependency("defusedxml") + self.assert_dependency(DefusedXML) diff --git a/tests/test_dependency_manager.py b/tests/test_dependency_manager.py index bf62b99c..1860374a 100644 --- a/tests/test_dependency_manager.py +++ b/tests/test_dependency_manager.py @@ -2,10 +2,8 @@ import pytest -from codemodder.dependency_manager import ( - DependencyManager, - Requirement, -) +from codemodder.dependency import DefusedXML +from codemodder.dependency_manager import DependencyManager, Requirement @pytest.fixture(autouse=True, scope="module") @@ -30,11 +28,11 @@ def test_add_dependency_preserve_comments(self, tmpdir, dry_run): dependency_file.write_text(contents, encoding="utf-8") dm = DependencyManager(Path(tmpdir)) - dm.add(["defusedxml"]) + dm.add([DefusedXML]) changeset = dm.write(dry_run=dry_run) assert dependency_file.read_text(encoding="utf-8") == ( - contents if dry_run else "# comment\n\nrequests\ndefusedxml" + contents if dry_run else "# comment\n\nrequests\ndefusedxml~=0.7.1" ) assert changeset is not None @@ -46,6 +44,9 @@ def test_add_dependency_preserve_comments(self, tmpdir, dry_run): " # comment\n" " \n" " requests\n" - "+defusedxml+\n" + "+defusedxml~=0.7.1+\n" ) - assert changeset.changes == [] + assert len(changeset.changes) == 1 + assert changeset.changes[0].lineNumber == 4 + assert changeset.changes[0].description == DefusedXML.build_description() + assert changeset.changes[0].properties == {"contextual_description": True} diff --git a/tests/test_file_context.py b/tests/test_file_context.py index 9f38bc59..2ec8277e 100644 --- a/tests/test_file_context.py +++ b/tests/test_file_context.py @@ -1,7 +1,7 @@ from codemodder.file_context import FileContext -def test_file_context(): - file_context = FileContext(None, None, None, None) +def test_file_context(mocker): + file_context = FileContext(mocker.MagicMock()) assert file_context.line_exclude == [] assert file_context.line_include == []