Skip to content
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
9 changes: 8 additions & 1 deletion patch_package_py/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ def cmd_patch(args):
)
sys.exit(1)
module_path, version = package
prepare_patch_workspace(module_path, package_name, version, env_path)
prepare_patch_workspace(
module_path, package_name, version, env_path, amend=args.amend
)


def cmd_commit(args):
Expand Down Expand Up @@ -110,6 +112,11 @@ def cli():
)
workspace_parser.add_argument("package", help="Package name")
workspace_parser.add_argument("-e", "--env-path", help="Environment Path")
workspace_parser.add_argument(
"--amend",
action="store_true",
help="Apply existing patch file to the workspace so you can continue editing",
)
workspace_parser.set_defaults(func=cmd_patch)

# commit command
Expand Down
63 changes: 62 additions & 1 deletion patch_package_py/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,20 @@ def _find_commonpath(self, files: list[PurePosixPath]) -> PurePosixPath:
return PurePosixPath(common_path_str)


def find_existing_patch(package_name: str, version: str) -> Union[Path, None]:
patch_file = Path.cwd() / "patches" / f"{package_name}+{version}.patch"
if patch_file.exists():
return patch_file
return None


def prepare_patch_workspace(
module_path: PurePosixPath, package_name: str, version: str, target_env_path: Path
module_path: PurePosixPath,
package_name: str,
version: str,
target_env_path: Path,
*,
amend: bool = False,
):
temp_dir = Path(tempfile.mkdtemp(prefix=f"patch-{package_name}-{version}-"))
venv_path = temp_dir / "venv"
Expand All @@ -105,6 +117,7 @@ def prepare_patch_workspace(
"pip",
"install",
"--no-deps",
"--link-mode=copy",
f"{package_name}=={version}",
"--python",
str(
Expand Down Expand Up @@ -181,6 +194,54 @@ def prepare_patch_workspace(
stdout=subprocess.DEVNULL,
)

if amend:
existing_patch = find_existing_patch(package_name, version)
if existing_patch:
logger.info(f"Applying existing patch: {existing_patch.name}")
patch_args = [
"patch",
"-p1",
"-N",
"--forward",
"-i",
str(existing_patch.absolute()),
]
try:
# Validate before modifying the workspace; recovery below
# handles residue if the real apply still fails unexpectedly.
subprocess.check_call(
[*patch_args, "--dry-run"],
cwd=site_packages_path,
stderr=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
)
subprocess.check_call(
patch_args,
cwd=site_packages_path,
)
except subprocess.CalledProcessError:
logger.warning(
"Failed to apply existing patch. Starting from clean state."
)
subprocess.check_call(
["git", "add", "."],
cwd=git_path,
stderr=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
)
subprocess.check_call(
["git", "reset", "--hard", "HEAD"],
cwd=git_path,
stderr=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
)
subprocess.check_call(
["git", "clean", "-fdX"],
cwd=git_path,
stderr=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
)

logger.info(
f"You can now edit the package in: {edit_path}. When done, run `{CLI_NAME} commit {edit_path}` in this directory to create the patch file."
)
Expand Down
27 changes: 27 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Resolver,
apply_patch,
commit_changes,
find_existing_patch,
find_site_packages,
restore_clean_package,
venv_python,
Expand Down Expand Up @@ -346,3 +347,29 @@ def test_restore_clean_package_uses_target_env_python(self, tmp_path: Path):
str(venv_python(target_env)),
]
)


class TestFindExistingPatch:
def test_finds_existing_patch(self, tmp_path: Path, monkeypatch):
monkeypatch.chdir(tmp_path)
patches_dir = tmp_path / "patches"
patches_dir.mkdir()
patch_file = patches_dir / "mypackage+1.0.0.patch"
patch_file.write_text("some diff")

result = find_existing_patch("mypackage", "1.0.0")
assert result == patch_file

def test_returns_none_when_no_patch(self, tmp_path: Path, monkeypatch):
monkeypatch.chdir(tmp_path)
result = find_existing_patch("mypackage", "1.0.0")
assert result is None

def test_returns_none_for_different_version(self, tmp_path: Path, monkeypatch):
monkeypatch.chdir(tmp_path)
patches_dir = tmp_path / "patches"
patches_dir.mkdir()
(patches_dir / "mypackage+2.0.0.patch").write_text("some diff")

result = find_existing_patch("mypackage", "1.0.0")
assert result is None
178 changes: 178 additions & 0 deletions tests/test_e2e.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import subprocess
import sys
import tempfile

import pytest

from patch_package_py.core import (
Resolver,
commit_changes,
find_site_packages,
prepare_patch_workspace,
venv_python,
)

PACKAGE = "six"
PACKAGE_VERSION = "1.16.0"


def _make_mock_mkdtemp(target_dir):
def mock_mkdtemp(*args, **kwargs):
target_dir.mkdir(exist_ok=True)
return str(target_dir)

return mock_mkdtemp


@pytest.fixture()
def project(tmp_path, monkeypatch):
project_dir = tmp_path / "project"
project_dir.mkdir()
monkeypatch.chdir(project_dir)

target_env = project_dir / ".venv"
subprocess.check_call(
["uv", "venv", str(target_env), "--python", sys.executable]
)
subprocess.check_call(
[
"uv",
"pip",
"install",
"--no-deps",
f"{PACKAGE}=={PACKAGE_VERSION}",
"--python",
str(venv_python(target_env)),
]
)

resolver = Resolver()
module_path, version = resolver.resolve_in_venv(target_env, PACKAGE)
assert version == PACKAGE_VERSION

return {
"dir": project_dir,
"target_env": target_env,
"module_path": module_path,
"version": version,
}


class TestCarryOverPatchE2E:
def test_new_workspace_has_existing_patch_applied(
self, tmp_path, monkeypatch, project
):
module_path = project["module_path"]
version = project["version"]
target_env = project["target_env"]

# Workspace 1: create and commit a patch
ws1 = tmp_path / "ws1"
monkeypatch.setattr(tempfile, "mkdtemp", _make_mock_mkdtemp(ws1))
prepare_patch_workspace(module_path, PACKAGE, version, target_env)

ws1_sp = find_site_packages(ws1 / "venv")
six_py = ws1_sp / "six.py"
original = six_py.read_text()
six_py.write_text(original + "\n# patched by e2e test\n")

commit_changes(PACKAGE, version, ws1_sp, target_env)

patch_file = project["dir"] / "patches" / f"{PACKAGE}+{version}.patch"
assert patch_file.exists()

# Workspace 2 with amend: should carry over the patch
ws2 = tmp_path / "ws2"
monkeypatch.setattr(tempfile, "mkdtemp", _make_mock_mkdtemp(ws2))
prepare_patch_workspace(
module_path, PACKAGE, version, target_env, amend=True
)

ws2_sp = find_site_packages(ws2 / "venv")
assert "# patched by e2e test" in (ws2_sp / "six.py").read_text()

diff = subprocess.check_output(
["git", "diff", "--relative"], cwd=ws2_sp, text=True
)
assert "patched by e2e test" in diff

def test_default_is_clean(self, tmp_path, monkeypatch, project):
module_path = project["module_path"]
version = project["version"]
target_env = project["target_env"]

# Workspace 1: create and commit a patch
ws1 = tmp_path / "ws1"
monkeypatch.setattr(tempfile, "mkdtemp", _make_mock_mkdtemp(ws1))
prepare_patch_workspace(module_path, PACKAGE, version, target_env)

ws1_sp = find_site_packages(ws1 / "venv")
six_py = ws1_sp / "six.py"
original = six_py.read_text()
six_py.write_text(original + "\n# should not appear\n")

commit_changes(PACKAGE, version, ws1_sp, target_env)

# Workspace 2 without --amend: should NOT have the patch
ws2 = tmp_path / "ws2"
monkeypatch.setattr(tempfile, "mkdtemp", _make_mock_mkdtemp(ws2))
prepare_patch_workspace(module_path, PACKAGE, version, target_env)

ws2_sp = find_site_packages(ws2 / "venv")
assert "# should not appear" not in (ws2_sp / "six.py").read_text()

diff = subprocess.check_output(
["git", "diff", "--relative"], cwd=ws2_sp, text=True
)
assert diff == ""

def test_amend_with_bad_patch_recovers_to_clean_state(
self, tmp_path, monkeypatch, project
):
module_path = project["module_path"]
version = project["version"]
target_env = project["target_env"]

# Write a corrupt patch file
patches_dir = project["dir"] / "patches"
patches_dir.mkdir()
patch_file = patches_dir / f"{PACKAGE}+{version}.patch"
patch_file.write_text(
"--- a/six.py\n"
"+++ b/six.py\n"
"@@ -1,3 +1,3 @@\n"
" this context does not exist in six.py\n"
"-nor does this line\n"
"+so the patch will fail\n"
)

ws = tmp_path / "ws"
monkeypatch.setattr(tempfile, "mkdtemp", _make_mock_mkdtemp(ws))
prepare_patch_workspace(
module_path, PACKAGE, version, target_env, amend=True
)

ws_sp = find_site_packages(ws / "venv")
git_path = ws_sp.parent

# No modified or staged files
diff = subprocess.check_output(
["git", "diff", "--relative"], cwd=ws_sp, text=True
)
assert diff == ""

# No untracked files (.rej, etc.)
untracked = subprocess.check_output(
["git", "ls-files", "--others", "--exclude-standard"],
cwd=git_path,
text=True,
)
assert untracked == ""

# No ignored residue
ignored = subprocess.check_output(
["git", "ls-files", "--others", "--ignored", "--exclude-standard"],
cwd=git_path,
text=True,
)
assert ignored == ""
Loading