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/runner.py
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
"""Internal transaction runner for approved text-file change plans."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import stat
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
|
|
12
|
+
from filelock import FileLock, Timeout
|
|
13
|
+
|
|
14
|
+
import patchshuttle.actions as actions
|
|
15
|
+
from patchshuttle.backup import (
|
|
16
|
+
BackupStatus,
|
|
17
|
+
PreparedBackup,
|
|
18
|
+
prepare_backup,
|
|
19
|
+
update_backup,
|
|
20
|
+
)
|
|
21
|
+
from patchshuttle.checks import CheckResult, CheckStatus, run_checks
|
|
22
|
+
from patchshuttle.errors import (
|
|
23
|
+
ExecutionError,
|
|
24
|
+
ExecutionErrorCode,
|
|
25
|
+
PlanningError,
|
|
26
|
+
PolicyError,
|
|
27
|
+
WorkspaceError,
|
|
28
|
+
)
|
|
29
|
+
from patchshuttle.formatters import (
|
|
30
|
+
FormattedFileState,
|
|
31
|
+
FormatterResult,
|
|
32
|
+
FormatterStatus,
|
|
33
|
+
capture_formatted_files,
|
|
34
|
+
run_formatters,
|
|
35
|
+
verify_formatted_files,
|
|
36
|
+
)
|
|
37
|
+
from patchshuttle.inventory import (
|
|
38
|
+
InventoryError,
|
|
39
|
+
WorkspaceComparison,
|
|
40
|
+
WorkspaceInventory,
|
|
41
|
+
capture_inventory,
|
|
42
|
+
compare_inventories,
|
|
43
|
+
)
|
|
44
|
+
from patchshuttle.models import JobKind
|
|
45
|
+
from patchshuttle.planner import (
|
|
46
|
+
ActionDisposition,
|
|
47
|
+
FileDisposition,
|
|
48
|
+
Plan,
|
|
49
|
+
plan_job,
|
|
50
|
+
)
|
|
51
|
+
from patchshuttle.rollback import rollback_created, rollback_transaction
|
|
52
|
+
from patchshuttle.workspace import Workspace
|
|
53
|
+
|
|
54
|
+
_CREATE_ACTIONS = frozenset({"create_directory", "create_file"})
|
|
55
|
+
_CHANGE_ACTIONS = frozenset(
|
|
56
|
+
{
|
|
57
|
+
*_CREATE_ACTIONS,
|
|
58
|
+
"replace_exact",
|
|
59
|
+
"insert_before",
|
|
60
|
+
"insert_after",
|
|
61
|
+
"delete_exact",
|
|
62
|
+
"apply_diff",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TransactionStatus(str, Enum):
|
|
68
|
+
"""Successful outcomes of the internal transaction core."""
|
|
69
|
+
|
|
70
|
+
APPLIED = "APPLIED"
|
|
71
|
+
NO_CHANGE = "NO_CHANGE"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class TransactionResult:
|
|
76
|
+
"""Immutable result returned only after a verified transaction outcome."""
|
|
77
|
+
|
|
78
|
+
status: TransactionStatus
|
|
79
|
+
plan: Plan = field(repr=False)
|
|
80
|
+
backup_path: Path | None
|
|
81
|
+
created_files: tuple[PurePosixPath, ...]
|
|
82
|
+
created_directories: tuple[PurePosixPath, ...]
|
|
83
|
+
modified_files: tuple[PurePosixPath, ...] = ()
|
|
84
|
+
initial_checks: tuple[CheckResult, ...] = ()
|
|
85
|
+
formatting_results: tuple[FormatterResult, ...] = ()
|
|
86
|
+
formatted_files: tuple[FormattedFileState, ...] = ()
|
|
87
|
+
final_checks: tuple[CheckResult, ...] = ()
|
|
88
|
+
workspace_comparison: WorkspaceComparison | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def execute_create_transaction(
|
|
92
|
+
plan: Plan,
|
|
93
|
+
*,
|
|
94
|
+
approved: bool = False,
|
|
95
|
+
keep_changes: bool = False,
|
|
96
|
+
) -> TransactionResult:
|
|
97
|
+
"""Apply only create actions through the retained Phase 8 contract."""
|
|
98
|
+
|
|
99
|
+
return _execute_transaction(
|
|
100
|
+
plan,
|
|
101
|
+
approved=approved,
|
|
102
|
+
keep_changes=keep_changes,
|
|
103
|
+
allowed_actions=_CREATE_ACTIONS,
|
|
104
|
+
allow_modify=False,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def execute_change_transaction(
|
|
109
|
+
plan: Plan,
|
|
110
|
+
*,
|
|
111
|
+
approved: bool = False,
|
|
112
|
+
keep_changes: bool = False,
|
|
113
|
+
) -> TransactionResult:
|
|
114
|
+
"""Apply all planned text changes under one guarded transaction."""
|
|
115
|
+
|
|
116
|
+
return _execute_transaction(
|
|
117
|
+
plan,
|
|
118
|
+
approved=approved,
|
|
119
|
+
keep_changes=keep_changes,
|
|
120
|
+
allowed_actions=_CHANGE_ACTIONS,
|
|
121
|
+
allow_modify=True,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def execute_change_transaction_locked(
|
|
126
|
+
plan: Plan,
|
|
127
|
+
*,
|
|
128
|
+
approved: bool = False,
|
|
129
|
+
keep_changes: bool = False,
|
|
130
|
+
) -> TransactionResult:
|
|
131
|
+
"""Execute a patch while the caller holds the workspace run lock.
|
|
132
|
+
|
|
133
|
+
This entry point exists for the public operational coordinator, which must
|
|
134
|
+
keep registry checks, project changes, logs, and archive updates inside one
|
|
135
|
+
lock boundary. Direct callers should use ``execute_change_transaction``.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
_require_supported_plan(
|
|
139
|
+
plan,
|
|
140
|
+
allowed_actions=_CHANGE_ACTIONS,
|
|
141
|
+
allow_modify=True,
|
|
142
|
+
)
|
|
143
|
+
_require_approval(approved)
|
|
144
|
+
_require_keep_changes_allowed(plan, keep_changes)
|
|
145
|
+
return _execute_locked(plan, keep_changes=keep_changes)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _execute_transaction(
|
|
149
|
+
plan: Plan,
|
|
150
|
+
*,
|
|
151
|
+
approved: bool,
|
|
152
|
+
keep_changes: bool,
|
|
153
|
+
allowed_actions: frozenset[str],
|
|
154
|
+
allow_modify: bool,
|
|
155
|
+
) -> TransactionResult:
|
|
156
|
+
"""Validate entry-point authority, acquire the lock, and execute."""
|
|
157
|
+
|
|
158
|
+
_require_supported_plan(
|
|
159
|
+
plan,
|
|
160
|
+
allowed_actions=allowed_actions,
|
|
161
|
+
allow_modify=allow_modify,
|
|
162
|
+
)
|
|
163
|
+
_require_approval(approved)
|
|
164
|
+
_require_keep_changes_allowed(plan, keep_changes)
|
|
165
|
+
with acquire_workspace_lock(plan.workspace):
|
|
166
|
+
return _execute_locked(plan, keep_changes=keep_changes)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _require_approval(approved: bool) -> None:
|
|
170
|
+
if not approved:
|
|
171
|
+
raise ExecutionError(
|
|
172
|
+
ExecutionErrorCode.APPROVAL_REQUIRED,
|
|
173
|
+
"explicit approval is required before project files are changed",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _require_keep_changes_allowed(plan: Plan, keep_changes: bool) -> None:
|
|
178
|
+
if keep_changes and not plan.workspace.config.execution.allow_keep_changes:
|
|
179
|
+
raise ExecutionError(
|
|
180
|
+
ExecutionErrorCode.KEEP_CHANGES_FORBIDDEN,
|
|
181
|
+
"local workspace policy does not allow keeping failed-job changes",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@contextmanager
|
|
186
|
+
def acquire_workspace_lock(workspace: Workspace) -> Iterator[None]:
|
|
187
|
+
"""Acquire one initialized workspace's non-blocking operational lock."""
|
|
188
|
+
|
|
189
|
+
lock_path = workspace.patches_dir / "state/run.lock"
|
|
190
|
+
try:
|
|
191
|
+
lock_metadata = lock_path.lstat()
|
|
192
|
+
if not stat.S_ISREG(lock_metadata.st_mode):
|
|
193
|
+
raise OSError("workspace lock path is not a regular file")
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
raise ExecutionError(
|
|
196
|
+
ExecutionErrorCode.WORKSPACE_LOCK_FAILED,
|
|
197
|
+
"workspace lock file is missing, unsafe, or unreadable",
|
|
198
|
+
path="patches/state/run.lock",
|
|
199
|
+
) from exc
|
|
200
|
+
lock = FileLock(lock_path, timeout=0, preserve_lock_file=True)
|
|
201
|
+
try:
|
|
202
|
+
with lock.acquire(timeout=0):
|
|
203
|
+
yield
|
|
204
|
+
except Timeout as exc:
|
|
205
|
+
raise ExecutionError(
|
|
206
|
+
ExecutionErrorCode.WORKSPACE_LOCKED,
|
|
207
|
+
"another PatchShuttle transaction holds the workspace lock",
|
|
208
|
+
path="patches/state/run.lock",
|
|
209
|
+
) from exc
|
|
210
|
+
except OSError as exc:
|
|
211
|
+
raise ExecutionError(
|
|
212
|
+
ExecutionErrorCode.WORKSPACE_LOCK_FAILED,
|
|
213
|
+
"workspace lock could not be acquired or released",
|
|
214
|
+
path="patches/state/run.lock",
|
|
215
|
+
) from exc
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _require_supported_plan(
|
|
219
|
+
plan: Plan,
|
|
220
|
+
*,
|
|
221
|
+
allowed_actions: frozenset[str],
|
|
222
|
+
allow_modify: bool,
|
|
223
|
+
) -> None:
|
|
224
|
+
if plan.job.kind is not JobKind.PATCH:
|
|
225
|
+
raise ExecutionError(
|
|
226
|
+
ExecutionErrorCode.JOB_KIND_UNSUPPORTED,
|
|
227
|
+
"the internal transaction core accepts only patch jobs",
|
|
228
|
+
)
|
|
229
|
+
for action in plan.actions:
|
|
230
|
+
if action.name not in allowed_actions:
|
|
231
|
+
raise ExecutionError(
|
|
232
|
+
ExecutionErrorCode.ACTION_UNSUPPORTED,
|
|
233
|
+
"this transaction entry point does not accept the planned action",
|
|
234
|
+
item_id=action.id,
|
|
235
|
+
path=(action.paths[0].as_posix() if action.paths else None),
|
|
236
|
+
)
|
|
237
|
+
if not allow_modify and any(
|
|
238
|
+
change.disposition is not FileDisposition.CREATE for change in plan.file_changes
|
|
239
|
+
):
|
|
240
|
+
raise ExecutionError(
|
|
241
|
+
ExecutionErrorCode.ACTION_UNSUPPORTED,
|
|
242
|
+
"the create-only transaction cannot modify existing files",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _execute_locked(plan: Plan, *, keep_changes: bool = False) -> TransactionResult:
|
|
247
|
+
_revalidate_plan(plan)
|
|
248
|
+
if not plan.file_changes and not plan.directories_to_create:
|
|
249
|
+
return TransactionResult(
|
|
250
|
+
status=TransactionStatus.NO_CHANGE,
|
|
251
|
+
plan=plan,
|
|
252
|
+
backup_path=None,
|
|
253
|
+
created_files=(),
|
|
254
|
+
created_directories=(),
|
|
255
|
+
modified_files=(),
|
|
256
|
+
initial_checks=(),
|
|
257
|
+
formatting_results=(),
|
|
258
|
+
formatted_files=(),
|
|
259
|
+
final_checks=(),
|
|
260
|
+
workspace_comparison=None,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
baseline = _capture_workspace_inventory(plan)
|
|
264
|
+
backup = prepare_backup(plan)
|
|
265
|
+
created_files: list[PurePosixPath] = []
|
|
266
|
+
rollback_files: list[PurePosixPath] = []
|
|
267
|
+
created_directories: list[PurePosixPath] = []
|
|
268
|
+
modified_files: list[PurePosixPath] = []
|
|
269
|
+
applied_files: set[PurePosixPath] = set()
|
|
270
|
+
current_item: str | None = None
|
|
271
|
+
current_path: PurePosixPath | None = None
|
|
272
|
+
initial_checks: tuple[CheckResult, ...] = ()
|
|
273
|
+
formatting_results: tuple[FormatterResult, ...] = ()
|
|
274
|
+
formatted_files: tuple[FormattedFileState, ...] = ()
|
|
275
|
+
final_checks: tuple[CheckResult, ...] = ()
|
|
276
|
+
workspace_comparison: WorkspaceComparison | None = None
|
|
277
|
+
try:
|
|
278
|
+
changes = {change.path: change for change in plan.file_changes}
|
|
279
|
+
for action in plan.actions:
|
|
280
|
+
current_item = action.id
|
|
281
|
+
current_path = action.paths[0] if action.paths else None
|
|
282
|
+
if action.name == "create_directory":
|
|
283
|
+
if action.disposition is ActionDisposition.CREATE:
|
|
284
|
+
_create_required_directories(
|
|
285
|
+
plan,
|
|
286
|
+
action.paths[0],
|
|
287
|
+
created_directories,
|
|
288
|
+
)
|
|
289
|
+
continue
|
|
290
|
+
for path in action.paths:
|
|
291
|
+
current_path = path
|
|
292
|
+
change = changes.get(path)
|
|
293
|
+
if change is None or path in applied_files:
|
|
294
|
+
continue
|
|
295
|
+
if change.disposition is FileDisposition.CREATE:
|
|
296
|
+
_create_required_directories(
|
|
297
|
+
plan,
|
|
298
|
+
path.parent,
|
|
299
|
+
created_directories,
|
|
300
|
+
)
|
|
301
|
+
try:
|
|
302
|
+
actions.atomic_create_file(plan.workspace, change)
|
|
303
|
+
except actions.FilePublishError as exc:
|
|
304
|
+
if exc.target_created:
|
|
305
|
+
created_files.append(path)
|
|
306
|
+
rollback_files.append(path)
|
|
307
|
+
if exc.temporary_path is not None:
|
|
308
|
+
rollback_files.append(
|
|
309
|
+
PurePosixPath(
|
|
310
|
+
exc.temporary_path.relative_to(
|
|
311
|
+
plan.workspace.root
|
|
312
|
+
).as_posix()
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
raise
|
|
316
|
+
created_files.append(path)
|
|
317
|
+
rollback_files.append(path)
|
|
318
|
+
actions.verify_created_file(plan.workspace, change)
|
|
319
|
+
else:
|
|
320
|
+
entry = backup.entry_for(path)
|
|
321
|
+
if entry.original_mode is None:
|
|
322
|
+
raise OSError("modified file backup is missing its mode")
|
|
323
|
+
try:
|
|
324
|
+
actions.atomic_replace_file(
|
|
325
|
+
plan.workspace,
|
|
326
|
+
change,
|
|
327
|
+
mode=entry.original_mode,
|
|
328
|
+
)
|
|
329
|
+
except actions.FileReplaceError as exc:
|
|
330
|
+
if exc.target_modified:
|
|
331
|
+
modified_files.append(path)
|
|
332
|
+
if exc.temporary_path is not None:
|
|
333
|
+
rollback_files.append(
|
|
334
|
+
PurePosixPath(
|
|
335
|
+
exc.temporary_path.relative_to(
|
|
336
|
+
plan.workspace.root
|
|
337
|
+
).as_posix()
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
raise
|
|
341
|
+
modified_files.append(path)
|
|
342
|
+
actions.verify_modified_file(
|
|
343
|
+
plan.workspace,
|
|
344
|
+
change,
|
|
345
|
+
mode=entry.original_mode,
|
|
346
|
+
)
|
|
347
|
+
applied_files.add(path)
|
|
348
|
+
|
|
349
|
+
if tuple(created_directories) != plan.directories_to_create:
|
|
350
|
+
raise OSError("not all planned directories were created")
|
|
351
|
+
if tuple(created_files) != plan.files_to_create:
|
|
352
|
+
raise OSError("not all planned files were created")
|
|
353
|
+
if tuple(modified_files) != plan.files_to_modify:
|
|
354
|
+
raise OSError("not all planned files were modified")
|
|
355
|
+
|
|
356
|
+
current_item = None
|
|
357
|
+
current_path = None
|
|
358
|
+
check_run = run_checks(plan)
|
|
359
|
+
initial_checks = check_run.results
|
|
360
|
+
if check_run.failed is not None:
|
|
361
|
+
failure_messages = {
|
|
362
|
+
CheckStatus.FAILED: "initial project check returned a non-zero exit code",
|
|
363
|
+
CheckStatus.TIMED_OUT: "initial project check timed out",
|
|
364
|
+
CheckStatus.ERROR: "initial project check could not be started",
|
|
365
|
+
}
|
|
366
|
+
raise ExecutionError(
|
|
367
|
+
ExecutionErrorCode.CHECK_FAILED,
|
|
368
|
+
failure_messages[check_run.failed.status],
|
|
369
|
+
item_id=check_run.failed.id,
|
|
370
|
+
path=check_run.failed.name,
|
|
371
|
+
check_results=initial_checks,
|
|
372
|
+
)
|
|
373
|
+
_verify_transaction_files_after_checks(plan, backup, initial_checks)
|
|
374
|
+
|
|
375
|
+
if plan.formatting_targets:
|
|
376
|
+
current_item = "formatting"
|
|
377
|
+
current_path = plan.formatting_targets[0]
|
|
378
|
+
before_formatting = _capture_formatter_states(
|
|
379
|
+
plan,
|
|
380
|
+
check_results=initial_checks,
|
|
381
|
+
formatting_results=formatting_results,
|
|
382
|
+
message="formatter targets could not be captured before formatting",
|
|
383
|
+
)
|
|
384
|
+
try:
|
|
385
|
+
formatting_run = run_formatters(plan)
|
|
386
|
+
except (OSError, PolicyError, ValueError) as exc:
|
|
387
|
+
raise ExecutionError(
|
|
388
|
+
ExecutionErrorCode.FORMAT_FAILED,
|
|
389
|
+
"formatter commands could not be prepared or launched",
|
|
390
|
+
item_id="formatting",
|
|
391
|
+
path=plan.formatting_targets[0].as_posix(),
|
|
392
|
+
check_results=initial_checks,
|
|
393
|
+
formatting_results=formatting_results,
|
|
394
|
+
) from exc
|
|
395
|
+
formatting_results = formatting_run.results
|
|
396
|
+
if formatting_run.failed is not None:
|
|
397
|
+
failure_messages = {
|
|
398
|
+
FormatterStatus.FAILED: ("formatter returned a non-zero exit code"),
|
|
399
|
+
FormatterStatus.TIMED_OUT: "formatter timed out",
|
|
400
|
+
FormatterStatus.ERROR: "formatter could not be started",
|
|
401
|
+
}
|
|
402
|
+
raise ExecutionError(
|
|
403
|
+
ExecutionErrorCode.FORMAT_FAILED,
|
|
404
|
+
failure_messages[formatting_run.failed.status],
|
|
405
|
+
item_id=formatting_run.failed.id,
|
|
406
|
+
path=formatting_run.failed.name,
|
|
407
|
+
check_results=initial_checks,
|
|
408
|
+
formatting_results=formatting_results,
|
|
409
|
+
)
|
|
410
|
+
_verify_planned_transaction_files(
|
|
411
|
+
plan,
|
|
412
|
+
backup,
|
|
413
|
+
excluded=frozenset(plan.formatting_targets),
|
|
414
|
+
code=ExecutionErrorCode.FORMAT_FAILED,
|
|
415
|
+
message="formatters changed a declared non-formatting file",
|
|
416
|
+
check_results=initial_checks,
|
|
417
|
+
formatting_results=formatting_results,
|
|
418
|
+
)
|
|
419
|
+
formatted_files = _capture_formatter_states(
|
|
420
|
+
plan,
|
|
421
|
+
check_results=initial_checks,
|
|
422
|
+
formatting_results=formatting_results,
|
|
423
|
+
message="formatter output has an invalid transaction state",
|
|
424
|
+
)
|
|
425
|
+
_require_preserved_formatter_modes(
|
|
426
|
+
before_formatting,
|
|
427
|
+
formatted_files,
|
|
428
|
+
check_results=initial_checks,
|
|
429
|
+
formatting_results=formatting_results,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
if plan.workspace.config.formatting.rerun_checks:
|
|
433
|
+
current_item = None
|
|
434
|
+
current_path = None
|
|
435
|
+
final_run = run_checks(plan)
|
|
436
|
+
final_checks = final_run.results
|
|
437
|
+
all_checks = initial_checks + final_checks
|
|
438
|
+
if final_run.failed is not None:
|
|
439
|
+
failure_messages = {
|
|
440
|
+
CheckStatus.FAILED: (
|
|
441
|
+
"final project check returned a non-zero exit code"
|
|
442
|
+
),
|
|
443
|
+
CheckStatus.TIMED_OUT: "final project check timed out",
|
|
444
|
+
CheckStatus.ERROR: "final project check could not be started",
|
|
445
|
+
}
|
|
446
|
+
raise ExecutionError(
|
|
447
|
+
ExecutionErrorCode.CHECK_FAILED,
|
|
448
|
+
failure_messages[final_run.failed.status],
|
|
449
|
+
item_id=final_run.failed.id,
|
|
450
|
+
path=final_run.failed.name,
|
|
451
|
+
check_results=all_checks,
|
|
452
|
+
formatting_results=formatting_results,
|
|
453
|
+
)
|
|
454
|
+
_verify_planned_transaction_files(
|
|
455
|
+
plan,
|
|
456
|
+
backup,
|
|
457
|
+
excluded=frozenset(plan.formatting_targets),
|
|
458
|
+
code=ExecutionErrorCode.CHECK_FAILED,
|
|
459
|
+
message="final project checks changed a declared transaction file",
|
|
460
|
+
check_results=all_checks,
|
|
461
|
+
formatting_results=formatting_results,
|
|
462
|
+
)
|
|
463
|
+
try:
|
|
464
|
+
verify_formatted_files(plan, formatted_files)
|
|
465
|
+
except (OSError, PolicyError, ValueError) as exc:
|
|
466
|
+
raise ExecutionError(
|
|
467
|
+
ExecutionErrorCode.CHECK_FAILED,
|
|
468
|
+
"final project checks changed a formatted transaction file",
|
|
469
|
+
path=_first_formatter_path(plan),
|
|
470
|
+
check_results=all_checks,
|
|
471
|
+
formatting_results=formatting_results,
|
|
472
|
+
) from exc
|
|
473
|
+
|
|
474
|
+
current_item = None
|
|
475
|
+
current_path = None
|
|
476
|
+
workspace_comparison = _compare_workspace_to_baseline(plan, baseline)
|
|
477
|
+
if workspace_comparison.unexpected_changes:
|
|
478
|
+
unexpected = workspace_comparison.unexpected_changes[0]
|
|
479
|
+
raise ExecutionError(
|
|
480
|
+
ExecutionErrorCode.UNEXPECTED_WORKSPACE_CHANGE,
|
|
481
|
+
"project checks or formatters changed an undeclared workspace path",
|
|
482
|
+
path=unexpected.path.as_posix(),
|
|
483
|
+
check_results=initial_checks + final_checks,
|
|
484
|
+
formatting_results=formatting_results,
|
|
485
|
+
workspace_comparison=workspace_comparison,
|
|
486
|
+
)
|
|
487
|
+
try:
|
|
488
|
+
update_backup(
|
|
489
|
+
backup,
|
|
490
|
+
BackupStatus.COMPLETED,
|
|
491
|
+
capture_applied_state=True,
|
|
492
|
+
)
|
|
493
|
+
except ExecutionError as exc:
|
|
494
|
+
exc.check_results = initial_checks + final_checks
|
|
495
|
+
exc.formatting_results = formatting_results
|
|
496
|
+
raise
|
|
497
|
+
except BaseException as exc:
|
|
498
|
+
failure = _action_failure(
|
|
499
|
+
exc,
|
|
500
|
+
item_id=current_item,
|
|
501
|
+
path=current_path,
|
|
502
|
+
backup=backup,
|
|
503
|
+
)
|
|
504
|
+
if plan.auto_rollback and not keep_changes:
|
|
505
|
+
try:
|
|
506
|
+
_rollback_or_raise(
|
|
507
|
+
failure,
|
|
508
|
+
backup,
|
|
509
|
+
files=tuple(rollback_files),
|
|
510
|
+
directories=tuple(created_directories),
|
|
511
|
+
modified_files=tuple(modified_files),
|
|
512
|
+
)
|
|
513
|
+
except ExecutionError as rollback_failure:
|
|
514
|
+
rollback_failure.workspace_comparison = _safe_workspace_comparison(
|
|
515
|
+
plan,
|
|
516
|
+
baseline,
|
|
517
|
+
fallback=rollback_failure.workspace_comparison,
|
|
518
|
+
)
|
|
519
|
+
raise
|
|
520
|
+
else:
|
|
521
|
+
_record_retained_failure(
|
|
522
|
+
failure,
|
|
523
|
+
backup,
|
|
524
|
+
changes_present=bool(
|
|
525
|
+
rollback_files or created_directories or modified_files
|
|
526
|
+
),
|
|
527
|
+
)
|
|
528
|
+
failure.workspace_comparison = _safe_workspace_comparison(
|
|
529
|
+
plan,
|
|
530
|
+
baseline,
|
|
531
|
+
fallback=failure.workspace_comparison,
|
|
532
|
+
)
|
|
533
|
+
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
|
534
|
+
raise
|
|
535
|
+
raise failure
|
|
536
|
+
|
|
537
|
+
return TransactionResult(
|
|
538
|
+
status=TransactionStatus.APPLIED,
|
|
539
|
+
plan=plan,
|
|
540
|
+
backup_path=backup.path,
|
|
541
|
+
created_files=tuple(created_files),
|
|
542
|
+
created_directories=tuple(created_directories),
|
|
543
|
+
modified_files=tuple(modified_files),
|
|
544
|
+
initial_checks=initial_checks,
|
|
545
|
+
formatting_results=formatting_results,
|
|
546
|
+
formatted_files=formatted_files,
|
|
547
|
+
final_checks=final_checks,
|
|
548
|
+
workspace_comparison=workspace_comparison,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _capture_workspace_inventory(plan: Plan) -> WorkspaceInventory:
|
|
553
|
+
try:
|
|
554
|
+
return capture_inventory(plan.workspace)
|
|
555
|
+
except InventoryError as exc:
|
|
556
|
+
raise ExecutionError(
|
|
557
|
+
ExecutionErrorCode.WORKSPACE_INVENTORY_FAILED,
|
|
558
|
+
"workspace baseline inventory could not be captured",
|
|
559
|
+
path=exc.path.as_posix() if exc.path is not None else None,
|
|
560
|
+
) from exc
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _compare_workspace_to_baseline(
|
|
564
|
+
plan: Plan,
|
|
565
|
+
baseline: WorkspaceInventory,
|
|
566
|
+
) -> WorkspaceComparison:
|
|
567
|
+
try:
|
|
568
|
+
current = capture_inventory(plan.workspace)
|
|
569
|
+
except InventoryError as exc:
|
|
570
|
+
raise ExecutionError(
|
|
571
|
+
ExecutionErrorCode.WORKSPACE_INVENTORY_FAILED,
|
|
572
|
+
"final workspace inventory could not be captured",
|
|
573
|
+
path=exc.path.as_posix() if exc.path is not None else None,
|
|
574
|
+
) from exc
|
|
575
|
+
return compare_inventories(
|
|
576
|
+
baseline,
|
|
577
|
+
current,
|
|
578
|
+
expected_paths=(
|
|
579
|
+
*plan.files_to_create,
|
|
580
|
+
*plan.files_to_modify,
|
|
581
|
+
*plan.directories_to_create,
|
|
582
|
+
),
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _safe_workspace_comparison(
|
|
587
|
+
plan: Plan,
|
|
588
|
+
baseline: WorkspaceInventory,
|
|
589
|
+
*,
|
|
590
|
+
fallback: WorkspaceComparison | None,
|
|
591
|
+
) -> WorkspaceComparison | None:
|
|
592
|
+
try:
|
|
593
|
+
return _compare_workspace_to_baseline(plan, baseline)
|
|
594
|
+
except ExecutionError:
|
|
595
|
+
return fallback
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _revalidate_plan(plan: Plan) -> None:
|
|
599
|
+
try:
|
|
600
|
+
current = plan_job(plan.job, plan.workspace.root)
|
|
601
|
+
except (PlanningError, PolicyError, WorkspaceError) as exc:
|
|
602
|
+
raise ExecutionError(
|
|
603
|
+
ExecutionErrorCode.PLAN_STALE,
|
|
604
|
+
"the workspace no longer matches the approved plan",
|
|
605
|
+
item_id=getattr(exc, "item_id", None),
|
|
606
|
+
path=getattr(exc, "path", None),
|
|
607
|
+
) from exc
|
|
608
|
+
if current != plan:
|
|
609
|
+
raise ExecutionError(
|
|
610
|
+
ExecutionErrorCode.PLAN_STALE,
|
|
611
|
+
"the workspace no longer matches the approved plan",
|
|
612
|
+
path=_first_stale_path(plan, current),
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _first_stale_path(plan: Plan, current: Plan) -> str | None:
|
|
617
|
+
current_creates = set(current.files_to_create) | set(current.directories_to_create)
|
|
618
|
+
for path in (*plan.files_to_create, *plan.directories_to_create):
|
|
619
|
+
if path not in current_creates:
|
|
620
|
+
return path.as_posix()
|
|
621
|
+
current_fingerprints = {item.path: item for item in current.fingerprints}
|
|
622
|
+
for fingerprint in plan.fingerprints:
|
|
623
|
+
if current_fingerprints.get(fingerprint.path) != fingerprint:
|
|
624
|
+
return fingerprint.path.as_posix()
|
|
625
|
+
return None
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _create_required_directories(
|
|
629
|
+
plan: Plan,
|
|
630
|
+
target: PurePosixPath,
|
|
631
|
+
created: list[PurePosixPath],
|
|
632
|
+
) -> None:
|
|
633
|
+
for path in plan.directories_to_create:
|
|
634
|
+
if path in created:
|
|
635
|
+
continue
|
|
636
|
+
if path != target and path not in target.parents:
|
|
637
|
+
continue
|
|
638
|
+
actions.create_directory(plan.workspace, path)
|
|
639
|
+
created.append(path)
|
|
640
|
+
actions.verify_created_directory(plan.workspace, path)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _verify_transaction_files_after_checks(
|
|
644
|
+
plan: Plan,
|
|
645
|
+
backup: PreparedBackup,
|
|
646
|
+
check_results: tuple[CheckResult, ...],
|
|
647
|
+
) -> None:
|
|
648
|
+
_verify_planned_transaction_files(
|
|
649
|
+
plan,
|
|
650
|
+
backup,
|
|
651
|
+
excluded=frozenset(),
|
|
652
|
+
code=ExecutionErrorCode.CHECK_FAILED,
|
|
653
|
+
message="project checks changed a declared transaction file",
|
|
654
|
+
check_results=check_results,
|
|
655
|
+
formatting_results=(),
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _verify_planned_transaction_files(
|
|
660
|
+
plan: Plan,
|
|
661
|
+
backup: PreparedBackup,
|
|
662
|
+
*,
|
|
663
|
+
excluded: frozenset[PurePosixPath],
|
|
664
|
+
code: ExecutionErrorCode,
|
|
665
|
+
message: str,
|
|
666
|
+
check_results: tuple[CheckResult, ...],
|
|
667
|
+
formatting_results: tuple[FormatterResult, ...],
|
|
668
|
+
) -> None:
|
|
669
|
+
for change in plan.file_changes:
|
|
670
|
+
if change.path in excluded:
|
|
671
|
+
continue
|
|
672
|
+
try:
|
|
673
|
+
if change.disposition is FileDisposition.CREATE:
|
|
674
|
+
actions.verify_created_file(plan.workspace, change)
|
|
675
|
+
else:
|
|
676
|
+
entry = backup.entry_for(change.path)
|
|
677
|
+
if entry.original_mode is None:
|
|
678
|
+
raise OSError("modified file backup is missing its mode")
|
|
679
|
+
actions.verify_modified_file(
|
|
680
|
+
plan.workspace,
|
|
681
|
+
change,
|
|
682
|
+
mode=entry.original_mode,
|
|
683
|
+
)
|
|
684
|
+
except (KeyError, OSError, PolicyError) as exc:
|
|
685
|
+
raise ExecutionError(
|
|
686
|
+
code,
|
|
687
|
+
message,
|
|
688
|
+
path=change.path.as_posix(),
|
|
689
|
+
check_results=check_results,
|
|
690
|
+
formatting_results=formatting_results,
|
|
691
|
+
) from exc
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _capture_formatter_states(
|
|
695
|
+
plan: Plan,
|
|
696
|
+
*,
|
|
697
|
+
check_results: tuple[CheckResult, ...],
|
|
698
|
+
formatting_results: tuple[FormatterResult, ...],
|
|
699
|
+
message: str,
|
|
700
|
+
) -> tuple[FormattedFileState, ...]:
|
|
701
|
+
try:
|
|
702
|
+
return capture_formatted_files(plan)
|
|
703
|
+
except (OSError, PolicyError, ValueError) as exc:
|
|
704
|
+
raise ExecutionError(
|
|
705
|
+
ExecutionErrorCode.FORMAT_FAILED,
|
|
706
|
+
message,
|
|
707
|
+
item_id="formatting",
|
|
708
|
+
path=_first_formatter_path(plan),
|
|
709
|
+
check_results=check_results,
|
|
710
|
+
formatting_results=formatting_results,
|
|
711
|
+
) from exc
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _require_preserved_formatter_modes(
|
|
715
|
+
before: tuple[FormattedFileState, ...],
|
|
716
|
+
after: tuple[FormattedFileState, ...],
|
|
717
|
+
*,
|
|
718
|
+
check_results: tuple[CheckResult, ...],
|
|
719
|
+
formatting_results: tuple[FormatterResult, ...],
|
|
720
|
+
) -> None:
|
|
721
|
+
if tuple(item.path for item in before) != tuple(item.path for item in after):
|
|
722
|
+
raise ExecutionError(
|
|
723
|
+
ExecutionErrorCode.FORMAT_FAILED,
|
|
724
|
+
"formatter output scope no longer matches the approved plan",
|
|
725
|
+
item_id="formatting",
|
|
726
|
+
path=before[0].path.as_posix() if before else None,
|
|
727
|
+
check_results=check_results,
|
|
728
|
+
formatting_results=formatting_results,
|
|
729
|
+
)
|
|
730
|
+
for earlier, later in zip(before, after):
|
|
731
|
+
if earlier.mode != later.mode:
|
|
732
|
+
raise ExecutionError(
|
|
733
|
+
ExecutionErrorCode.FORMAT_FAILED,
|
|
734
|
+
"formatter changed the mode of a transaction file",
|
|
735
|
+
item_id="formatting",
|
|
736
|
+
path=later.path.as_posix(),
|
|
737
|
+
check_results=check_results,
|
|
738
|
+
formatting_results=formatting_results,
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _first_formatter_path(plan: Plan) -> str | None:
|
|
743
|
+
return plan.formatting_targets[0].as_posix() if plan.formatting_targets else None
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _action_failure(
|
|
747
|
+
exc: BaseException,
|
|
748
|
+
*,
|
|
749
|
+
item_id: str | None,
|
|
750
|
+
path: PurePosixPath | None,
|
|
751
|
+
backup: PreparedBackup,
|
|
752
|
+
) -> ExecutionError:
|
|
753
|
+
if isinstance(exc, ExecutionError):
|
|
754
|
+
return ExecutionError(
|
|
755
|
+
exc.code,
|
|
756
|
+
exc.message,
|
|
757
|
+
item_id=exc.item_id or item_id,
|
|
758
|
+
path=exc.path or (path.as_posix() if path is not None else None),
|
|
759
|
+
backup_path=backup.path,
|
|
760
|
+
rollback_skipped=exc.rollback_skipped,
|
|
761
|
+
changes_kept=exc.changes_kept,
|
|
762
|
+
cause_code=exc.cause_code,
|
|
763
|
+
check_results=exc.check_results,
|
|
764
|
+
formatting_results=exc.formatting_results,
|
|
765
|
+
workspace_comparison=exc.workspace_comparison,
|
|
766
|
+
)
|
|
767
|
+
return ExecutionError(
|
|
768
|
+
ExecutionErrorCode.ACTION_FAILED,
|
|
769
|
+
"a planned transaction action failed",
|
|
770
|
+
item_id=item_id,
|
|
771
|
+
path=path.as_posix() if path is not None else None,
|
|
772
|
+
backup_path=backup.path,
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _record_retained_failure(
|
|
777
|
+
failure: ExecutionError,
|
|
778
|
+
backup: PreparedBackup,
|
|
779
|
+
*,
|
|
780
|
+
changes_present: bool,
|
|
781
|
+
) -> None:
|
|
782
|
+
failure.rollback_skipped = True
|
|
783
|
+
failure.changes_kept = changes_present
|
|
784
|
+
status = BackupStatus.CHANGES_KEPT if changes_present else BackupStatus.FAILED
|
|
785
|
+
try:
|
|
786
|
+
update_backup(
|
|
787
|
+
backup,
|
|
788
|
+
status,
|
|
789
|
+
failure_code=failure.code,
|
|
790
|
+
)
|
|
791
|
+
except ExecutionError as error:
|
|
792
|
+
error.cause_code = failure.code
|
|
793
|
+
error.check_results = failure.check_results
|
|
794
|
+
error.formatting_results = failure.formatting_results
|
|
795
|
+
error.rollback_skipped = True
|
|
796
|
+
error.changes_kept = changes_present
|
|
797
|
+
raise
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _rollback_or_raise(
|
|
801
|
+
failure: ExecutionError,
|
|
802
|
+
backup: PreparedBackup,
|
|
803
|
+
*,
|
|
804
|
+
files: tuple[PurePosixPath, ...],
|
|
805
|
+
directories: tuple[PurePosixPath, ...],
|
|
806
|
+
modified_files: tuple[PurePosixPath, ...],
|
|
807
|
+
) -> None:
|
|
808
|
+
try:
|
|
809
|
+
if modified_files:
|
|
810
|
+
rollback = rollback_transaction(
|
|
811
|
+
backup.plan.workspace,
|
|
812
|
+
backup,
|
|
813
|
+
modified_files=modified_files,
|
|
814
|
+
files=files,
|
|
815
|
+
directories=directories,
|
|
816
|
+
)
|
|
817
|
+
else:
|
|
818
|
+
rollback = rollback_created(
|
|
819
|
+
backup.plan.workspace,
|
|
820
|
+
files=files,
|
|
821
|
+
directories=directories,
|
|
822
|
+
)
|
|
823
|
+
except BaseException as exc:
|
|
824
|
+
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
|
825
|
+
raise
|
|
826
|
+
raise ExecutionError(
|
|
827
|
+
ExecutionErrorCode.ROLLBACK_FAILED,
|
|
828
|
+
"rollback failed before it could report a complete result",
|
|
829
|
+
backup_path=backup.path,
|
|
830
|
+
rollback_succeeded=False,
|
|
831
|
+
cause_code=failure.code,
|
|
832
|
+
check_results=failure.check_results,
|
|
833
|
+
formatting_results=failure.formatting_results,
|
|
834
|
+
workspace_comparison=failure.workspace_comparison,
|
|
835
|
+
) from exc
|
|
836
|
+
if rollback.success:
|
|
837
|
+
try:
|
|
838
|
+
update_backup(
|
|
839
|
+
backup,
|
|
840
|
+
BackupStatus.ROLLED_BACK,
|
|
841
|
+
failure_code=failure.code,
|
|
842
|
+
)
|
|
843
|
+
except ExecutionError as exc:
|
|
844
|
+
exc.rollback_succeeded = True
|
|
845
|
+
exc.cause_code = failure.code
|
|
846
|
+
exc.check_results = failure.check_results
|
|
847
|
+
exc.formatting_results = failure.formatting_results
|
|
848
|
+
raise
|
|
849
|
+
failure.rollback_succeeded = True
|
|
850
|
+
return
|
|
851
|
+
|
|
852
|
+
try:
|
|
853
|
+
update_backup(
|
|
854
|
+
backup,
|
|
855
|
+
BackupStatus.ROLLBACK_FAILED,
|
|
856
|
+
failure_code=failure.code,
|
|
857
|
+
)
|
|
858
|
+
except ExecutionError:
|
|
859
|
+
pass
|
|
860
|
+
raise ExecutionError(
|
|
861
|
+
ExecutionErrorCode.ROLLBACK_FAILED,
|
|
862
|
+
"rollback could not restore every created path",
|
|
863
|
+
path=rollback.unresolved[0].as_posix(),
|
|
864
|
+
backup_path=backup.path,
|
|
865
|
+
rollback_succeeded=False,
|
|
866
|
+
cause_code=failure.code,
|
|
867
|
+
check_results=failure.check_results,
|
|
868
|
+
formatting_results=failure.formatting_results,
|
|
869
|
+
workspace_comparison=failure.workspace_comparison,
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
__all__ = [
|
|
874
|
+
"TransactionResult",
|
|
875
|
+
"TransactionStatus",
|
|
876
|
+
"acquire_workspace_lock",
|
|
877
|
+
"execute_change_transaction",
|
|
878
|
+
"execute_change_transaction_locked",
|
|
879
|
+
"execute_create_transaction",
|
|
880
|
+
]
|