patchshuttle 0.1.0a2__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.
- patchshuttle/__init__.py +98 -0
- patchshuttle/_diff.py +317 -0
- patchshuttle/_process.py +198 -0
- patchshuttle/_version.py +3 -0
- patchshuttle/actions/__init__.py +80 -0
- patchshuttle/actions/constructors.py +211 -0
- patchshuttle/actions/create.py +155 -0
- patchshuttle/actions/modify.py +174 -0
- patchshuttle/audit.py +588 -0
- patchshuttle/backup.py +712 -0
- patchshuttle/checks/__init__.py +37 -0
- patchshuttle/checks/constructors.py +67 -0
- patchshuttle/checks/runner.py +233 -0
- patchshuttle/cli.py +766 -0
- patchshuttle/config.py +247 -0
- patchshuttle/context.py +370 -0
- patchshuttle/errors.py +291 -0
- patchshuttle/execution.py +651 -0
- patchshuttle/formatters/__init__.py +25 -0
- patchshuttle/formatters/runner.py +240 -0
- patchshuttle/identifiers.py +20 -0
- patchshuttle/inventory.py +331 -0
- patchshuttle/logging.py +741 -0
- patchshuttle/models.py +496 -0
- patchshuttle/operations.py +292 -0
- patchshuttle/parser.py +243 -0
- patchshuttle/planner.py +1144 -0
- patchshuttle/policy.py +377 -0
- patchshuttle/py.typed +1 -0
- patchshuttle/registry.py +275 -0
- patchshuttle/resources/AI_GUIDE.md +163 -0
- patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
- patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
- patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
- patchshuttle/resources/__init__.py +1 -0
- patchshuttle/rollback.py +306 -0
- patchshuttle/runner.py +880 -0
- patchshuttle/verification.py +107 -0
- patchshuttle/workspace.py +382 -0
- patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
- patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
- patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
- patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
- patchshuttle-0.1.0a2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Declarative public action constructors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from patchshuttle.models import Action
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def tree(
|
|
9
|
+
path: str = ".",
|
|
10
|
+
*,
|
|
11
|
+
depth: int = 4,
|
|
12
|
+
max_entries: int = 500,
|
|
13
|
+
include_hidden: bool = False,
|
|
14
|
+
) -> Action:
|
|
15
|
+
return Action(
|
|
16
|
+
{
|
|
17
|
+
"tree": {
|
|
18
|
+
"path": path,
|
|
19
|
+
"depth": depth,
|
|
20
|
+
"max_entries": max_entries,
|
|
21
|
+
"include_hidden": include_hidden,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read(
|
|
28
|
+
path: str,
|
|
29
|
+
*,
|
|
30
|
+
start_line: int = 1,
|
|
31
|
+
end_line: int | None = None,
|
|
32
|
+
max_bytes: int | None = None,
|
|
33
|
+
) -> Action:
|
|
34
|
+
parameters = {"path": path, "start_line": start_line}
|
|
35
|
+
if end_line is not None:
|
|
36
|
+
parameters["end_line"] = end_line
|
|
37
|
+
if max_bytes is not None:
|
|
38
|
+
parameters["max_bytes"] = max_bytes
|
|
39
|
+
return Action({"read": parameters})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def search(
|
|
43
|
+
text: str,
|
|
44
|
+
*,
|
|
45
|
+
path: str = ".",
|
|
46
|
+
glob: str | None = None,
|
|
47
|
+
case_sensitive: bool = True,
|
|
48
|
+
max_results: int = 200,
|
|
49
|
+
) -> Action:
|
|
50
|
+
parameters = {
|
|
51
|
+
"path": path,
|
|
52
|
+
"text": text,
|
|
53
|
+
"case_sensitive": case_sensitive,
|
|
54
|
+
"max_results": max_results,
|
|
55
|
+
}
|
|
56
|
+
if glob is not None:
|
|
57
|
+
parameters["glob"] = glob
|
|
58
|
+
return Action({"search": parameters})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def find_files(
|
|
62
|
+
glob: str,
|
|
63
|
+
*,
|
|
64
|
+
path: str = ".",
|
|
65
|
+
max_results: int = 500,
|
|
66
|
+
) -> Action:
|
|
67
|
+
return Action(
|
|
68
|
+
{
|
|
69
|
+
"find_files": {
|
|
70
|
+
"path": path,
|
|
71
|
+
"glob": glob,
|
|
72
|
+
"max_results": max_results,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def file_info(path: str) -> Action:
|
|
79
|
+
return Action({"file_info": {"path": path}})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def hash(path: str, *, algorithm: str = "sha256") -> Action:
|
|
83
|
+
return Action({"hash": {"path": path, "algorithm": algorithm}})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def git_status() -> Action:
|
|
87
|
+
return Action({"git_status": {}})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def environment() -> Action:
|
|
91
|
+
return Action({"environment": {}})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def create_directory(path: str) -> Action:
|
|
95
|
+
return Action({"create_directory": {"path": path}})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def create_file(
|
|
99
|
+
path: str,
|
|
100
|
+
content: str,
|
|
101
|
+
*,
|
|
102
|
+
encoding: str = "utf-8",
|
|
103
|
+
newline: str = "lf",
|
|
104
|
+
) -> Action:
|
|
105
|
+
return Action(
|
|
106
|
+
{
|
|
107
|
+
"create_file": {
|
|
108
|
+
"path": path,
|
|
109
|
+
"content": content,
|
|
110
|
+
"encoding": encoding,
|
|
111
|
+
"newline": newline,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def replace_exact(
|
|
118
|
+
path: str,
|
|
119
|
+
old: str,
|
|
120
|
+
new: str,
|
|
121
|
+
*,
|
|
122
|
+
expected_count: int = 1,
|
|
123
|
+
) -> Action:
|
|
124
|
+
return Action(
|
|
125
|
+
{
|
|
126
|
+
"replace_exact": {
|
|
127
|
+
"path": path,
|
|
128
|
+
"old": old,
|
|
129
|
+
"new": new,
|
|
130
|
+
"expected_count": expected_count,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def insert_before(
|
|
137
|
+
path: str,
|
|
138
|
+
anchor: str,
|
|
139
|
+
content: str,
|
|
140
|
+
*,
|
|
141
|
+
expected_count: int = 1,
|
|
142
|
+
) -> Action:
|
|
143
|
+
return Action(
|
|
144
|
+
{
|
|
145
|
+
"insert_before": {
|
|
146
|
+
"path": path,
|
|
147
|
+
"anchor": anchor,
|
|
148
|
+
"content": content,
|
|
149
|
+
"expected_count": expected_count,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def insert_after(
|
|
156
|
+
path: str,
|
|
157
|
+
anchor: str,
|
|
158
|
+
content: str,
|
|
159
|
+
*,
|
|
160
|
+
expected_count: int = 1,
|
|
161
|
+
) -> Action:
|
|
162
|
+
return Action(
|
|
163
|
+
{
|
|
164
|
+
"insert_after": {
|
|
165
|
+
"path": path,
|
|
166
|
+
"anchor": anchor,
|
|
167
|
+
"content": content,
|
|
168
|
+
"expected_count": expected_count,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def delete_exact(
|
|
175
|
+
path: str,
|
|
176
|
+
text: str,
|
|
177
|
+
*,
|
|
178
|
+
expected_count: int = 1,
|
|
179
|
+
) -> Action:
|
|
180
|
+
return Action(
|
|
181
|
+
{
|
|
182
|
+
"delete_exact": {
|
|
183
|
+
"path": path,
|
|
184
|
+
"text": text,
|
|
185
|
+
"expected_count": expected_count,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def apply_diff(diff: str, *, strip: int = 1) -> Action:
|
|
192
|
+
return Action({"apply_diff": {"diff": diff, "strip": strip}})
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
__all__ = [
|
|
196
|
+
"apply_diff",
|
|
197
|
+
"create_directory",
|
|
198
|
+
"create_file",
|
|
199
|
+
"delete_exact",
|
|
200
|
+
"environment",
|
|
201
|
+
"file_info",
|
|
202
|
+
"find_files",
|
|
203
|
+
"git_status",
|
|
204
|
+
"hash",
|
|
205
|
+
"insert_after",
|
|
206
|
+
"insert_before",
|
|
207
|
+
"read",
|
|
208
|
+
"replace_exact",
|
|
209
|
+
"search",
|
|
210
|
+
"tree",
|
|
211
|
+
]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Race-resistant creation primitives for already-approved plans."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
|
|
11
|
+
from patchshuttle.planner import PlannedFileChange
|
|
12
|
+
from patchshuttle.policy import PathKind, Policy
|
|
13
|
+
from patchshuttle.workspace import Workspace
|
|
14
|
+
|
|
15
|
+
_LINK_FALLBACK_ERRORS = frozenset(
|
|
16
|
+
value
|
|
17
|
+
for value in (
|
|
18
|
+
errno.EPERM,
|
|
19
|
+
errno.EXDEV,
|
|
20
|
+
getattr(errno, "ENOTSUP", None),
|
|
21
|
+
getattr(errno, "EOPNOTSUPP", None),
|
|
22
|
+
)
|
|
23
|
+
if value is not None
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FilePublishError(OSError):
|
|
28
|
+
"""A publication failure that reports which temporary paths now exist."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
message: str,
|
|
33
|
+
*,
|
|
34
|
+
target_created: bool,
|
|
35
|
+
temporary_path: Path | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.target_created = target_created
|
|
38
|
+
self.temporary_path = temporary_path
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def create_directory(workspace: Workspace, path: PurePosixPath) -> None:
|
|
43
|
+
"""Create one missing planned directory without accepting races."""
|
|
44
|
+
|
|
45
|
+
target = Policy(workspace).resolve(path, allow_missing=True)
|
|
46
|
+
if target.kind is not PathKind.MISSING:
|
|
47
|
+
raise FileExistsError(path.as_posix())
|
|
48
|
+
target.absolute.mkdir()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def verify_created_directory(workspace: Workspace, path: PurePosixPath) -> None:
|
|
52
|
+
"""Require the post-state promised by a directory creation action."""
|
|
53
|
+
|
|
54
|
+
target = Policy(workspace).resolve(path, allow_missing=True)
|
|
55
|
+
if target.kind is not PathKind.DIRECTORY:
|
|
56
|
+
raise OSError(f"created directory has an unexpected post-state: {path}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def atomic_create_file(workspace: Workspace, change: PlannedFileChange) -> None:
|
|
60
|
+
"""Publish a complete new file without replacing an existing target."""
|
|
61
|
+
|
|
62
|
+
policy = Policy(workspace)
|
|
63
|
+
target = policy.resolve(change.path, allow_missing=True)
|
|
64
|
+
if target.kind is not PathKind.MISSING:
|
|
65
|
+
raise FileExistsError(change.path.as_posix())
|
|
66
|
+
parent = policy.resolve(change.path.parent, allow_root=True)
|
|
67
|
+
|
|
68
|
+
temporary = parent.absolute / f".patchshuttle-{uuid.uuid4().hex}.tmp"
|
|
69
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666)
|
|
70
|
+
published = False
|
|
71
|
+
pending: BaseException | None = None
|
|
72
|
+
try:
|
|
73
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
74
|
+
stream.write(change.content)
|
|
75
|
+
stream.flush()
|
|
76
|
+
os.fsync(stream.fileno())
|
|
77
|
+
try:
|
|
78
|
+
os.link(temporary, target.absolute)
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
if exc.errno not in _LINK_FALLBACK_ERRORS:
|
|
81
|
+
raise
|
|
82
|
+
_exclusive_copy(target.absolute, change.content)
|
|
83
|
+
published = True
|
|
84
|
+
except BaseException as exc:
|
|
85
|
+
pending = exc
|
|
86
|
+
|
|
87
|
+
cleanup_error: OSError | None = None
|
|
88
|
+
try:
|
|
89
|
+
temporary.unlink()
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
pass
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
cleanup_error = exc
|
|
94
|
+
|
|
95
|
+
if cleanup_error is not None:
|
|
96
|
+
target_created = published or (
|
|
97
|
+
isinstance(pending, FilePublishError) and pending.target_created
|
|
98
|
+
)
|
|
99
|
+
raise FilePublishError(
|
|
100
|
+
"temporary create-file data could not be removed",
|
|
101
|
+
target_created=target_created,
|
|
102
|
+
temporary_path=temporary,
|
|
103
|
+
) from (pending or cleanup_error)
|
|
104
|
+
if pending is not None:
|
|
105
|
+
raise pending
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def verify_created_file(workspace: Workspace, change: PlannedFileChange) -> None:
|
|
109
|
+
"""Require exact bytes, length, and hash after a create-file action."""
|
|
110
|
+
|
|
111
|
+
target = Policy(workspace).resolve(change.path, allow_missing=True)
|
|
112
|
+
if target.kind is not PathKind.FILE:
|
|
113
|
+
raise OSError(f"created file has an unexpected post-state: {change.path}")
|
|
114
|
+
raw = target.absolute.read_bytes()
|
|
115
|
+
if (
|
|
116
|
+
len(raw) != change.after_size
|
|
117
|
+
or hashlib.sha256(raw).hexdigest() != change.after_sha256
|
|
118
|
+
or raw != change.content
|
|
119
|
+
):
|
|
120
|
+
raise OSError(
|
|
121
|
+
f"created file content failed post-state validation: {change.path}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _exclusive_copy(target: Path, content: bytes) -> None:
|
|
126
|
+
descriptor = os.open(
|
|
127
|
+
target,
|
|
128
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
129
|
+
0o666,
|
|
130
|
+
)
|
|
131
|
+
try:
|
|
132
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
133
|
+
stream.write(content)
|
|
134
|
+
stream.flush()
|
|
135
|
+
os.fsync(stream.fileno())
|
|
136
|
+
except BaseException as exc:
|
|
137
|
+
try:
|
|
138
|
+
target.unlink()
|
|
139
|
+
except FileNotFoundError:
|
|
140
|
+
pass
|
|
141
|
+
except OSError as cleanup_error:
|
|
142
|
+
raise FilePublishError(
|
|
143
|
+
"a partial create-file target could not be removed",
|
|
144
|
+
target_created=True,
|
|
145
|
+
) from cleanup_error
|
|
146
|
+
raise exc
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = [
|
|
150
|
+
"FilePublishError",
|
|
151
|
+
"atomic_create_file",
|
|
152
|
+
"create_directory",
|
|
153
|
+
"verify_created_directory",
|
|
154
|
+
"verify_created_file",
|
|
155
|
+
]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Atomic replacement primitives for planned existing-file changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
|
|
12
|
+
from patchshuttle.planner import PlannedFileChange
|
|
13
|
+
from patchshuttle.policy import PathKind, Policy, WorkspacePath
|
|
14
|
+
from patchshuttle.workspace import Workspace
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FileReplaceError(OSError):
|
|
18
|
+
"""A replacement failure with retained temporary-path context."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
message: str,
|
|
23
|
+
*,
|
|
24
|
+
target_modified: bool,
|
|
25
|
+
temporary_path: Path | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.target_modified = target_modified
|
|
28
|
+
self.temporary_path = temporary_path
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def atomic_replace_file(
|
|
33
|
+
workspace: Workspace,
|
|
34
|
+
change: PlannedFileChange,
|
|
35
|
+
*,
|
|
36
|
+
mode: int,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Replace a still-matching existing file with its planned final bytes."""
|
|
39
|
+
|
|
40
|
+
policy = Policy(workspace)
|
|
41
|
+
target = _require_file(policy, change.path)
|
|
42
|
+
_require_planned_original(target, change)
|
|
43
|
+
_atomic_replace_bytes(
|
|
44
|
+
target.absolute,
|
|
45
|
+
change.content,
|
|
46
|
+
mode=mode,
|
|
47
|
+
before_publish=lambda: _require_planned_original(
|
|
48
|
+
_require_file(policy, change.path),
|
|
49
|
+
change,
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def verify_modified_file(
|
|
55
|
+
workspace: Workspace,
|
|
56
|
+
change: PlannedFileChange,
|
|
57
|
+
*,
|
|
58
|
+
mode: int,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Require exact planned bytes and preserved mode after replacement."""
|
|
61
|
+
|
|
62
|
+
target = _require_file(Policy(workspace), change.path)
|
|
63
|
+
raw = target.absolute.read_bytes()
|
|
64
|
+
if (
|
|
65
|
+
len(raw) != change.after_size
|
|
66
|
+
or hashlib.sha256(raw).hexdigest() != change.after_sha256
|
|
67
|
+
or raw != change.content
|
|
68
|
+
or stat.S_IMODE(target.absolute.lstat().st_mode) != mode
|
|
69
|
+
):
|
|
70
|
+
raise OSError(f"modified file failed post-state validation: {change.path}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def atomic_restore_file(
|
|
74
|
+
workspace: Workspace,
|
|
75
|
+
path: PurePosixPath,
|
|
76
|
+
content: bytes,
|
|
77
|
+
*,
|
|
78
|
+
mode: int,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Restore retained bytes to a regular or unexpectedly missing target."""
|
|
81
|
+
|
|
82
|
+
policy = Policy(workspace)
|
|
83
|
+
target = policy.resolve(path, allow_missing=True)
|
|
84
|
+
if target.kind not in {PathKind.FILE, PathKind.MISSING}:
|
|
85
|
+
raise OSError(f"rollback target has an unexpected type: {path}")
|
|
86
|
+
parent = policy.resolve(path.parent, allow_root=True)
|
|
87
|
+
_atomic_replace_bytes(parent.absolute / path.name, content, mode=mode)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def verify_restored_file(
|
|
91
|
+
workspace: Workspace,
|
|
92
|
+
path: PurePosixPath,
|
|
93
|
+
content: bytes,
|
|
94
|
+
*,
|
|
95
|
+
mode: int,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Require exact original bytes and mode after a rollback restoration."""
|
|
98
|
+
|
|
99
|
+
target = _require_file(Policy(workspace), path)
|
|
100
|
+
raw = target.absolute.read_bytes()
|
|
101
|
+
if raw != content or stat.S_IMODE(target.absolute.lstat().st_mode) != mode:
|
|
102
|
+
raise OSError(f"restored file failed post-state validation: {path}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _require_file(policy: Policy, path: PurePosixPath) -> WorkspacePath:
|
|
106
|
+
target = policy.resolve(path, allow_missing=True)
|
|
107
|
+
if target.kind is not PathKind.FILE:
|
|
108
|
+
raise OSError(f"planned modification target is not a regular file: {path}")
|
|
109
|
+
return target
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _require_planned_original(
|
|
113
|
+
target: WorkspacePath,
|
|
114
|
+
change: PlannedFileChange,
|
|
115
|
+
) -> None:
|
|
116
|
+
if change.before_size is None or change.before_sha256 is None:
|
|
117
|
+
raise OSError(f"planned modification lacks an original hash: {change.path}")
|
|
118
|
+
raw = target.absolute.read_bytes()
|
|
119
|
+
if (
|
|
120
|
+
len(raw) != change.before_size
|
|
121
|
+
or hashlib.sha256(raw).hexdigest() != change.before_sha256
|
|
122
|
+
):
|
|
123
|
+
raise OSError(f"modification target changed after planning: {change.path}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _atomic_replace_bytes(
|
|
127
|
+
target: Path,
|
|
128
|
+
content: bytes,
|
|
129
|
+
*,
|
|
130
|
+
mode: int,
|
|
131
|
+
before_publish: Callable[[], None] | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
temporary = target.parent / f".patchshuttle-{uuid.uuid4().hex}.tmp"
|
|
134
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
135
|
+
replaced = False
|
|
136
|
+
pending: BaseException | None = None
|
|
137
|
+
try:
|
|
138
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
139
|
+
stream.write(content)
|
|
140
|
+
stream.flush()
|
|
141
|
+
os.fsync(stream.fileno())
|
|
142
|
+
temporary.chmod(mode)
|
|
143
|
+
if before_publish is not None:
|
|
144
|
+
before_publish()
|
|
145
|
+
os.replace(temporary, target)
|
|
146
|
+
replaced = True
|
|
147
|
+
except BaseException as exc:
|
|
148
|
+
pending = exc
|
|
149
|
+
|
|
150
|
+
cleanup_error: OSError | None = None
|
|
151
|
+
try:
|
|
152
|
+
temporary.unlink()
|
|
153
|
+
except FileNotFoundError:
|
|
154
|
+
pass
|
|
155
|
+
except OSError as exc:
|
|
156
|
+
cleanup_error = exc
|
|
157
|
+
|
|
158
|
+
if cleanup_error is not None:
|
|
159
|
+
raise FileReplaceError(
|
|
160
|
+
"temporary replacement data could not be removed",
|
|
161
|
+
target_modified=replaced,
|
|
162
|
+
temporary_path=temporary,
|
|
163
|
+
) from (pending or cleanup_error)
|
|
164
|
+
if pending is not None:
|
|
165
|
+
raise pending
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
__all__ = [
|
|
169
|
+
"FileReplaceError",
|
|
170
|
+
"atomic_replace_file",
|
|
171
|
+
"atomic_restore_file",
|
|
172
|
+
"verify_modified_file",
|
|
173
|
+
"verify_restored_file",
|
|
174
|
+
]
|