patch-package-py 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ from .core import * # noqa: F403
2
+ from .cli import cli as cli
@@ -0,0 +1,128 @@
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+ from patch_package_py import (
5
+ prepare_patch_workspace,
6
+ commit_changes,
7
+ apply_patch,
8
+ Resolver,
9
+ find_site_packages,
10
+ PATCH_INFO_FILE,
11
+ CLI_NAME,
12
+ )
13
+ from logging import getLogger
14
+ import logging
15
+
16
+ logger = getLogger(__name__)
17
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
18
+
19
+
20
+ def cmd_patch(args):
21
+ package_name = args.package
22
+ resolver = Resolver()
23
+ package = resolver.resolve_in_venv(Path.cwd() / ".venv", package_name)
24
+ if not package:
25
+ logger.error(
26
+ "Error: No package found",
27
+ )
28
+ sys.exit(1)
29
+ module_path, version = package
30
+ prepare_patch_workspace(module_path, package_name, version)
31
+
32
+
33
+ def cmd_commit(args):
34
+ edit_path = Path(args.path)
35
+ if not edit_path.exists() or not edit_path.is_dir():
36
+ logger.error(
37
+ f"Error: Path {edit_path} does not exist or is not a directory",
38
+ )
39
+ sys.exit(1)
40
+ import subprocess
41
+
42
+ git_dir = subprocess.run(
43
+ ["git", "rev-parse", "--show-toplevel"],
44
+ cwd=edit_path,
45
+ capture_output=True,
46
+ text=True,
47
+ check=True,
48
+ ).stdout.strip()
49
+ with open(Path(git_dir) / PATCH_INFO_FILE, "r") as f:
50
+ import json
51
+
52
+ info = json.load(f)
53
+ site_packages_dir = Path(info["site_packages_path"])
54
+ commit_changes(info["package_name"], info["version"], site_packages_dir)
55
+ import shutil
56
+
57
+ shutil.rmtree(info["temp_dir"])
58
+
59
+
60
+ def cmd_apply(args):
61
+ patches_dir = Path.cwd() / "patches"
62
+ site_packages_dir = find_site_packages(Path.cwd() / ".venv")
63
+
64
+ if not patches_dir.exists():
65
+ logger.error(
66
+ f"Error: Patches directory {patches_dir} does not exist",
67
+ )
68
+ sys.exit(1)
69
+
70
+ if not site_packages_dir.exists():
71
+ logger.error(
72
+ f"Error: Site-packages directory {site_packages_dir} does not exist",
73
+ )
74
+ sys.exit(1)
75
+
76
+ patch_files = list(patches_dir.glob("*.patch"))
77
+
78
+ if not patch_files:
79
+ logger.info(f"No patch files found in {patches_dir}")
80
+ return
81
+
82
+ success_count = 0
83
+ for patch_file in patch_files:
84
+ try:
85
+ apply_patch(patch_file, site_packages_dir)
86
+ success_count += 1
87
+ except Exception as e:
88
+ logger.error(f"✗ Failed to apply {patch_file.name}: {e}")
89
+
90
+ logger.info(f"Applied {success_count}/{len(patch_files)} patches successfully")
91
+
92
+
93
+ def cli():
94
+ parser = argparse.ArgumentParser(
95
+ prog=CLI_NAME, description="A Python package patching tool"
96
+ )
97
+
98
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
99
+
100
+ # patch command
101
+ workspace_parser = subparsers.add_parser(
102
+ "patch", help="Prepare for patching a package"
103
+ )
104
+ workspace_parser.add_argument("package", help="Package name")
105
+ workspace_parser.set_defaults(func=cmd_patch)
106
+
107
+ # commit command
108
+ commit_parser = subparsers.add_parser(
109
+ "commit", help="Commit changes and create a patch file"
110
+ )
111
+ commit_parser.add_argument("path", help="Edit patch given by `patch` command")
112
+ commit_parser.set_defaults(func=cmd_commit)
113
+
114
+ # apply command
115
+ apply_parser = subparsers.add_parser("apply", help="Apply patches")
116
+ apply_parser.set_defaults(func=cmd_apply)
117
+
118
+ args = parser.parse_args()
119
+
120
+ if not args.command:
121
+ parser.print_help()
122
+ sys.exit(1)
123
+
124
+ try:
125
+ args.func(args)
126
+ except Exception as e:
127
+ logger.error(f"Error: {e}")
128
+ sys.exit(1)
@@ -0,0 +1,238 @@
1
+ from pathlib import Path, PurePosixPath
2
+ import tempfile
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ from logging import getLogger
7
+ import posixpath
8
+
9
+ logger = getLogger(__name__)
10
+
11
+ CLI_NAME = "p12y"
12
+ PATCH_INFO_FILE = ".patch_info.json"
13
+
14
+
15
+ def find_site_packages(venv: Path) -> Path:
16
+ # For Windows
17
+ if os.name == "nt":
18
+ return venv / "Lib" / "site-packages"
19
+
20
+ # For Unix-like systems
21
+ lib_path = venv / "lib"
22
+ if lib_path.exists():
23
+ python_dirs = list(lib_path.glob("python*"))
24
+ if python_dirs:
25
+ return python_dirs[0] / "site-packages"
26
+
27
+ raise FileNotFoundError("Could not determine site-packages directory.")
28
+
29
+
30
+ class Resolver:
31
+ def resolve_in_venv(
32
+ self, venv: Path, package_name: str
33
+ ) -> tuple[PurePosixPath, str] | None:
34
+ site_packages_path = find_site_packages(venv)
35
+ dist_info = list(
36
+ site_packages_path.glob(f"{package_name.replace('-', '_')}-*.dist-info")
37
+ )
38
+ if not dist_info:
39
+ return None
40
+ if len(dist_info) != 1:
41
+ raise ValueError("unreachable")
42
+ dist_info_path = dist_info[0]
43
+ _, version = dist_info_path.stem.rsplit("-", 1)
44
+ files = self._parse_record_file(dist_info_path)
45
+ commonpath = self._find_commonpath(files)
46
+ return commonpath, version
47
+
48
+ def _parse_record_file(self, dist_info_path: Path) -> list[PurePosixPath]:
49
+ record_file = dist_info_path / "RECORD"
50
+ if not record_file.exists():
51
+ return []
52
+
53
+ files: list[PurePosixPath] = []
54
+ with record_file.open("r") as f:
55
+ for line in f:
56
+ line = line.strip()
57
+ if not line:
58
+ continue
59
+
60
+ # Parse CSV-like format: path,hash,size
61
+ parts = line.split(",")
62
+ if len(parts) >= 1:
63
+ file_path = parts[0]
64
+ # Skip .dist-info files and external files
65
+ if (
66
+ ".dist-info/" not in file_path
67
+ and not file_path.startswith("./")
68
+ and not file_path.startswith("../")
69
+ ):
70
+ files.append(PurePosixPath(file_path))
71
+
72
+ return files
73
+
74
+ def _find_commonpath(self, files: list[PurePosixPath]) -> PurePosixPath:
75
+ if not files:
76
+ return PurePosixPath("")
77
+
78
+ if len(files) == 1:
79
+ # For single file, return its directory
80
+ return files[0].parent
81
+
82
+ common_path_str = posixpath.commonpath([str(p) for p in files])
83
+ return PurePosixPath(common_path_str)
84
+
85
+
86
+ def prepare_patch_workspace(
87
+ module_path: PurePosixPath, package_name: str, version: str
88
+ ):
89
+ temp_dir = Path(tempfile.mkdtemp(prefix=f"patch-{package_name}-{version}-"))
90
+ venv_path = temp_dir / "venv"
91
+
92
+ # Create venv with uv using current Python version
93
+ subprocess.run(
94
+ ["uv", "venv", str(venv_path), "--python", sys.executable], check=True
95
+ )
96
+
97
+ # Install the package without dependencies using uv
98
+ subprocess.run(
99
+ [
100
+ "uv",
101
+ "pip",
102
+ "install",
103
+ "--no-deps",
104
+ f"{package_name}=={version}",
105
+ "--python",
106
+ str(
107
+ venv_path / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
108
+ ),
109
+ ],
110
+ check=True,
111
+ cwd=temp_dir,
112
+ )
113
+
114
+ # Remove all directories ending with .dist-info
115
+ site_packages_path = find_site_packages(venv_path)
116
+ for dist_info in site_packages_path.glob("*.dist-info"):
117
+ if dist_info.is_dir():
118
+ import shutil
119
+
120
+ shutil.rmtree(dist_info)
121
+ # Remove _virtualenv.py and _virtualenv.pth if exist
122
+ for extra_file in ["_virtualenv.py", "_virtualenv.pth"]:
123
+ extra_path = site_packages_path / extra_file
124
+ if extra_path.exists():
125
+ extra_path.unlink()
126
+
127
+ git_path = site_packages_path.parent
128
+ edit_path = site_packages_path / module_path
129
+
130
+ with open(git_path / ".gitignore", "w") as f:
131
+ f.write(
132
+ "__pycache__/\n*.py[oc]\nbuild/\ndist/\nwheels/\n*.egg-info\n_virtualenv.py\n_virtualenv.pth"
133
+ )
134
+ with open(git_path / PATCH_INFO_FILE, "w") as f:
135
+ import json
136
+
137
+ json.dump(
138
+ {
139
+ "temp_dir": str(temp_dir.absolute()),
140
+ "venv_path": str(venv_path.absolute()),
141
+ "site_packages_path": str(site_packages_path.absolute()),
142
+ "package_name": package_name,
143
+ "version": version,
144
+ },
145
+ f,
146
+ indent=2,
147
+ )
148
+ subprocess.run(["git", "init"], cwd=git_path, check=True, capture_output=True)
149
+ subprocess.run(
150
+ ["git", "add", "."],
151
+ cwd=git_path,
152
+ check=True,
153
+ capture_output=True,
154
+ )
155
+ subprocess.run(
156
+ [
157
+ "git",
158
+ "commit",
159
+ "--no-gpg-sign",
160
+ "-m",
161
+ f"Initial commit of {package_name}=={version}",
162
+ ],
163
+ cwd=git_path,
164
+ check=True,
165
+ capture_output=True,
166
+ )
167
+
168
+ logger.info(
169
+ 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."
170
+ )
171
+
172
+
173
+ def commit_changes(package_name: str, version: str, site_packages_path: Path) -> None:
174
+ diff_proc = subprocess.run(
175
+ ["git", "diff", "--relative"],
176
+ cwd=site_packages_path,
177
+ check=True,
178
+ capture_output=True,
179
+ text=True,
180
+ )
181
+
182
+ diff_content = diff_proc.stdout
183
+ if not diff_content:
184
+ logger.info("No changes detected, nothing to commit.")
185
+ return
186
+ patch_file_name = f"{package_name}+{version}.patch"
187
+ patches_dir = Path.cwd() / "patches"
188
+ patches_dir.mkdir(exist_ok=True, parents=True)
189
+ patch_file_path = patches_dir / patch_file_name
190
+ with open(patch_file_path, "w") as f:
191
+ f.write(diff_content)
192
+
193
+ try:
194
+ apply_patch(patch_file_path, site_packages_path)
195
+ except subprocess.CalledProcessError:
196
+ logger.error(
197
+ f"Error: failed to apply the patch after creation. There's maybe a conflict, you can try to reinstall the package and apply the patch manually via `{CLI_NAME} apply {patch_file_name}`"
198
+ )
199
+ return
200
+ logger.info(f"Patch created and applied for {package_name}=={version}")
201
+
202
+
203
+ def apply_patch(patch_file: Path, site_packages_dir: Path) -> None:
204
+ # First, check if the patch is already applied using dry-run
205
+ try:
206
+ subprocess.run(
207
+ [
208
+ "patch",
209
+ "-p1",
210
+ "-N",
211
+ "--dry-run",
212
+ "--forward",
213
+ "-i",
214
+ str(patch_file.absolute()),
215
+ ],
216
+ cwd=site_packages_dir,
217
+ check=True,
218
+ capture_output=True,
219
+ )
220
+ except subprocess.CalledProcessError:
221
+ logger.warning(
222
+ f"Patch `{patch_file.stem}` appears to be already applied, skipping...",
223
+ )
224
+ return
225
+
226
+ # If dry-run succeeds, apply the patch for real
227
+ subprocess.run(
228
+ [
229
+ "patch",
230
+ "-p1",
231
+ "-N",
232
+ "--forward",
233
+ "-i",
234
+ str(patch_file.absolute()),
235
+ ],
236
+ cwd=site_packages_dir,
237
+ check=True,
238
+ )
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: patch-package-py
3
+ Version: 0.1.0
4
+ Summary: patch 3rd party Python packages
5
+ Project-URL: Homepage, https://github.com/nomyfan/patch-package-py
6
+ Project-URL: Repository, https://github.com/nomyfan/patch-package-py
7
+ Project-URL: Issues, https://github.com/nomyfan/patch-package-py/issues
8
+ Author-email: nomyfan <nomyfan@live.com>
9
+ License: MIT
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+
21
+ # patch-package-py
22
+
23
+ A Python package patching tool that allows you to make and apply patches to third-party packages in your virtual environment.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ uv add patch-package-py
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ The tool provides three main commands via the `p12y` CLI:
34
+
35
+ ### 1. Create a patch workspace
36
+
37
+ ```bash
38
+ p12y patch <package_name>
39
+ ```
40
+
41
+ This command:
42
+
43
+ - Resolves the package from your current virtual environment (`.venv`)
44
+ - Creates a temporary virtual environment
45
+ - Installs the same version of the package without dependencies
46
+ - Sets up a git repository for tracking changes
47
+ - Provides a path where you can edit the package files
48
+
49
+ Example:
50
+
51
+ ```bash
52
+ p12y patch requests
53
+ ```
54
+
55
+ ### 2. Commit changes and create patch file
56
+
57
+ ```bash
58
+ p12y commit <edit_path>
59
+ ```
60
+
61
+ After editing the package files, use this command to:
62
+
63
+ - Generate a git diff of your changes
64
+ - Create a `.patch` file in the `patches/` directory
65
+ - Test that the patch can be applied successfully
66
+
67
+ Example:
68
+
69
+ ```bash
70
+ p12y commit /tmp/patch-requests-2.28.1-abc123/venv/lib/python3.11/site-packages/requests
71
+ ```
72
+
73
+ ### 3. Apply patches
74
+
75
+ ```bash
76
+ p12y apply
77
+ ```
78
+
79
+ This command:
80
+
81
+ - Looks for `.patch` files in the `patches/` directory
82
+ - Applies them to the packages in your current virtual environment (`.venv`)
83
+ - Reports success/failure for each patch
84
+
85
+ ## Workflow
86
+
87
+ 1. **Prepare for patching**: Run `p12y patch <package_name>` to set up a workspace
88
+ 2. **Make your changes**: Edit the files in the provided path
89
+ 3. **Create the patch**: Run `p12y commit <path>` to generate the patch file
90
+ 4. **Apply patches**: Run `p12y apply` in your project to apply all patches
91
+
92
+ ## How it works
93
+
94
+ - Uses `uv` for fast virtual environment creation and package installation
95
+ - Leverages git for tracking changes and generating diffs
96
+ - Stores patch files in a `patches/` directory in your project root
97
+ - Patch files are named using the format: `<package-name>+<version>.patch`
98
+
99
+ ## Requirements
100
+
101
+ - Python ≥ 3.9
102
+ - `uv` package manager
103
+ - `git` version control system
104
+ - `patch` utility (typically pre-installed on Unix-like systems)
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,7 @@
1
+ patch_package_py/__init__.py,sha256=bSu9ttGPKG8PfZiNHM0-VMQmGnlNWcxGEp46pwZ-T_4,62
2
+ patch_package_py/cli.py,sha256=2ktZP8ZTT6CSETrOKDEG83UB7ubl0YXeNr4vSfL4RGM,3518
3
+ patch_package_py/core.py,sha256=_5KCnh30HiSc2WsAr8XdHjVUWsm-Mqe934IoDuBqS8Q,7338
4
+ patch_package_py-0.1.0.dist-info/METADATA,sha256=CDcSahgN5ctu4qRNMv70R1OILjHjWyI6zGNJu8Okc_0,2900
5
+ patch_package_py-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
6
+ patch_package_py-0.1.0.dist-info/entry_points.txt,sha256=5dr99rfwblqKup5172uzG4QcL2CDDz8JqoFWkTXc9M8,50
7
+ patch_package_py-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ p12y = patch_package_py.cli:cli