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
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Test

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4

- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}

- name: Install dependencies
run: uv sync --dev

- name: Run tests
run: uv run pytest --cov=patch_package_py
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ wheels/

# Virtual environments
.venv
.coverage
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.12
3.9
2 changes: 0 additions & 2 deletions patch_package_py/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +0,0 @@
from .cli import cli as cli
from .core import * # noqa: F403
2 changes: 1 addition & 1 deletion patch_package_py/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from logging import getLogger
from pathlib import Path

from patch_package_py import (
from patch_package_py.core import (
CLI_NAME,
PATCH_INFO_FILE,
Resolver,
Expand Down
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]

[project.urls]
Expand All @@ -31,7 +33,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"

[dependency-groups]
dev = ["pyright>=1.1.405", "ruff>=0.12.12"]
dev = ["pyright>=1.1.405", "ruff>=0.12.12", "pytest>=8.0", "pytest-cov>=7.0.0"]

[tool.ruff]
target-version = "py39"
Expand All @@ -47,3 +49,8 @@ quote-style = "double"
[tool.pyright]
pythonVersion = "3.9"
include = ["patch_package_py"]

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
Empty file added tests/__init__.py
Empty file.
271 changes: 271 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import subprocess
from pathlib import Path, PurePosixPath
from unittest.mock import patch

import pytest

from patch_package_py.core import (
Resolver,
apply_patch,
commit_changes,
find_site_packages,
)


class TestFindSitePackages:
def test_unix_site_packages(self, tmp_path: Path):
"""Test finding site-packages on Unix-like systems."""
# Create mock venv structure
site_packages = tmp_path / "lib" / "python3.9" / "site-packages"
site_packages.mkdir(parents=True)

with patch("os.name", "posix"):
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

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

The patch target should be "patch_package_py.core.os.name" instead of "os.name" to correctly mock the os.name attribute in the module where find_site_packages is defined. The current patch may not affect the actual code being tested.

Copilot uses AI. Check for mistakes.
result = find_site_packages(tmp_path)
assert result == site_packages

def test_windows_site_packages(self, tmp_path: Path):
"""Test finding site-packages on Windows."""
site_packages = tmp_path / "Lib" / "site-packages"
site_packages.mkdir(parents=True)

with patch("os.name", "nt"):
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

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

The patch target should be "patch_package_py.core.os.name" instead of "os.name" to correctly mock the os.name attribute in the module where find_site_packages is defined. The current patch may not affect the actual code being tested.

Copilot uses AI. Check for mistakes.
result = find_site_packages(tmp_path)
assert result == site_packages

def test_no_site_packages_raises(self, tmp_path: Path):
"""Test that missing site-packages raises FileNotFoundError."""
with patch("os.name", "posix"), pytest.raises(FileNotFoundError):
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

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

The patch target should be "patch_package_py.core.os.name" instead of "os.name" to correctly mock the os.name attribute in the module where find_site_packages is defined. The current patch may not affect the actual code being tested.

Copilot uses AI. Check for mistakes.
find_site_packages(tmp_path)


class TestResolver:
def test_parse_record_file(self, tmp_path: Path):
"""Test parsing RECORD file from dist-info."""
dist_info = tmp_path / "mypackage-1.0.0.dist-info"
dist_info.mkdir()
record = dist_info / "RECORD"
record.write_text(
"mypackage/__init__.py,sha256=abc,100\n"
"mypackage/core.py,sha256=def,200\n"
"mypackage-1.0.0.dist-info/METADATA,,\n"
"../outside.py,,\n"
"./relative.py,,\n"
)

resolver = Resolver()
files = resolver._parse_record_file(dist_info)

assert len(files) == 2
assert PurePosixPath("mypackage/__init__.py") in files
assert PurePosixPath("mypackage/core.py") in files

def test_parse_record_file_empty(self, tmp_path: Path):
"""Test parsing empty or missing RECORD file."""
dist_info = tmp_path / "mypackage-1.0.0.dist-info"
dist_info.mkdir()

resolver = Resolver()
files = resolver._parse_record_file(dist_info)
assert files == []

def test_find_commonpath_multiple_files(self):
"""Test finding common path for multiple files."""
resolver = Resolver()
files = [
PurePosixPath("mypackage/__init__.py"),
PurePosixPath("mypackage/core.py"),
PurePosixPath("mypackage/utils/helpers.py"),
]
result = resolver._find_commonpath(files)
assert result == PurePosixPath("mypackage")

def test_find_commonpath_single_file(self):
"""Test finding common path for single file."""
resolver = Resolver()
files = [PurePosixPath("mypackage/core.py")]
result = resolver._find_commonpath(files)
assert result == PurePosixPath("mypackage")

def test_find_commonpath_empty(self):
"""Test finding common path for empty list."""
resolver = Resolver()
result = resolver._find_commonpath([])
assert result == PurePosixPath("")

def test_resolve_in_site_packages(self, tmp_path: Path):
"""Test resolving package in site-packages."""
# Create mock dist-info
dist_info = tmp_path / "my_package-2.0.0.dist-info"
dist_info.mkdir()
record = dist_info / "RECORD"
record.write_text(
"my_package/__init__.py,sha256=abc,100\n"
"my_package/module.py,sha256=def,200\n"
)

resolver = Resolver()
result = resolver.resolve_in_site_packages(tmp_path, "my-package")

assert result is not None
module_path, version = result
assert module_path == PurePosixPath("my_package")
assert version == "2.0.0"

def test_resolve_in_site_packages_not_found(self, tmp_path: Path):
"""Test resolving non-existent package."""
resolver = Resolver()
result = resolver.resolve_in_site_packages(tmp_path, "nonexistent")
assert result is None


class TestApplyPatch:
"""Integration tests for the apply_patch workflow."""

def _setup_site_packages(self, tmp_path: Path, package_name: str, version: str):
"""Helper to create a mock site-packages with a package installed."""
site_packages = tmp_path / "site-packages"
site_packages.mkdir()

# Create dist-info
dist_info = (
site_packages / f"{package_name.replace('-', '_')}-{version}.dist-info"
)
dist_info.mkdir()
(dist_info / "RECORD").write_text(
f"{package_name}/__init__.py,sha256=abc123,50\n"
f"{package_name}/core.py,sha256=def456,200\n"
)

# Create package files
pkg_dir = site_packages / package_name
pkg_dir.mkdir()
(pkg_dir / "__init__.py").write_text('__version__ = "1.0.0"\n')
(pkg_dir / "core.py").write_text("def hello():\n return 'hello'\n")

return site_packages

def test_apply_patch_invalid_name_format(self, tmp_path: Path, caplog):
"""Test that invalid patch file name is skipped."""
site_packages = self._setup_site_packages(tmp_path, "mypackage", "1.0.0")
patch_file = tmp_path / "invalid_name.patch"
patch_file.write_text("some patch content")

apply_patch(patch_file, site_packages)

assert "Invalid patch file name format" in caplog.text

def test_apply_patch_package_not_found(self, tmp_path: Path, caplog):
"""Test that missing package is skipped."""
site_packages = self._setup_site_packages(tmp_path, "mypackage", "1.0.0")
patch_file = tmp_path / "otherpackage+1.0.0.patch"
patch_file.write_text("some patch content")

apply_patch(patch_file, site_packages)

assert "not found in site-packages" in caplog.text

def test_apply_patch_version_mismatch(self, tmp_path: Path):
"""Test that version mismatch raises error."""
site_packages = self._setup_site_packages(tmp_path, "mypackage", "1.0.0")
patch_file = tmp_path / "mypackage+2.0.0.patch"
patch_file.write_text("some patch content")

with pytest.raises(ValueError, match="Version mismatch"):
apply_patch(patch_file, site_packages)

def test_apply_patch_success(self, tmp_path: Path):
"""Test successful patch application."""
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"
)

with patch("subprocess.check_call") as mock_check_call:
apply_patch(patch_file, site_packages)

# Should be called twice: dry-run and actual apply
assert mock_check_call.call_count == 2

def test_apply_patch_already_applied(self, tmp_path: Path, caplog):
"""Test that already applied patch is skipped."""
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 patch("subprocess.check_call") as mock_check_call:
# Simulate dry-run failure (patch already applied)
mock_check_call.side_effect = subprocess.CalledProcessError(1, "patch")

apply_patch(patch_file, site_packages)

assert "already applied" in caplog.text


class TestCommitChanges:
"""Tests for creating patch files via commit_changes."""

def test_commit_no_changes(self, tmp_path: Path, caplog, monkeypatch):
"""Test that no patch is created when there are no changes."""
import logging
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

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

The logging module should be imported at the module level (top of the file) rather than inside a test function. This follows Python best practices and improves code readability.

Copilot uses AI. Check for mistakes.

caplog.set_level(logging.INFO)
monkeypatch.chdir(tmp_path)

with patch("subprocess.check_output", return_value=""):
commit_changes("mypackage", "1.0.0", tmp_path)

assert "No changes detected" in caplog.text
assert not (tmp_path / "patches").exists()

def test_commit_creates_patch_file(self, tmp_path: Path, monkeypatch):
"""Test that patch file is created with correct name and content."""
monkeypatch.chdir(tmp_path)

# Create .venv structure for find_site_packages
site_packages = tmp_path / ".venv" / "lib" / "python3.9" / "site-packages"
site_packages.mkdir(parents=True)
dist_info = site_packages / "mypackage-1.0.0.dist-info"
dist_info.mkdir()
(dist_info / "RECORD").write_text("mypackage/__init__.py,,\n")

diff_content = (
"--- a/mypackage/core.py\n"
"+++ b/mypackage/core.py\n"
"@@ -1 +1 @@\n"
"-old\n"
"+new\n"
)

with (
patch("subprocess.check_output", return_value=diff_content),
patch("subprocess.check_call"), # mock patch command
):
commit_changes("mypackage", "1.0.0", tmp_path)

patch_file = tmp_path / "patches" / "mypackage+1.0.0.patch"
assert patch_file.exists()
assert patch_file.read_text() == diff_content

def test_commit_patch_file_naming(self, tmp_path: Path, monkeypatch):
"""Test patch file naming with package name and version."""
monkeypatch.chdir(tmp_path)

site_packages = tmp_path / ".venv" / "lib" / "python3.9" / "site-packages"
site_packages.mkdir(parents=True)
dist_info = site_packages / "my_package-2.5.0.dist-info"
dist_info.mkdir()
(dist_info / "RECORD").write_text("my_package/__init__.py,,\n")

with (
patch("subprocess.check_output", return_value="some diff"),
patch("subprocess.check_call"),
):
commit_changes("my-package", "2.5.0", tmp_path)

assert (tmp_path / "patches" / "my-package+2.5.0.patch").exists()
Loading