Skip to content
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
14 changes: 12 additions & 2 deletions patch_package_py/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@ def cmd_apply(args):
return

for patch_file in patch_files:
apply_patch(patch_file, site_packages_dir)
apply_patch(
patch_file,
site_packages_dir,
env_path=env_path if args.restore else None,
restore=args.restore,
)
Comment on lines +106 to +110


def cli():
Expand Down Expand Up @@ -135,13 +140,18 @@ def cli():
commit_parser.add_argument(
"--skip-restore",
action="store_true",
help="Skip reinstalling the target package before applying the new patch",
help="Skip restoring the clean package before applying the new patch",
)
commit_parser.set_defaults(func=cmd_commit)

# apply command
apply_parser = subparsers.add_parser("apply", help="Apply patches")
apply_parser.add_argument("-e", "--env-path", help="Environment Path")
apply_parser.add_argument(
"--restore",
action="store_true",
help="Restore the clean package before applying each patch",
)
apply_parser.set_defaults(func=cmd_apply)

args = parser.parse_args()
Expand Down
24 changes: 20 additions & 4 deletions patch_package_py/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,13 @@ def commit_changes(
logger.info(f"Patch created and applied for {package_name}=={version}")


def apply_patch(patch_file: Path, site_packages_dir: Path) -> None:
def apply_patch(
patch_file: Path,
site_packages_dir: Path,
*,
env_path: Union[Path, None] = None,
restore: bool = False,
) -> None:
# Parse package name and version from patch file name
patch_name = patch_file.stem # Remove .patch extension
if "+" not in patch_name:
Expand All @@ -339,6 +345,11 @@ def apply_patch(patch_file: Path, site_packages_dir: Path) -> None:
f"Version mismatch: patch is for {package_name}=={version} but installed version is {installed_version}"
)

Comment thread
nomyfan marked this conversation as resolved.
if restore:
if env_path is None:
raise ValueError("env_path is required when restore=True")
restore_clean_package(package_name, version, env_path)
Comment thread
nomyfan marked this conversation as resolved.

# First, check if the patch is already applied using dry-run
try:
subprocess.check_call(
Expand All @@ -356,9 +367,14 @@ def apply_patch(patch_file: Path, site_packages_dir: Path) -> None:
stdout=subprocess.DEVNULL,
)
except subprocess.CalledProcessError:
logger.warning(
f"Patch `{patch_name}` appears to be already applied, skipping...",
)
if restore:
logger.error(
f"Failed to apply patch `{patch_name}` after restoring clean package.",
)
else:
Comment thread
nomyfan marked this conversation as resolved.
Outdated
logger.warning(
f"Patch `{patch_name}` appears to be already applied, skipping...",
)
return

# If dry-run succeeds, apply the patch for real
Expand Down
15 changes: 10 additions & 5 deletions skills/patch-package-py/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The CLI has three commands:
```bash
p12y patch <package> [-e <env-path>] [--amend]
p12y commit <edit-path> [--skip-restore]
p12y apply [-e <env-path>]
p12y apply [-e <env-path>] [--restore]
```

## CLI availability and uv-based workflow
Expand Down Expand Up @@ -99,10 +99,9 @@ Use this sequence when guiding a user through patching a package:
uv run p12y commit <edit-path>
```

`commit` writes the patch file, reinstalls the original package in the
`commit` writes the patch file, restores the clean package in the
environment selected during `patch`, then applies the new patch. Use
`--skip-restore` when the target environment is already prepared for direct
patch application.
`--skip-restore` when the target environment is already in a clean state.

6. Apply all patch files in `patches/` to the selected environment:

Expand All @@ -116,6 +115,12 @@ Use this sequence when guiding a user through patching a package:
uv run p12y apply -e <env-path>
```

Use `--restore` to restore each package to its clean state before applying the patch. This is useful when the environment may already contain a previous version of the patch or manual edits:

```bash
uv run p12y apply --restore
```

7. Run a small import verification check for the patched behavior.

## Custom environment paths
Expand All @@ -138,5 +143,5 @@ Run `uv run p12y commit <edit-path>` from the project root so the patch file lan
- `Could not determine site-packages directory`: pass a virtual environment path that contains `Lib/site-packages` on Windows or `lib/python*/site-packages` on Unix-like systems.
- `Version mismatch`: recreate the patch for the installed version, or install the package version named in the patch file.
- `Invalid patch file name format`: use `patches/<package-name>+<version>.patch`.
- `appears to be already applied`: the selected environment already contains the patch changes.
- `appears to be already applied`: the selected environment already contains the patch changes. Run `uv run p12y apply --restore` to restore the clean package first and then re-apply.
- `No changes detected`: edit files inside the path printed by `uv run p12y patch`, then rerun `uv run p12y commit <edit-path>`.
60 changes: 60 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,66 @@ def test_apply_patch_already_applied(self, tmp_path: Path, caplog):

assert "already applied" in caplog.text

def test_apply_patch_restore_calls_restore(self, tmp_path: Path):
"""Test that restore=True calls restore_clean_package before patching."""
site_packages = self._setup_site_packages(tmp_path, "mypackage", "1.0.0")
patch_file = tmp_path / "mypackage+1.0.0.patch"
patch_file.write_text(
"--- a/mypackage/core.py\n"
"+++ b/mypackage/core.py\n"
"@@ -1,2 +1,2 @@\n"
" def hello():\n"
"- return 'hello'\n"
"+ return 'hello world'\n"
)
env_path = tmp_path / ".venv"

with patch("subprocess.check_call") as mock_check_call:
apply_patch(patch_file, site_packages, env_path=env_path, restore=True)

commands = [call_args.args[0] for call_args in mock_check_call.call_args_list]
assert commands[0] == [
"uv",
"pip",
"install",
"--force-reinstall",
"--no-deps",
"mypackage==1.0.0",
"--python",
str(venv_python(env_path)),
]
# dry-run + actual apply should follow
assert mock_check_call.call_count == 3

def test_apply_patch_restore_requires_env_path(self, tmp_path: Path):
"""Test that restore=True without env_path raises ValueError."""
site_packages = self._setup_site_packages(tmp_path, "mypackage", "1.0.0")
patch_file = tmp_path / "mypackage+1.0.0.patch"
patch_file.write_text("some patch content")

with pytest.raises(ValueError, match="env_path is required"):
apply_patch(patch_file, site_packages, restore=True)

def test_apply_patch_restore_dry_run_failure_logs_error(
self, tmp_path: Path, caplog
):
"""Test that a broken patch after restore logs an error (not 'already applied')."""
site_packages = self._setup_site_packages(tmp_path, "mypackage", "1.0.0")
patch_file = tmp_path / "mypackage+1.0.0.patch"
patch_file.write_text("some patch content")
env_path = tmp_path / ".venv"

def side_effect(cmd, *args, **kwargs):
if cmd[0] == "uv":
return None
raise subprocess.CalledProcessError(1, "patch")

with patch("subprocess.check_call", side_effect=side_effect):
apply_patch(patch_file, site_packages, env_path=env_path, restore=True)

assert "already applied" not in caplog.text
assert "Failed to apply patch" in caplog.text


class TestCommitChanges:
"""Tests for creating patch files via commit_changes."""
Expand Down
12 changes: 3 additions & 9 deletions tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ def project(tmp_path, monkeypatch):
monkeypatch.chdir(project_dir)

target_env = project_dir / ".venv"
subprocess.check_call(
["uv", "venv", str(target_env), "--python", sys.executable]
)
subprocess.check_call(["uv", "venv", str(target_env), "--python", sys.executable])
subprocess.check_call(
[
"uv",
Expand Down Expand Up @@ -84,9 +82,7 @@ def test_new_workspace_has_existing_patch_applied(
# 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
)
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()
Expand Down Expand Up @@ -148,9 +144,7 @@ def test_amend_with_bad_patch_recovers_to_clean_state(

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

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