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

Integrate Repo Manager #148

Merged
merged 6 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from 5 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
7 changes: 4 additions & 3 deletions src/codemodder/codemodder.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,15 @@ def apply_codemod_to_file(
return True


# pylint: disable-next=too-many-arguments
def process_file(
idx: int,
file_path: Path,
base_directory: Path,
codemod,
results: ResultSet,
cli_args,
): # pylint: disable=too-many-arguments
) -> FileContext:
logger.debug("scanning file %s", file_path)
if idx and idx % 100 == 0:
logger.info("scanned %s files...", idx) # pragma: no cover
Expand Down Expand Up @@ -262,8 +263,8 @@ def run(original_args) -> int:
codemod_registry,
repo_manager,
)
# todo: enable when ready
# repo_manager.package_stores

repo_manager.parse_project()

# TODO: this should be a method of CodemodExecutionContext
codemods_to_run = codemod_registry.match_codemods(
Expand Down
11 changes: 7 additions & 4 deletions src/codemodder/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,20 +87,23 @@ def get_failed_files(self):
)

def process_dependencies(self, codemod_id: str):
"""Write the dependencies a codemod added to the appropriate dependency
file in the project.
"""
dependencies = self.dependencies.get(codemod_id)
if not dependencies:
return

dm = DependencyManager(self.directory)
if not dm.found_dependency_file:
dependencies_store = self.repo_manager.dependencies_store
if dependencies_store is None:
logger.info(
"unable to write dependencies for %s: no dependency file found",
codemod_id,
)
return

dm.add(list(dependencies))
if (changeset := dm.write(self.dry_run)) is not None:
dm = DependencyManager(dependencies_store, self.directory)
if (changeset := dm.write(list(dependencies), self.dry_run)) is not None:
self.add_results(codemod_id, [changeset])

def add_description(self, codemod: CodemodExecutorWrapper):
Expand Down
2 changes: 1 addition & 1 deletion src/codemodder/dependency_management/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from .dependency_manager import DependencyManager, Requirement
from .dependency_manager import DependencyManager
32 changes: 32 additions & 0 deletions src/codemodder/dependency_management/base_dependency_writer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from abc import ABCMeta, abstractmethod
from pathlib import Path
from typing import Optional
from codemodder.project_analysis.file_parsers.package_store import PackageStore
from codemodder.change import ChangeSet
from codemodder.dependency import Dependency
from packaging.requirements import Requirement


class DependencyWriter(metaclass=ABCMeta):
dependency_store: PackageStore

def __init__(self, dependency_store: PackageStore, parent_directory: Path):
self.dependency_store = dependency_store
self.path = Path(dependency_store.file)
self.parent_directory = parent_directory

@abstractmethod
def write(
self, dependencies: list[Dependency], dry_run: bool = False
) -> Optional[ChangeSet]:
pass

Check warning on line 22 in src/codemodder/dependency_management/base_dependency_writer.py

View check run for this annotation

Codecov / codecov/patch

src/codemodder/dependency_management/base_dependency_writer.py#L22

Added line #L22 was not covered by tests

def add(self, dependencies: list[Dependency]) -> Optional[list[Dependency]]:
Copy link
Contributor

Choose a reason for hiding this comment

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

Optional[list[Dependency]] -> list[Dependency].
I usually expect a None with Optional[...] but here it just returns an empty list.

"""add any number of dependencies to the end of list of dependencies."""
new = []
for new_dep in dependencies:
requirement: Requirement = new_dep.requirement
if requirement not in self.dependency_store.dependencies:
Copy link
Contributor

Choose a reason for hiding this comment

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

PackageStore.dependencies is a list. This particular test will take O(n).
Suggestion:
Change PackageStore.dependencies to be a set.

self.dependency_store.dependencies.append(requirement)
new.append(new_dep)
return new
118 changes: 20 additions & 98 deletions src/codemodder/dependency_management/dependency_manager.py
Original file line number Diff line number Diff line change
@@ -1,110 +1,32 @@
from functools import cached_property
from pathlib import Path
from typing import Optional

from packaging.requirements import Requirement

from codemodder.change import Action, Change, ChangeSet, PackageAction, Result
from codemodder.diff import create_diff
from codemodder.change import ChangeSet
from codemodder.dependency import Dependency
from codemodder.dependency_management.requirements_txt_writer import (
RequirementsTxtWriter,
)
from codemodder.project_analysis.file_parsers.package_store import PackageStore
from pathlib import Path


class DependencyManager:
dependencies_store: PackageStore
parent_directory: Path
_lines: list[str]
_new_requirements: list[Dependency]

def __init__(self, parent_directory: Path):
def __init__(self, dependencies_store: PackageStore, parent_directory: Path):
self.dependencies_store = dependencies_store
self.parent_directory = parent_directory
self.dependency_file_changed = False
self._lines = []
self._new_requirements = []

@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 in dependencies:
if dep.requirement.name not in self.dependencies:
self.dependencies.update({dep.requirement.name: dep.requirement})
self._new_requirements.append(dep)

def write(self, dry_run: bool = False) -> Optional[ChangeSet]:
def write(
self, dependencies: list[Dependency], dry_run: bool = False
) -> Optional[ChangeSet]:
"""
Write the updated dependency files if any changes were made.
Write `dependencies` to the appropriate location in the project.
"""
if not (self.dependency_file and self._new_requirements):
return None

original_lines = self._lines.copy()
if not original_lines[-1].endswith("\n"):
original_lines[-1] += "\n"

requirement_lines = [f"{req}\n" for req in self.new_requirements]

updated = original_lines + requirement_lines
diff = create_diff(self._lines, updated)

changes = [
Change(
lineNumber=len(original_lines) + i + 1,
description=dep.build_description(),
# Contextual comments should be added to the right side of split diffs
properties={
"contextual_description": True,
"contextual_description_position": "right",
},
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(original_lines)
f.writelines(requirement_lines)

self.dependency_file_changed = True
return ChangeSet(
str(self.dependency_file.relative_to(self.parent_directory)),
diff,
changes=changes,
)

@property
def found_dependency_file(self) -> bool:
return self.dependency_file is not None

@cached_property
def dependency_file(self) -> Optional[Path]:
try:
# For now for simplicity only return the first file
return next(Path(self.parent_directory).rglob("requirements.txt"))
except StopIteration:
pass
match self.dependencies_store.type:
case "requirements.txt":
return RequirementsTxtWriter(
self.dependencies_store, self.parent_directory
).write(dependencies, dry_run)
case "setup.py":
pass

Check warning on line 31 in src/codemodder/dependency_management/dependency_manager.py

View check run for this annotation

Codecov / codecov/patch

src/codemodder/dependency_management/dependency_manager.py#L31

Added line #L31 was not covered by tests
return None

@cached_property
def dependencies(self) -> dict[str, Requirement]:
"""
Extract list of dependencies from requirements.txt file.
Same order of requirements is maintained, no alphabetical sorting is done.
"""
if not self.dependency_file:
return {}

with open(self.dependency_file, "r", encoding="utf-8") as f:
self._lines = f.readlines()

return {
requirement.name: requirement
for x in self._lines
# Skip empty lines and comments
if (line := x.strip())
and not line.startswith("#")
and (requirement := Requirement(line))
}
57 changes: 57 additions & 0 deletions src/codemodder/dependency_management/requirements_txt_writer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from typing import Optional
from codemodder.dependency_management.base_dependency_writer import DependencyWriter
from codemodder.change import Action, Change, ChangeSet, PackageAction, Result
from codemodder.diff import create_diff
from codemodder.dependency import Dependency
from packaging.requirements import Requirement


class RequirementsTxtWriter(DependencyWriter):
def write(
self, dependencies: list[Dependency], dry_run: bool = False
) -> Optional[ChangeSet]:
new_dependencies = self.add(dependencies)
if new_dependencies:
return self.add_to_file(new_dependencies, dry_run)
return None

def add_to_file(self, dependencies: list[Requirement], dry_run: bool):
lines = self._parse_file()
original_lines = lines.copy()
if not original_lines[-1].endswith("\n"):
original_lines[-1] += "\n"

requirement_lines = [f"{dep.requirement}\n" for dep in dependencies]
updated_lines = original_lines + requirement_lines

diff = create_diff(original_lines, updated_lines)

changes = [
Change(
lineNumber=len(original_lines) + i + 1,
description=dep.build_description(),
# Contextual comments should be added to the right side of split diffs
properties={
"contextual_description": True,
"contextual_description_position": "right",
},
packageActions=[
PackageAction(Action.ADD, Result.COMPLETED, str(dep.requirement))
],
)
for i, dep in enumerate(dependencies)
]

if not dry_run:
with open(self.path, "w", encoding="utf-8") as f:
f.writelines(updated_lines)

return ChangeSet(
str(self.path.relative_to(self.parent_directory)),
diff,
changes=changes,
)

def _parse_file(self):
with open(self.path, "r", encoding="utf-8") as f:
return f.readlines()
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import libcst as cst
from libcst import matchers
from packaging.requirements import Requirement
from typing import Optional

from .base_parser import BaseParser

Expand All @@ -21,10 +22,8 @@ def _parse_dependencies(self, dependencies):
if (line := clean_simplestring(x.value)) and not line.startswith("#")
]

def _parse_dependencies_from_cst(self, cst_dependencies):
# todo: handle cases for
# 1. no dependencies,
return self._parse_dependencies(cst_dependencies)
def _parse_dependencies_from_cst(self, cst_dependencies: Optional[list]):
return self._parse_dependencies(cst_dependencies) if cst_dependencies else []

def _parse_py_versions(self, version_str):
# todo: handle for multiple versions
Expand Down Expand Up @@ -68,10 +67,12 @@ def __init__(self):
self.install_requires = None

def visit_Arg(self, node: cst.Arg) -> None:
if matchers.matches(node.keyword, cst.Name(value="python_requires")):
if matchers.matches(node.keyword, matchers.Name(value="python_requires")):
# todo: this works for `python_requires=">=3.7",` but what about
# a list of versions?
self.python_requires = node.value.value
if matchers.matches(node.keyword, cst.Name(value="install_requires")):
# todo: could it be something other than a list?
if matchers.matches(
node.keyword, matchers.Name(value="install_requires")
) and matchers.matches(node.value, matchers.List()):
# todo: if node.value is Name node, find requirements in the variable at node.value
self.install_requires = node.value.elements
14 changes: 14 additions & 0 deletions src/codemodder/project_analysis/python_repo_manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from functools import cached_property
from pathlib import Path
from typing import Optional
from codemodder.project_analysis.file_parsers import (
RequirementsTxtParser,
PyprojectTomlParser,
Expand All @@ -19,10 +20,23 @@
SetupPyParser,
]

@cached_property
def dependencies_store(self) -> Optional[PackageStore]:
"""The location where to write new dependencies for project.
For now just pick the first store found with order given by _potential_stores.
"""
if self.package_stores:
return self.package_stores[0]
return None

Check warning on line 30 in src/codemodder/project_analysis/python_repo_manager.py

View check run for this annotation

Codecov / codecov/patch

src/codemodder/project_analysis/python_repo_manager.py#L30

Added line #L30 was not covered by tests

@cached_property
def package_stores(self) -> list[PackageStore]:
return self._parse_all_stores()

def parse_project(self) -> list[PackageStore]:
"""Wrapper around cached-property for clarity when calling it the first time."""
return self.package_stores

def _parse_all_stores(self) -> list[PackageStore]:
discovered_pkg_stores: list[PackageStore] = []
for store in self._potential_stores:
Expand Down
Loading
Loading