patchon 0.1.0__cp313-cp313-win_amd64.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.
- patchon/__init__.py +22 -0
- patchon/__main__.py +8 -0
- patchon/_native/__init__.py +65 -0
- patchon/_native/_pure.py +220 -0
- patchon/_native/_rust.py +65 -0
- patchon/_native/_rust_ext.cp313-win_amd64.pyd +0 -0
- patchon/cleanup.py +330 -0
- patchon/cli.py +348 -0
- patchon/config.py +82 -0
- patchon/core.py +363 -0
- patchon/discover.py +86 -0
- patchon/lock.py +85 -0
- patchon/models.py +49 -0
- patchon-0.1.0.dist-info/METADATA +214 -0
- patchon-0.1.0.dist-info/RECORD +19 -0
- patchon-0.1.0.dist-info/WHEEL +4 -0
- patchon-0.1.0.dist-info/entry_points.txt +2 -0
- patchon-0.1.0.dist-info/licenses/LICENSE +21 -0
- patchon-0.1.0.dist-info/sboms/patchon-rust.cyclonedx.json +1673 -0
patchon/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Patchon: Run Python scripts with temporary source-file hot patches."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
# Native backend info
|
|
6
|
+
from ._native import NATIVE_BACKEND
|
|
7
|
+
|
|
8
|
+
# Utilities
|
|
9
|
+
from .cleanup import check_status, cleanup_all
|
|
10
|
+
|
|
11
|
+
# Core components
|
|
12
|
+
from .core import PatchSession
|
|
13
|
+
from .lock import EnvironmentLock
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"NATIVE_BACKEND",
|
|
17
|
+
"EnvironmentLock",
|
|
18
|
+
"PatchSession",
|
|
19
|
+
"__version__",
|
|
20
|
+
"check_status",
|
|
21
|
+
"cleanup_all",
|
|
22
|
+
]
|
patchon/__main__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Native code abstraction layer.
|
|
2
|
+
|
|
3
|
+
This module provides a clean abstraction over native implementations:
|
|
4
|
+
- _rust: Rust extension (high performance)
|
|
5
|
+
- _pure: Pure Python fallback
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from patchon._native import batch_copy_files, PatchSession
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
# Try to import Rust extension first
|
|
14
|
+
# Fallback to pure Python if not available
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from ._rust import (
|
|
18
|
+
PatchSession,
|
|
19
|
+
acquire_file_lock,
|
|
20
|
+
atomic_write_with_backup,
|
|
21
|
+
batch_copy_files,
|
|
22
|
+
batch_restore,
|
|
23
|
+
calculate_file_hash,
|
|
24
|
+
cleanup_stale_locks,
|
|
25
|
+
fast_file_copy,
|
|
26
|
+
is_process_alive,
|
|
27
|
+
release_file_lock,
|
|
28
|
+
restore_from_backup,
|
|
29
|
+
scan_python_files,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
NATIVE_BACKEND = "rust"
|
|
33
|
+
except ImportError:
|
|
34
|
+
from ._pure import (
|
|
35
|
+
PatchSession,
|
|
36
|
+
acquire_file_lock,
|
|
37
|
+
atomic_write_with_backup,
|
|
38
|
+
batch_copy_files,
|
|
39
|
+
batch_restore,
|
|
40
|
+
calculate_file_hash,
|
|
41
|
+
cleanup_stale_locks,
|
|
42
|
+
fast_file_copy,
|
|
43
|
+
is_process_alive,
|
|
44
|
+
release_file_lock,
|
|
45
|
+
restore_from_backup,
|
|
46
|
+
scan_python_files,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
NATIVE_BACKEND = "pure"
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"NATIVE_BACKEND",
|
|
53
|
+
"PatchSession",
|
|
54
|
+
"acquire_file_lock",
|
|
55
|
+
"atomic_write_with_backup",
|
|
56
|
+
"batch_copy_files",
|
|
57
|
+
"batch_restore",
|
|
58
|
+
"calculate_file_hash",
|
|
59
|
+
"cleanup_stale_locks",
|
|
60
|
+
"fast_file_copy",
|
|
61
|
+
"is_process_alive",
|
|
62
|
+
"release_file_lock",
|
|
63
|
+
"restore_from_backup",
|
|
64
|
+
"scan_python_files",
|
|
65
|
+
]
|
patchon/_native/_pure.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Pure Python fallback implementation.
|
|
2
|
+
|
|
3
|
+
Provides same interface as Rust extension but with pure Python implementation
|
|
4
|
+
for maximum compatibility.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fcntl as _fcntl
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
FCNTL: Any = _fcntl
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def fast_file_copy(src: str, dst: str) -> None:
|
|
24
|
+
"""Copy file with metadata preservation."""
|
|
25
|
+
dst_path = Path(dst)
|
|
26
|
+
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
shutil.copy2(src, dst)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def batch_copy_files(operations: list[tuple[str, str]]) -> list[str | None]:
|
|
31
|
+
"""Execute multiple copy operations."""
|
|
32
|
+
results: list[str | None] = []
|
|
33
|
+
for src, dst in operations:
|
|
34
|
+
try:
|
|
35
|
+
fast_file_copy(src, dst)
|
|
36
|
+
results.append(None)
|
|
37
|
+
except Exception as e:
|
|
38
|
+
results.append(str(e))
|
|
39
|
+
return results
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def scan_python_files(dir: str, recursive: bool = True) -> list[str]:
|
|
43
|
+
"""Scan directory for Python files."""
|
|
44
|
+
result = []
|
|
45
|
+
path = Path(dir)
|
|
46
|
+
|
|
47
|
+
if recursive:
|
|
48
|
+
for py_file in path.rglob("*.py"):
|
|
49
|
+
result.append(str(py_file))
|
|
50
|
+
else:
|
|
51
|
+
for py_file in path.glob("*.py"):
|
|
52
|
+
result.append(str(py_file))
|
|
53
|
+
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def calculate_file_hash(path: str) -> int:
|
|
58
|
+
"""Calculate file hash for change detection."""
|
|
59
|
+
hasher = hashlib.blake2b(digest_size=8)
|
|
60
|
+
with Path(path).open("rb") as f:
|
|
61
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
62
|
+
hasher.update(chunk)
|
|
63
|
+
return int.from_bytes(hasher.digest(), "big")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def atomic_write_with_backup(target: str, content: str, backup_dir: str | None = None) -> str | None:
|
|
67
|
+
"""Atomically write file with backup creation."""
|
|
68
|
+
target_path = Path(target)
|
|
69
|
+
backup_path = None
|
|
70
|
+
|
|
71
|
+
if target_path.exists():
|
|
72
|
+
if backup_dir:
|
|
73
|
+
backup_path = Path(backup_dir) / f"{target_path.name}.{os.getpid()}.backup"
|
|
74
|
+
else:
|
|
75
|
+
backup_path = Path(tempfile.gettempdir()) / f"patchon_backup_{target_path.name}_{os.getpid()}"
|
|
76
|
+
shutil.copy2(target, backup_path)
|
|
77
|
+
|
|
78
|
+
# Atomic write
|
|
79
|
+
temp_path = target_path.with_suffix(".tmp")
|
|
80
|
+
temp_path.write_text(content)
|
|
81
|
+
temp_path.rename(target_path)
|
|
82
|
+
|
|
83
|
+
return str(backup_path) if backup_path else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def restore_from_backup(backup_path: str, target: str) -> None:
|
|
87
|
+
"""Restore file from backup."""
|
|
88
|
+
shutil.copy2(backup_path, target)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def batch_restore(backups: list[tuple[str, str]]) -> list[str | None]:
|
|
92
|
+
"""Execute multiple restore operations."""
|
|
93
|
+
results: list[str | None] = []
|
|
94
|
+
for backup, target in backups:
|
|
95
|
+
try:
|
|
96
|
+
restore_from_backup(backup, target)
|
|
97
|
+
results.append(None)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
results.append(f"Failed to restore {target}: {e}")
|
|
100
|
+
return results
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def acquire_file_lock(lock_path: str, timeout_secs: int = 30) -> int:
|
|
104
|
+
"""Acquire exclusive file lock."""
|
|
105
|
+
import time
|
|
106
|
+
|
|
107
|
+
start = time.time()
|
|
108
|
+
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT)
|
|
109
|
+
|
|
110
|
+
while time.time() - start < timeout_secs:
|
|
111
|
+
try:
|
|
112
|
+
FCNTL.flock(fd, FCNTL.LOCK_EX | FCNTL.LOCK_NB)
|
|
113
|
+
return fd
|
|
114
|
+
except OSError:
|
|
115
|
+
time.sleep(0.01)
|
|
116
|
+
|
|
117
|
+
os.close(fd)
|
|
118
|
+
raise TimeoutError(f"Failed to acquire lock within {timeout_secs}s")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def release_file_lock(fd: int) -> None:
|
|
122
|
+
"""Release file lock."""
|
|
123
|
+
FCNTL.flock(fd, FCNTL.LOCK_UN)
|
|
124
|
+
os.close(fd)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def is_process_alive(pid: int) -> bool:
|
|
128
|
+
"""Check if process is still running."""
|
|
129
|
+
try:
|
|
130
|
+
os.kill(pid, 0)
|
|
131
|
+
return True
|
|
132
|
+
except (OSError, ProcessLookupError):
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cleanup_stale_locks(lock_dir: str) -> int:
|
|
137
|
+
"""Remove stale lock files."""
|
|
138
|
+
cleaned = 0
|
|
139
|
+
lock_path = Path(lock_dir)
|
|
140
|
+
|
|
141
|
+
if not lock_path.exists():
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
for lock_file in lock_path.glob("*.lock"):
|
|
145
|
+
try:
|
|
146
|
+
fd = os.open(lock_file, os.O_RDWR)
|
|
147
|
+
try:
|
|
148
|
+
FCNTL.flock(fd, FCNTL.LOCK_EX | FCNTL.LOCK_NB)
|
|
149
|
+
# If we can acquire the lock, it's stale
|
|
150
|
+
FCNTL.flock(fd, FCNTL.LOCK_UN)
|
|
151
|
+
Path(lock_file).unlink()
|
|
152
|
+
cleaned += 1
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
finally:
|
|
156
|
+
os.close(fd)
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
return cleaned
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class PatchSession:
|
|
164
|
+
"""Pure Python implementation of patch session."""
|
|
165
|
+
|
|
166
|
+
def __init__(self) -> None:
|
|
167
|
+
self._applied: dict[str, str] = {}
|
|
168
|
+
self._lock_fd: int | None = None
|
|
169
|
+
|
|
170
|
+
def apply_patches(self, patches: list[tuple[str, str]]) -> list[tuple[str, str | None]]:
|
|
171
|
+
"""Apply patches and store backups."""
|
|
172
|
+
results = []
|
|
173
|
+
for source, target in patches:
|
|
174
|
+
content = Path(source).read_text()
|
|
175
|
+
backup = atomic_write_with_backup(target, content)
|
|
176
|
+
if backup:
|
|
177
|
+
self._applied[target] = backup
|
|
178
|
+
results.append((target, backup))
|
|
179
|
+
return results
|
|
180
|
+
|
|
181
|
+
def restore_all(self) -> list[tuple[str, bool]]:
|
|
182
|
+
"""Restore all applied patches."""
|
|
183
|
+
results = []
|
|
184
|
+
for target, backup in self._applied.items():
|
|
185
|
+
try:
|
|
186
|
+
restore_from_backup(backup, target)
|
|
187
|
+
results.append((target, True))
|
|
188
|
+
except Exception:
|
|
189
|
+
results.append((target, False))
|
|
190
|
+
return results
|
|
191
|
+
|
|
192
|
+
def patch_count(self) -> int:
|
|
193
|
+
"""Get number of applied patches."""
|
|
194
|
+
return len(self._applied)
|
|
195
|
+
|
|
196
|
+
def acquire_lock(self, lock_path: str) -> None:
|
|
197
|
+
"""Acquire session lock."""
|
|
198
|
+
self._lock_fd = acquire_file_lock(lock_path)
|
|
199
|
+
|
|
200
|
+
def release_lock(self) -> None:
|
|
201
|
+
"""Release session lock."""
|
|
202
|
+
if self._lock_fd is not None:
|
|
203
|
+
release_file_lock(self._lock_fd)
|
|
204
|
+
self._lock_fd = None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
__all__ = [
|
|
208
|
+
"PatchSession",
|
|
209
|
+
"acquire_file_lock",
|
|
210
|
+
"atomic_write_with_backup",
|
|
211
|
+
"batch_copy_files",
|
|
212
|
+
"batch_restore",
|
|
213
|
+
"calculate_file_hash",
|
|
214
|
+
"cleanup_stale_locks",
|
|
215
|
+
"fast_file_copy",
|
|
216
|
+
"is_process_alive",
|
|
217
|
+
"release_file_lock",
|
|
218
|
+
"restore_from_backup",
|
|
219
|
+
"scan_python_files",
|
|
220
|
+
]
|
patchon/_native/_rust.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Rust extension wrapper.
|
|
2
|
+
|
|
3
|
+
This module wraps the Rust extension and provides:
|
|
4
|
+
1. Python-friendly interface
|
|
5
|
+
2. Type hints
|
|
6
|
+
3. Error conversion
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# mypy: ignore-errors
|
|
10
|
+
# This module imports from the compiled Rust extension which mypy cannot analyze
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from patchon._native._rust_ext import (
|
|
18
|
+
PatchSessionRust as PatchSession,
|
|
19
|
+
)
|
|
20
|
+
from patchon._native._rust_ext import (
|
|
21
|
+
acquire_file_lock,
|
|
22
|
+
atomic_write_with_backup,
|
|
23
|
+
batch_copy_files,
|
|
24
|
+
batch_restore,
|
|
25
|
+
calculate_file_hash,
|
|
26
|
+
cleanup_stale_locks,
|
|
27
|
+
fast_file_copy,
|
|
28
|
+
is_process_alive,
|
|
29
|
+
release_file_lock,
|
|
30
|
+
restore_from_backup,
|
|
31
|
+
scan_python_files,
|
|
32
|
+
)
|
|
33
|
+
else:
|
|
34
|
+
# Import from Rust module (runtime)
|
|
35
|
+
from patchon._native._rust_ext import ( # type: ignore[import-not-found]
|
|
36
|
+
PatchSessionRust as PatchSession,
|
|
37
|
+
)
|
|
38
|
+
from patchon._native._rust_ext import (
|
|
39
|
+
acquire_file_lock,
|
|
40
|
+
atomic_write_with_backup,
|
|
41
|
+
batch_copy_files,
|
|
42
|
+
batch_restore,
|
|
43
|
+
calculate_file_hash,
|
|
44
|
+
cleanup_stale_locks,
|
|
45
|
+
fast_file_copy,
|
|
46
|
+
is_process_alive,
|
|
47
|
+
release_file_lock,
|
|
48
|
+
restore_from_backup,
|
|
49
|
+
scan_python_files,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"PatchSession",
|
|
54
|
+
"acquire_file_lock",
|
|
55
|
+
"atomic_write_with_backup",
|
|
56
|
+
"batch_copy_files",
|
|
57
|
+
"batch_restore",
|
|
58
|
+
"calculate_file_hash",
|
|
59
|
+
"cleanup_stale_locks",
|
|
60
|
+
"fast_file_copy",
|
|
61
|
+
"is_process_alive",
|
|
62
|
+
"release_file_lock",
|
|
63
|
+
"restore_from_backup",
|
|
64
|
+
"scan_python_files",
|
|
65
|
+
]
|
|
Binary file
|