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
patchshuttle/__init__.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Public package surface for PatchShuttle."""
|
|
2
|
+
|
|
3
|
+
from patchshuttle._version import __version__
|
|
4
|
+
from patchshuttle.audit import AuditActionResult, AuditRunResult, AuditStatus
|
|
5
|
+
from patchshuttle.context import (
|
|
6
|
+
HandoffResult,
|
|
7
|
+
SnapshotResult,
|
|
8
|
+
create_handoff,
|
|
9
|
+
create_snapshot,
|
|
10
|
+
)
|
|
11
|
+
from patchshuttle.errors import (
|
|
12
|
+
ExecutionError,
|
|
13
|
+
ExecutionErrorCode,
|
|
14
|
+
JobError,
|
|
15
|
+
JobErrorCode,
|
|
16
|
+
PlanningError,
|
|
17
|
+
PlanningErrorCode,
|
|
18
|
+
PolicyError,
|
|
19
|
+
PolicyErrorCode,
|
|
20
|
+
WorkspaceError,
|
|
21
|
+
WorkspaceErrorCode,
|
|
22
|
+
)
|
|
23
|
+
from patchshuttle.execution import RunResult, RunStatus, execute_plan
|
|
24
|
+
from patchshuttle.models import Action, Check, Job, JobKind
|
|
25
|
+
from patchshuttle.operations import ManualRollbackResult, rollback_job
|
|
26
|
+
from patchshuttle.parser import load_job, validate_job
|
|
27
|
+
from patchshuttle.planner import (
|
|
28
|
+
ActionDisposition,
|
|
29
|
+
FileDisposition,
|
|
30
|
+
NewlineStyle,
|
|
31
|
+
PathFingerprint,
|
|
32
|
+
Plan,
|
|
33
|
+
PlannedAction,
|
|
34
|
+
PlannedCheck,
|
|
35
|
+
PlannedFileChange,
|
|
36
|
+
plan_job,
|
|
37
|
+
)
|
|
38
|
+
from patchshuttle.policy import PathKind, Policy, WorkspacePath
|
|
39
|
+
from patchshuttle.verification import VerificationRunResult
|
|
40
|
+
from patchshuttle.workspace import (
|
|
41
|
+
Workspace,
|
|
42
|
+
WorkspaceInitResult,
|
|
43
|
+
WorkspaceInitStatus,
|
|
44
|
+
discover_workspace,
|
|
45
|
+
init_workspace,
|
|
46
|
+
load_workspace,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"Action",
|
|
51
|
+
"ActionDisposition",
|
|
52
|
+
"AuditActionResult",
|
|
53
|
+
"AuditRunResult",
|
|
54
|
+
"AuditStatus",
|
|
55
|
+
"Check",
|
|
56
|
+
"ExecutionError",
|
|
57
|
+
"ExecutionErrorCode",
|
|
58
|
+
"FileDisposition",
|
|
59
|
+
"HandoffResult",
|
|
60
|
+
"Job",
|
|
61
|
+
"JobError",
|
|
62
|
+
"JobErrorCode",
|
|
63
|
+
"JobKind",
|
|
64
|
+
"ManualRollbackResult",
|
|
65
|
+
"NewlineStyle",
|
|
66
|
+
"PathKind",
|
|
67
|
+
"PathFingerprint",
|
|
68
|
+
"Plan",
|
|
69
|
+
"PlannedAction",
|
|
70
|
+
"PlannedCheck",
|
|
71
|
+
"PlannedFileChange",
|
|
72
|
+
"PlanningError",
|
|
73
|
+
"PlanningErrorCode",
|
|
74
|
+
"Policy",
|
|
75
|
+
"PolicyError",
|
|
76
|
+
"PolicyErrorCode",
|
|
77
|
+
"RunResult",
|
|
78
|
+
"RunStatus",
|
|
79
|
+
"SnapshotResult",
|
|
80
|
+
"VerificationRunResult",
|
|
81
|
+
"Workspace",
|
|
82
|
+
"WorkspaceError",
|
|
83
|
+
"WorkspaceErrorCode",
|
|
84
|
+
"WorkspaceInitResult",
|
|
85
|
+
"WorkspaceInitStatus",
|
|
86
|
+
"WorkspacePath",
|
|
87
|
+
"__version__",
|
|
88
|
+
"create_handoff",
|
|
89
|
+
"create_snapshot",
|
|
90
|
+
"discover_workspace",
|
|
91
|
+
"execute_plan",
|
|
92
|
+
"init_workspace",
|
|
93
|
+
"load_job",
|
|
94
|
+
"load_workspace",
|
|
95
|
+
"plan_job",
|
|
96
|
+
"rollback_job",
|
|
97
|
+
"validate_job",
|
|
98
|
+
]
|
patchshuttle/_diff.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Strict in-process unified-diff parsing and dry-run application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import PurePosixPath
|
|
8
|
+
|
|
9
|
+
from patchshuttle.errors import PlanningError, PlanningErrorCode
|
|
10
|
+
|
|
11
|
+
_HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?:.*)?(?:\n)?$")
|
|
12
|
+
_BINARY_MARKERS = ("Binary files ", "GIT binary patch")
|
|
13
|
+
_FORBIDDEN_METADATA = (
|
|
14
|
+
"new file mode ",
|
|
15
|
+
"deleted file mode ",
|
|
16
|
+
"old mode ",
|
|
17
|
+
"new mode ",
|
|
18
|
+
"similarity index ",
|
|
19
|
+
"rename from ",
|
|
20
|
+
"rename to ",
|
|
21
|
+
"copy from ",
|
|
22
|
+
"copy to ",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class DiffHunk:
|
|
28
|
+
old_start: int
|
|
29
|
+
old_count: int
|
|
30
|
+
new_start: int
|
|
31
|
+
new_count: int
|
|
32
|
+
old_lines: tuple[str, ...]
|
|
33
|
+
new_lines: tuple[str, ...]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class FileDiff:
|
|
38
|
+
path: str
|
|
39
|
+
hunks: tuple[DiffHunk, ...]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_unified_diff(
|
|
43
|
+
value: str,
|
|
44
|
+
*,
|
|
45
|
+
strip: int,
|
|
46
|
+
item_id: str,
|
|
47
|
+
) -> tuple[FileDiff, ...]:
|
|
48
|
+
"""Parse a text-only, existing-file unified diff without side effects."""
|
|
49
|
+
|
|
50
|
+
normalized = value.replace("\r\n", "\n").replace("\r", "\n")
|
|
51
|
+
lines = normalized.splitlines(keepends=True)
|
|
52
|
+
if not lines:
|
|
53
|
+
raise _error(
|
|
54
|
+
PlanningErrorCode.DIFF_INVALID,
|
|
55
|
+
"unified diff is empty",
|
|
56
|
+
item_id,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
parsed: list[FileDiff] = []
|
|
60
|
+
index = 0
|
|
61
|
+
while index < len(lines):
|
|
62
|
+
line = lines[index]
|
|
63
|
+
if line.startswith(_BINARY_MARKERS):
|
|
64
|
+
raise _error(
|
|
65
|
+
PlanningErrorCode.DIFF_BINARY_FORBIDDEN,
|
|
66
|
+
"binary diffs are not allowed",
|
|
67
|
+
item_id,
|
|
68
|
+
)
|
|
69
|
+
if line.startswith(_FORBIDDEN_METADATA):
|
|
70
|
+
raise _error(
|
|
71
|
+
PlanningErrorCode.DIFF_PATH_INVALID,
|
|
72
|
+
"file creation, deletion, rename, copy, and mode changes are forbidden",
|
|
73
|
+
item_id,
|
|
74
|
+
)
|
|
75
|
+
if line.startswith("diff --git ") or line.startswith("index "):
|
|
76
|
+
index += 1
|
|
77
|
+
continue
|
|
78
|
+
if not line.startswith("--- "):
|
|
79
|
+
raise _error(
|
|
80
|
+
PlanningErrorCode.DIFF_INVALID,
|
|
81
|
+
"expected an old-file header beginning with '--- '",
|
|
82
|
+
item_id,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
old_path = _parse_header_path(line, strip=strip, item_id=item_id)
|
|
86
|
+
index += 1
|
|
87
|
+
if index >= len(lines) or not lines[index].startswith("+++ "):
|
|
88
|
+
raise _error(
|
|
89
|
+
PlanningErrorCode.DIFF_INVALID,
|
|
90
|
+
"old-file header must be followed by a new-file header",
|
|
91
|
+
item_id,
|
|
92
|
+
)
|
|
93
|
+
new_path = _parse_header_path(lines[index], strip=strip, item_id=item_id)
|
|
94
|
+
if old_path != new_path:
|
|
95
|
+
raise _error(
|
|
96
|
+
PlanningErrorCode.DIFF_PATH_INVALID,
|
|
97
|
+
"unified diff may not rename or copy files",
|
|
98
|
+
item_id,
|
|
99
|
+
path=new_path,
|
|
100
|
+
)
|
|
101
|
+
index += 1
|
|
102
|
+
|
|
103
|
+
hunks: list[DiffHunk] = []
|
|
104
|
+
while index < len(lines) and lines[index].startswith("@@ "):
|
|
105
|
+
hunk, index = _parse_hunk(lines, index, item_id=item_id)
|
|
106
|
+
hunks.append(hunk)
|
|
107
|
+
if not hunks:
|
|
108
|
+
raise _error(
|
|
109
|
+
PlanningErrorCode.DIFF_INVALID,
|
|
110
|
+
"each file diff requires at least one hunk",
|
|
111
|
+
item_id,
|
|
112
|
+
path=new_path,
|
|
113
|
+
)
|
|
114
|
+
if any(existing.path == new_path for existing in parsed):
|
|
115
|
+
raise _error(
|
|
116
|
+
PlanningErrorCode.DIFF_INVALID,
|
|
117
|
+
"a unified diff may contain each target file only once",
|
|
118
|
+
item_id,
|
|
119
|
+
path=new_path,
|
|
120
|
+
)
|
|
121
|
+
parsed.append(FileDiff(path=new_path, hunks=tuple(hunks)))
|
|
122
|
+
|
|
123
|
+
if not parsed:
|
|
124
|
+
raise _error(
|
|
125
|
+
PlanningErrorCode.DIFF_INVALID,
|
|
126
|
+
"unified diff does not contain a file patch",
|
|
127
|
+
item_id,
|
|
128
|
+
)
|
|
129
|
+
return tuple(parsed)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def apply_file_diff(text: str, diff: FileDiff, *, item_id: str) -> str:
|
|
133
|
+
"""Apply parsed hunks to normalized LF text in memory."""
|
|
134
|
+
|
|
135
|
+
source = text.splitlines(keepends=True)
|
|
136
|
+
output: list[str] = []
|
|
137
|
+
cursor = 0
|
|
138
|
+
for hunk in diff.hunks:
|
|
139
|
+
start = hunk.old_start if hunk.old_count == 0 else hunk.old_start - 1
|
|
140
|
+
end = start + len(hunk.old_lines)
|
|
141
|
+
if start < cursor or end > len(source):
|
|
142
|
+
raise _error(
|
|
143
|
+
PlanningErrorCode.DIFF_HUNK_MISMATCH,
|
|
144
|
+
"hunk range does not match the current file",
|
|
145
|
+
item_id,
|
|
146
|
+
path=diff.path,
|
|
147
|
+
)
|
|
148
|
+
if tuple(source[start:end]) != hunk.old_lines:
|
|
149
|
+
raise _error(
|
|
150
|
+
PlanningErrorCode.DIFF_HUNK_MISMATCH,
|
|
151
|
+
"hunk context does not match the current file",
|
|
152
|
+
item_id,
|
|
153
|
+
path=diff.path,
|
|
154
|
+
)
|
|
155
|
+
output.extend(source[cursor:start])
|
|
156
|
+
output.extend(hunk.new_lines)
|
|
157
|
+
cursor = end
|
|
158
|
+
output.extend(source[cursor:])
|
|
159
|
+
return "".join(output)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _parse_header_path(line: str, *, strip: int, item_id: str) -> str:
|
|
163
|
+
raw = line[4:].rstrip("\n").split("\t", 1)[0]
|
|
164
|
+
if raw == "/dev/null":
|
|
165
|
+
raise _error(
|
|
166
|
+
PlanningErrorCode.DIFF_PATH_INVALID,
|
|
167
|
+
"file creation and deletion diffs are not allowed",
|
|
168
|
+
item_id,
|
|
169
|
+
)
|
|
170
|
+
if not raw or raw.startswith('"'):
|
|
171
|
+
raise _error(
|
|
172
|
+
PlanningErrorCode.DIFF_PATH_INVALID,
|
|
173
|
+
"quoted or empty diff paths are not supported",
|
|
174
|
+
item_id,
|
|
175
|
+
)
|
|
176
|
+
if raw.startswith(("/", "\\")):
|
|
177
|
+
raise _error(
|
|
178
|
+
PlanningErrorCode.DIFF_PATH_INVALID,
|
|
179
|
+
"absolute diff paths are not allowed",
|
|
180
|
+
item_id,
|
|
181
|
+
path=raw,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
parts = raw.replace("\\", "/").split("/")
|
|
185
|
+
if len(parts) <= strip:
|
|
186
|
+
raise _error(
|
|
187
|
+
PlanningErrorCode.DIFF_PATH_INVALID,
|
|
188
|
+
f"diff path has fewer than {strip + 1} component(s)",
|
|
189
|
+
item_id,
|
|
190
|
+
path=raw,
|
|
191
|
+
)
|
|
192
|
+
stripped = PurePosixPath(*parts[strip:]).as_posix()
|
|
193
|
+
return stripped
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_hunk(
|
|
197
|
+
lines: list[str],
|
|
198
|
+
index: int,
|
|
199
|
+
*,
|
|
200
|
+
item_id: str,
|
|
201
|
+
) -> tuple[DiffHunk, int]:
|
|
202
|
+
match = _HUNK_HEADER.fullmatch(lines[index])
|
|
203
|
+
if match is None:
|
|
204
|
+
raise _error(
|
|
205
|
+
PlanningErrorCode.DIFF_INVALID,
|
|
206
|
+
"invalid unified-diff hunk header",
|
|
207
|
+
item_id,
|
|
208
|
+
)
|
|
209
|
+
old_start = int(match.group(1))
|
|
210
|
+
old_count = int(match.group(2) or 1)
|
|
211
|
+
new_start = int(match.group(3))
|
|
212
|
+
new_count = int(match.group(4) or 1)
|
|
213
|
+
if (old_start == 0 and old_count != 0) or (new_start == 0 and new_count != 0):
|
|
214
|
+
raise _error(
|
|
215
|
+
PlanningErrorCode.DIFF_INVALID,
|
|
216
|
+
"a zero hunk start is valid only for an empty range",
|
|
217
|
+
item_id,
|
|
218
|
+
)
|
|
219
|
+
index += 1
|
|
220
|
+
old_lines: list[str] = []
|
|
221
|
+
new_lines: list[str] = []
|
|
222
|
+
previous_prefix: str | None = None
|
|
223
|
+
|
|
224
|
+
while index < len(lines):
|
|
225
|
+
line = lines[index]
|
|
226
|
+
if line.startswith(("@@ ", "--- ", "diff --git ")):
|
|
227
|
+
break
|
|
228
|
+
if line.startswith(_BINARY_MARKERS) or line.startswith(_FORBIDDEN_METADATA):
|
|
229
|
+
break
|
|
230
|
+
if line.startswith("\\"):
|
|
231
|
+
if line.rstrip("\n") != "\":
|
|
232
|
+
raise _error(
|
|
233
|
+
PlanningErrorCode.DIFF_INVALID,
|
|
234
|
+
"unknown backslash marker in unified diff",
|
|
235
|
+
item_id,
|
|
236
|
+
)
|
|
237
|
+
_remove_last_newline(
|
|
238
|
+
old_lines,
|
|
239
|
+
new_lines,
|
|
240
|
+
previous_prefix=previous_prefix,
|
|
241
|
+
item_id=item_id,
|
|
242
|
+
)
|
|
243
|
+
index += 1
|
|
244
|
+
continue
|
|
245
|
+
if not line or line[0] not in " +-":
|
|
246
|
+
raise _error(
|
|
247
|
+
PlanningErrorCode.DIFF_INVALID,
|
|
248
|
+
"hunk lines must begin with space, '+', or '-'",
|
|
249
|
+
item_id,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
previous_prefix = line[0]
|
|
253
|
+
content = line[1:]
|
|
254
|
+
if previous_prefix in " -":
|
|
255
|
+
old_lines.append(content)
|
|
256
|
+
if previous_prefix in " +":
|
|
257
|
+
new_lines.append(content)
|
|
258
|
+
index += 1
|
|
259
|
+
|
|
260
|
+
if len(old_lines) != old_count or len(new_lines) != new_count:
|
|
261
|
+
raise _error(
|
|
262
|
+
PlanningErrorCode.DIFF_INVALID,
|
|
263
|
+
"hunk body line counts do not match its header",
|
|
264
|
+
item_id,
|
|
265
|
+
)
|
|
266
|
+
return (
|
|
267
|
+
DiffHunk(
|
|
268
|
+
old_start=old_start,
|
|
269
|
+
old_count=old_count,
|
|
270
|
+
new_start=new_start,
|
|
271
|
+
new_count=new_count,
|
|
272
|
+
old_lines=tuple(old_lines),
|
|
273
|
+
new_lines=tuple(new_lines),
|
|
274
|
+
),
|
|
275
|
+
index,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _remove_last_newline(
|
|
280
|
+
old_lines: list[str],
|
|
281
|
+
new_lines: list[str],
|
|
282
|
+
*,
|
|
283
|
+
previous_prefix: str | None,
|
|
284
|
+
item_id: str,
|
|
285
|
+
) -> None:
|
|
286
|
+
if previous_prefix is None:
|
|
287
|
+
raise _error(
|
|
288
|
+
PlanningErrorCode.DIFF_INVALID,
|
|
289
|
+
"no-newline marker must follow a hunk content line",
|
|
290
|
+
item_id,
|
|
291
|
+
)
|
|
292
|
+
targets = []
|
|
293
|
+
if previous_prefix in " -":
|
|
294
|
+
targets.append(old_lines)
|
|
295
|
+
if previous_prefix in " +":
|
|
296
|
+
targets.append(new_lines)
|
|
297
|
+
for target in targets:
|
|
298
|
+
if not target or not target[-1].endswith("\n"):
|
|
299
|
+
raise _error(
|
|
300
|
+
PlanningErrorCode.DIFF_INVALID,
|
|
301
|
+
"no-newline marker is duplicated or misplaced",
|
|
302
|
+
item_id,
|
|
303
|
+
)
|
|
304
|
+
target[-1] = target[-1][:-1]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _error(
|
|
308
|
+
code: PlanningErrorCode,
|
|
309
|
+
message: str,
|
|
310
|
+
item_id: str,
|
|
311
|
+
*,
|
|
312
|
+
path: str | None = None,
|
|
313
|
+
) -> PlanningError:
|
|
314
|
+
return PlanningError(code, message, item_id=item_id, path=path)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
__all__ = ["FileDiff", "apply_file_diff", "parse_unified_diff"]
|
patchshuttle/_process.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Shared bounded subprocess execution for trusted fixed command adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import BinaryIO
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProcessStatus(str, Enum):
|
|
17
|
+
"""Low-level outcome of one controlled child process."""
|
|
18
|
+
|
|
19
|
+
PASSED = "PASSED"
|
|
20
|
+
FAILED = "FAILED"
|
|
21
|
+
TIMED_OUT = "TIMED_OUT"
|
|
22
|
+
ERROR = "ERROR"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class ProcessCommand:
|
|
27
|
+
"""A fixed argument array and its local execution limits."""
|
|
28
|
+
|
|
29
|
+
argv: tuple[str, ...]
|
|
30
|
+
working_directory: Path
|
|
31
|
+
timeout_seconds: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class ProcessResult:
|
|
36
|
+
"""Bounded output and status returned by a controlled child process."""
|
|
37
|
+
|
|
38
|
+
status: ProcessStatus
|
|
39
|
+
return_code: int | None
|
|
40
|
+
duration_ms: int
|
|
41
|
+
stdout: str
|
|
42
|
+
stderr: str
|
|
43
|
+
stdout_truncated: bool
|
|
44
|
+
stderr_truncated: bool
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def success(self) -> bool:
|
|
48
|
+
return self.status is ProcessStatus.PASSED
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_process(
|
|
52
|
+
command: ProcessCommand,
|
|
53
|
+
*,
|
|
54
|
+
maximum_output_bytes: int,
|
|
55
|
+
) -> ProcessResult:
|
|
56
|
+
"""Run one fixed command without a shell and with bounded captured output."""
|
|
57
|
+
|
|
58
|
+
started = time.monotonic_ns()
|
|
59
|
+
with (
|
|
60
|
+
tempfile.TemporaryFile(mode="w+b") as stdout_stream,
|
|
61
|
+
tempfile.TemporaryFile(mode="w+b") as stderr_stream,
|
|
62
|
+
):
|
|
63
|
+
try:
|
|
64
|
+
process = subprocess.Popen(
|
|
65
|
+
command.argv,
|
|
66
|
+
cwd=command.working_directory,
|
|
67
|
+
stdin=subprocess.DEVNULL,
|
|
68
|
+
stdout=stdout_stream,
|
|
69
|
+
stderr=stderr_stream,
|
|
70
|
+
shell=False,
|
|
71
|
+
**_process_group_options(),
|
|
72
|
+
)
|
|
73
|
+
except OSError as exc:
|
|
74
|
+
stderr, stderr_truncated = _bounded_text(
|
|
75
|
+
str(exc).encode("utf-8", errors="replace"),
|
|
76
|
+
maximum_output_bytes,
|
|
77
|
+
)
|
|
78
|
+
return _result(
|
|
79
|
+
status=ProcessStatus.ERROR,
|
|
80
|
+
return_code=None,
|
|
81
|
+
started=started,
|
|
82
|
+
stdout="",
|
|
83
|
+
stderr=stderr,
|
|
84
|
+
stdout_truncated=False,
|
|
85
|
+
stderr_truncated=stderr_truncated,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
timed_out = False
|
|
89
|
+
try:
|
|
90
|
+
return_code = process.wait(timeout=command.timeout_seconds)
|
|
91
|
+
except subprocess.TimeoutExpired:
|
|
92
|
+
timed_out = True
|
|
93
|
+
return_code = None
|
|
94
|
+
_terminate_process(process)
|
|
95
|
+
except BaseException:
|
|
96
|
+
_terminate_process(process)
|
|
97
|
+
raise
|
|
98
|
+
|
|
99
|
+
stdout, stdout_truncated = _read_stream(
|
|
100
|
+
stdout_stream,
|
|
101
|
+
maximum_output_bytes,
|
|
102
|
+
)
|
|
103
|
+
stderr, stderr_truncated = _read_stream(
|
|
104
|
+
stderr_stream,
|
|
105
|
+
maximum_output_bytes,
|
|
106
|
+
)
|
|
107
|
+
status = (
|
|
108
|
+
ProcessStatus.TIMED_OUT
|
|
109
|
+
if timed_out
|
|
110
|
+
else ProcessStatus.PASSED if return_code == 0 else ProcessStatus.FAILED
|
|
111
|
+
)
|
|
112
|
+
return _result(
|
|
113
|
+
status=status,
|
|
114
|
+
return_code=return_code,
|
|
115
|
+
started=started,
|
|
116
|
+
stdout=stdout,
|
|
117
|
+
stderr=stderr,
|
|
118
|
+
stdout_truncated=stdout_truncated,
|
|
119
|
+
stderr_truncated=stderr_truncated,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _result(
|
|
124
|
+
*,
|
|
125
|
+
status: ProcessStatus,
|
|
126
|
+
return_code: int | None,
|
|
127
|
+
started: int,
|
|
128
|
+
stdout: str,
|
|
129
|
+
stderr: str,
|
|
130
|
+
stdout_truncated: bool,
|
|
131
|
+
stderr_truncated: bool,
|
|
132
|
+
) -> ProcessResult:
|
|
133
|
+
return ProcessResult(
|
|
134
|
+
status=status,
|
|
135
|
+
return_code=return_code,
|
|
136
|
+
duration_ms=(time.monotonic_ns() - started) // 1_000_000,
|
|
137
|
+
stdout=stdout,
|
|
138
|
+
stderr=stderr,
|
|
139
|
+
stdout_truncated=stdout_truncated,
|
|
140
|
+
stderr_truncated=stderr_truncated,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _read_stream(stream: BinaryIO, maximum: int) -> tuple[str, bool]:
|
|
145
|
+
stream.flush()
|
|
146
|
+
stream.seek(0)
|
|
147
|
+
return _bounded_text(stream.read(maximum + 1), maximum)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _bounded_text(raw: bytes, maximum: int) -> tuple[str, bool]:
|
|
151
|
+
truncated = len(raw) > maximum
|
|
152
|
+
text = raw[:maximum].decode("utf-8", errors="replace")
|
|
153
|
+
return text.replace("\r\n", "\n").replace("\r", "\n"), truncated
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _process_group_options() -> dict[str, object]:
|
|
157
|
+
if os.name == "nt": # pragma: no cover - exercised by Windows CI
|
|
158
|
+
return {"creationflags": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)}
|
|
159
|
+
return {"start_new_session": True}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _terminate_process(process) -> None:
|
|
163
|
+
if process.poll() is not None:
|
|
164
|
+
return
|
|
165
|
+
_signal_process(process, force=False)
|
|
166
|
+
try:
|
|
167
|
+
process.wait(timeout=1)
|
|
168
|
+
return
|
|
169
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
170
|
+
pass
|
|
171
|
+
_signal_process(process, force=True)
|
|
172
|
+
try:
|
|
173
|
+
process.wait(timeout=1)
|
|
174
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _signal_process(process, *, force: bool) -> None:
|
|
179
|
+
if os.name != "nt":
|
|
180
|
+
selected = signal.SIGKILL if force else signal.SIGTERM
|
|
181
|
+
try:
|
|
182
|
+
os.killpg(process.pid, selected)
|
|
183
|
+
return
|
|
184
|
+
except OSError:
|
|
185
|
+
pass
|
|
186
|
+
operation = process.kill if force else process.terminate
|
|
187
|
+
try:
|
|
188
|
+
operation()
|
|
189
|
+
except OSError:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
__all__ = [
|
|
194
|
+
"ProcessCommand",
|
|
195
|
+
"ProcessResult",
|
|
196
|
+
"ProcessStatus",
|
|
197
|
+
"run_process",
|
|
198
|
+
]
|
patchshuttle/_version.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Guarded low-level actions used by the transaction runner."""
|
|
2
|
+
|
|
3
|
+
from patchshuttle.actions.constructors import (
|
|
4
|
+
apply_diff,
|
|
5
|
+
)
|
|
6
|
+
from patchshuttle.actions.constructors import (
|
|
7
|
+
create_directory as _declarative_create_directory,
|
|
8
|
+
)
|
|
9
|
+
from patchshuttle.actions.constructors import (
|
|
10
|
+
create_file,
|
|
11
|
+
delete_exact,
|
|
12
|
+
environment,
|
|
13
|
+
file_info,
|
|
14
|
+
find_files,
|
|
15
|
+
git_status,
|
|
16
|
+
hash,
|
|
17
|
+
insert_after,
|
|
18
|
+
insert_before,
|
|
19
|
+
read,
|
|
20
|
+
replace_exact,
|
|
21
|
+
search,
|
|
22
|
+
tree,
|
|
23
|
+
)
|
|
24
|
+
from patchshuttle.actions.create import (
|
|
25
|
+
FilePublishError,
|
|
26
|
+
atomic_create_file,
|
|
27
|
+
)
|
|
28
|
+
from patchshuttle.actions.create import create_directory as apply_create_directory
|
|
29
|
+
from patchshuttle.actions.create import (
|
|
30
|
+
verify_created_directory,
|
|
31
|
+
verify_created_file,
|
|
32
|
+
)
|
|
33
|
+
from patchshuttle.actions.modify import (
|
|
34
|
+
FileReplaceError,
|
|
35
|
+
atomic_replace_file,
|
|
36
|
+
atomic_restore_file,
|
|
37
|
+
verify_modified_file,
|
|
38
|
+
verify_restored_file,
|
|
39
|
+
)
|
|
40
|
+
from patchshuttle.workspace import Workspace
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_directory(*args, **kwargs):
|
|
44
|
+
"""Create a declarative action, retaining the legacy internal call form."""
|
|
45
|
+
|
|
46
|
+
if (args and isinstance(args[0], Workspace)) or isinstance(
|
|
47
|
+
kwargs.get("workspace"),
|
|
48
|
+
Workspace,
|
|
49
|
+
):
|
|
50
|
+
return apply_create_directory(*args, **kwargs)
|
|
51
|
+
return _declarative_create_directory(*args, **kwargs)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"FilePublishError",
|
|
56
|
+
"FileReplaceError",
|
|
57
|
+
"atomic_create_file",
|
|
58
|
+
"atomic_replace_file",
|
|
59
|
+
"atomic_restore_file",
|
|
60
|
+
"apply_create_directory",
|
|
61
|
+
"apply_diff",
|
|
62
|
+
"create_directory",
|
|
63
|
+
"create_file",
|
|
64
|
+
"delete_exact",
|
|
65
|
+
"environment",
|
|
66
|
+
"file_info",
|
|
67
|
+
"find_files",
|
|
68
|
+
"git_status",
|
|
69
|
+
"hash",
|
|
70
|
+
"insert_after",
|
|
71
|
+
"insert_before",
|
|
72
|
+
"read",
|
|
73
|
+
"replace_exact",
|
|
74
|
+
"search",
|
|
75
|
+
"tree",
|
|
76
|
+
"verify_created_directory",
|
|
77
|
+
"verify_created_file",
|
|
78
|
+
"verify_modified_file",
|
|
79
|
+
"verify_restored_file",
|
|
80
|
+
]
|