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.
Files changed (44) hide show
  1. patchshuttle/__init__.py +98 -0
  2. patchshuttle/_diff.py +317 -0
  3. patchshuttle/_process.py +198 -0
  4. patchshuttle/_version.py +3 -0
  5. patchshuttle/actions/__init__.py +80 -0
  6. patchshuttle/actions/constructors.py +211 -0
  7. patchshuttle/actions/create.py +155 -0
  8. patchshuttle/actions/modify.py +174 -0
  9. patchshuttle/audit.py +588 -0
  10. patchshuttle/backup.py +712 -0
  11. patchshuttle/checks/__init__.py +37 -0
  12. patchshuttle/checks/constructors.py +67 -0
  13. patchshuttle/checks/runner.py +233 -0
  14. patchshuttle/cli.py +766 -0
  15. patchshuttle/config.py +247 -0
  16. patchshuttle/context.py +370 -0
  17. patchshuttle/errors.py +291 -0
  18. patchshuttle/execution.py +651 -0
  19. patchshuttle/formatters/__init__.py +25 -0
  20. patchshuttle/formatters/runner.py +240 -0
  21. patchshuttle/identifiers.py +20 -0
  22. patchshuttle/inventory.py +331 -0
  23. patchshuttle/logging.py +741 -0
  24. patchshuttle/models.py +496 -0
  25. patchshuttle/operations.py +292 -0
  26. patchshuttle/parser.py +243 -0
  27. patchshuttle/planner.py +1144 -0
  28. patchshuttle/policy.py +377 -0
  29. patchshuttle/py.typed +1 -0
  30. patchshuttle/registry.py +275 -0
  31. patchshuttle/resources/AI_GUIDE.md +163 -0
  32. patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
  33. patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
  34. patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
  35. patchshuttle/resources/__init__.py +1 -0
  36. patchshuttle/rollback.py +306 -0
  37. patchshuttle/runner.py +880 -0
  38. patchshuttle/verification.py +107 -0
  39. patchshuttle/workspace.py +382 -0
  40. patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
  41. patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
  42. patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
  43. patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
  44. patchshuttle-0.1.0a2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,292 @@
1
+ """Project-level manual recovery operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import stat
6
+ from dataclasses import dataclass
7
+ from pathlib import Path, PurePosixPath
8
+
9
+ from patchshuttle.backup import (
10
+ BackupStatus,
11
+ load_completed_backup,
12
+ update_loaded_backup,
13
+ )
14
+ from patchshuttle.errors import ExecutionError, ExecutionErrorCode, JobError
15
+ from patchshuttle.logging import (
16
+ ManualRollbackLogRecord,
17
+ RunLogData,
18
+ current_run_clock,
19
+ write_run_log,
20
+ )
21
+ from patchshuttle.models import Job, JobKind
22
+ from patchshuttle.parser import load_job
23
+ from patchshuttle.planner import normalized_job_hash
24
+ from patchshuttle.registry import get_job, load_registry, update_registry
25
+ from patchshuttle.rollback import RollbackResult, rollback_completed_backup
26
+ from patchshuttle.runner import acquire_workspace_lock
27
+ from patchshuttle.workspace import Workspace
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ManualRollbackResult:
32
+ """A verified successful user-requested rollback."""
33
+
34
+ job_id: str
35
+ job_hash: str
36
+ backup_path: Path
37
+ restored_files: tuple[PurePosixPath, ...]
38
+ removed_files: tuple[PurePosixPath, ...]
39
+ removed_directories: tuple[PurePosixPath, ...]
40
+ log_path: Path
41
+
42
+
43
+ def rollback_job(
44
+ workspace: Workspace,
45
+ job_id: str,
46
+ *,
47
+ approved: bool = False,
48
+ ) -> ManualRollbackResult:
49
+ """Safely restore one completed patch from its retained manifest."""
50
+
51
+ if not approved:
52
+ raise ExecutionError(
53
+ ExecutionErrorCode.APPROVAL_REQUIRED,
54
+ "explicit approval is required before a completed job is rolled back",
55
+ item_id=job_id,
56
+ )
57
+ clock = current_run_clock(workspace)
58
+ with acquire_workspace_lock(workspace):
59
+ registry = load_registry(workspace)
60
+ record = get_job(registry, job_id)
61
+ if record.kind != JobKind.PATCH.value:
62
+ raise ExecutionError(
63
+ ExecutionErrorCode.ROLLBACK_FAILED,
64
+ "only a completed patch job can be rolled back",
65
+ item_id=job_id,
66
+ rollback_succeeded=False,
67
+ )
68
+ if not record.completed or record.backup_reference is None:
69
+ raise ExecutionError(
70
+ ExecutionErrorCode.ROLLBACK_FAILED,
71
+ "job does not have a completed backup available for manual rollback",
72
+ item_id=job_id,
73
+ rollback_succeeded=False,
74
+ )
75
+ job, archived = _find_archived_job(
76
+ workspace,
77
+ job_id=job_id,
78
+ job_hash=record.job_hash,
79
+ preferred=record.archived_job_copy,
80
+ )
81
+ backup = load_completed_backup(
82
+ workspace,
83
+ record.backup_reference,
84
+ job_id=job_id,
85
+ job_hash=record.job_hash,
86
+ )
87
+ try:
88
+ rollback = rollback_completed_backup(workspace, backup)
89
+ if not rollback.success:
90
+ update_loaded_backup(
91
+ backup,
92
+ BackupStatus.ROLLBACK_FAILED,
93
+ failure_code=ExecutionErrorCode.ROLLBACK_FAILED,
94
+ )
95
+ error = ExecutionError(
96
+ ExecutionErrorCode.ROLLBACK_FAILED,
97
+ "manual rollback left one or more transaction paths unresolved",
98
+ item_id=job_id,
99
+ path=rollback.unresolved[0].as_posix(),
100
+ backup_path=backup.path,
101
+ rollback_succeeded=False,
102
+ )
103
+ _record_failed_rollback(
104
+ workspace,
105
+ registry,
106
+ job,
107
+ record.job_hash,
108
+ archived,
109
+ clock,
110
+ backup.path,
111
+ rollback,
112
+ error,
113
+ )
114
+ raise error
115
+ except ExecutionError as error:
116
+ if error.log_path is None:
117
+ _record_failed_rollback(
118
+ workspace,
119
+ registry,
120
+ job,
121
+ record.job_hash,
122
+ archived,
123
+ clock,
124
+ backup.path,
125
+ RollbackResult((), (), ()),
126
+ error,
127
+ )
128
+ raise
129
+
130
+ update_loaded_backup(backup, BackupStatus.ROLLED_BACK)
131
+ log_record = ManualRollbackLogRecord(
132
+ status="SUCCESS",
133
+ backup_path=backup.path,
134
+ restored_files=rollback.restored_files,
135
+ removed_files=rollback.removed_files,
136
+ removed_directories=rollback.removed_directories,
137
+ )
138
+ log_path = write_run_log(
139
+ RunLogData(
140
+ workspace=workspace,
141
+ job=job,
142
+ job_hash=record.job_hash,
143
+ clock=clock,
144
+ result="ROLLED_BACK",
145
+ exit_code=0,
146
+ failure_stage=None,
147
+ failure_code=None,
148
+ archived_job_path=archived,
149
+ manual_rollback=log_record,
150
+ )
151
+ )
152
+ update_registry(
153
+ workspace,
154
+ registry,
155
+ job_id=job_id,
156
+ job_hash=record.job_hash,
157
+ kind=JobKind.PATCH,
158
+ occurred_at=clock.iso_timestamp,
159
+ result="ROLLED_BACK",
160
+ backup_path=backup.path,
161
+ rollback_state="SUCCESS",
162
+ archived_job_path=archived,
163
+ completed=False,
164
+ reset_completed=True,
165
+ )
166
+ return ManualRollbackResult(
167
+ job_id=job_id,
168
+ job_hash=record.job_hash,
169
+ backup_path=backup.path,
170
+ restored_files=rollback.restored_files,
171
+ removed_files=rollback.removed_files,
172
+ removed_directories=rollback.removed_directories,
173
+ log_path=log_path,
174
+ )
175
+
176
+
177
+ def _record_failed_rollback(
178
+ workspace: Workspace,
179
+ registry,
180
+ job: Job,
181
+ job_hash: str,
182
+ archived: Path,
183
+ clock,
184
+ backup_path: Path,
185
+ rollback: RollbackResult,
186
+ error: ExecutionError,
187
+ ) -> None:
188
+ log_path = write_run_log(
189
+ RunLogData(
190
+ workspace=workspace,
191
+ job=job,
192
+ job_hash=job_hash,
193
+ clock=clock,
194
+ result="ROLLBACK_FAILED",
195
+ exit_code=8,
196
+ failure_stage="ROLLBACK",
197
+ failure_code=(error.cause_code or error.code).value,
198
+ archived_job_path=archived,
199
+ error=error,
200
+ manual_rollback=ManualRollbackLogRecord(
201
+ status="FAILED",
202
+ backup_path=backup_path,
203
+ restored_files=rollback.restored_files,
204
+ removed_files=rollback.removed_files,
205
+ removed_directories=rollback.removed_directories,
206
+ unresolved=rollback.unresolved,
207
+ ),
208
+ )
209
+ )
210
+ update_registry(
211
+ workspace,
212
+ registry,
213
+ job_id=job.id,
214
+ job_hash=job_hash,
215
+ kind=JobKind.PATCH,
216
+ occurred_at=clock.iso_timestamp,
217
+ result="ROLLBACK_FAILED",
218
+ backup_path=backup_path,
219
+ rollback_state="FAILED",
220
+ archived_job_path=archived,
221
+ completed=False,
222
+ )
223
+ error.log_path = log_path
224
+
225
+
226
+ def _find_archived_job(
227
+ workspace: Workspace,
228
+ *,
229
+ job_id: str,
230
+ job_hash: str,
231
+ preferred: str,
232
+ ) -> tuple[Job, Path]:
233
+ candidates: list[Path] = []
234
+ preferred_path = _safe_archive_path(workspace, preferred)
235
+ if preferred_path is not None:
236
+ candidates.append(preferred_path)
237
+ for directory_name in ("applied", "failed"):
238
+ directory = workspace.patches_dir / directory_name
239
+ try:
240
+ paths = sorted(
241
+ directory.glob(f"{job_id}_*_{job_hash[:8]}.psh.yaml"),
242
+ reverse=True,
243
+ )
244
+ except OSError:
245
+ paths = []
246
+ candidates.extend(path for path in paths if path not in candidates)
247
+ for path in candidates:
248
+ try:
249
+ metadata = path.lstat()
250
+ if not stat.S_ISREG(metadata.st_mode) or path.is_symlink():
251
+ continue
252
+ job = load_job(
253
+ path,
254
+ max_bytes=workspace.config.execution.max_job_bytes,
255
+ )
256
+ except (OSError, JobError, ValueError):
257
+ continue
258
+ if (
259
+ job.id == job_id
260
+ and job.kind is JobKind.PATCH
261
+ and normalized_job_hash(job) == job_hash
262
+ ):
263
+ return job, path
264
+ raise ExecutionError(
265
+ ExecutionErrorCode.ROLLBACK_FAILED,
266
+ "an intact archived copy of the completed job was not found",
267
+ item_id=job_id,
268
+ rollback_succeeded=False,
269
+ )
270
+
271
+
272
+ def _safe_archive_path(workspace: Workspace, value: str) -> Path | None:
273
+ relative = PurePosixPath(value)
274
+ if (
275
+ relative.is_absolute()
276
+ or "\\" in value
277
+ or len(relative.parts) != 3
278
+ or relative.parts[0] != "patches"
279
+ or relative.parts[1] not in {"applied", "failed"}
280
+ or any(part in {"", ".", ".."} for part in relative.parts)
281
+ ):
282
+ return None
283
+ path = workspace.root.joinpath(*relative.parts)
284
+ try:
285
+ if path.resolve() != path.absolute():
286
+ return None
287
+ except OSError:
288
+ return None
289
+ return path
290
+
291
+
292
+ __all__ = ["ManualRollbackResult", "rollback_job"]
patchshuttle/parser.py ADDED
@@ -0,0 +1,243 @@
1
+ """Safe loading and structural validation for ``.psh.yaml`` jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import stat
8
+ from collections.abc import Mapping
9
+ from os import PathLike
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import yaml
14
+ from pydantic import ValidationError
15
+ from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode
16
+ from yaml.tokens import AliasToken, AnchorToken
17
+
18
+ from patchshuttle.errors import JobError, JobErrorCode
19
+ from patchshuttle.models import Job
20
+
21
+ DEFAULT_MAX_JOB_BYTES = 2_000_000
22
+ _CANONICAL_EXTENSION = ".psh.yaml"
23
+ _STRING_TAG = "tag:yaml.org,2002:str"
24
+ _SAFE_STANDARD_TAGS = frozenset(
25
+ tag for tag in yaml.SafeLoader.yaml_constructors if isinstance(tag, str)
26
+ )
27
+ _PLAIN_PATH_SEGMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*")
28
+
29
+
30
+ def load_job(
31
+ path: str | PathLike[str],
32
+ *,
33
+ max_bytes: int = DEFAULT_MAX_JOB_BYTES,
34
+ ) -> Job:
35
+ """Load one canonical UTF-8 YAML file and return a validated immutable job."""
36
+
37
+ if type(max_bytes) is not int or max_bytes <= 0:
38
+ raise ValueError("max_bytes must be a positive integer")
39
+
40
+ job_path = Path(path)
41
+ if not job_path.name.endswith(_CANONICAL_EXTENSION):
42
+ raise JobError(
43
+ JobErrorCode.JOB_EXTENSION_INVALID,
44
+ f"job filename must end with {_CANONICAL_EXTENSION}",
45
+ )
46
+
47
+ try:
48
+ file_stat = job_path.stat()
49
+ except FileNotFoundError as exc:
50
+ raise JobError(
51
+ JobErrorCode.JOB_FILE_NOT_FOUND,
52
+ "job file was not found",
53
+ ) from exc
54
+ except OSError as exc:
55
+ raise JobError(
56
+ JobErrorCode.JOB_FILE_READ_FAILED,
57
+ "job file metadata could not be read",
58
+ ) from exc
59
+
60
+ if not stat.S_ISREG(file_stat.st_mode):
61
+ raise JobError(
62
+ JobErrorCode.JOB_FILE_NOT_REGULAR,
63
+ "job path must identify a regular file",
64
+ )
65
+ if file_stat.st_size > max_bytes:
66
+ raise JobError(
67
+ JobErrorCode.JOB_SIZE_LIMIT_EXCEEDED,
68
+ f"job file exceeds the {max_bytes}-byte input limit",
69
+ )
70
+
71
+ try:
72
+ raw = job_path.read_bytes()
73
+ except FileNotFoundError as exc:
74
+ raise JobError(
75
+ JobErrorCode.JOB_FILE_NOT_FOUND,
76
+ "job file was not found",
77
+ ) from exc
78
+ except OSError as exc:
79
+ raise JobError(
80
+ JobErrorCode.JOB_FILE_READ_FAILED,
81
+ "job file could not be read",
82
+ ) from exc
83
+
84
+ if len(raw) > max_bytes:
85
+ raise JobError(
86
+ JobErrorCode.JOB_SIZE_LIMIT_EXCEEDED,
87
+ f"job file exceeds the {max_bytes}-byte input limit",
88
+ )
89
+
90
+ try:
91
+ text = raw.decode("utf-8")
92
+ except UnicodeDecodeError as exc:
93
+ raise JobError(
94
+ JobErrorCode.JOB_ENCODING_INVALID,
95
+ "job file must be valid UTF-8 text",
96
+ ) from exc
97
+
98
+ return validate_job(_load_yaml(text))
99
+
100
+
101
+ def validate_job(value: object) -> Job:
102
+ """Validate mapping-like data with the same models used by YAML jobs."""
103
+
104
+ if isinstance(value, Job):
105
+ return value
106
+ if not isinstance(value, Mapping):
107
+ raise JobError(
108
+ JobErrorCode.JOB_ROOT_INVALID,
109
+ "job document root must be a mapping",
110
+ field_path="$",
111
+ )
112
+
113
+ try:
114
+ return Job.model_validate(value)
115
+ except ValidationError as exc:
116
+ first_error = exc.errors(include_url=False)[0]
117
+ raise JobError(
118
+ JobErrorCode.JOB_SCHEMA_INVALID,
119
+ str(first_error["msg"]),
120
+ field_path=_format_validation_path(first_error["loc"]),
121
+ ) from exc
122
+
123
+
124
+ def _load_yaml(text: str) -> Mapping[str, Any]:
125
+ try:
126
+ _reject_references(text)
127
+ node = yaml.compose(text, Loader=yaml.SafeLoader)
128
+ except JobError:
129
+ raise
130
+ except (yaml.YAMLError, RecursionError) as exc:
131
+ raise _yaml_syntax_error(exc) from exc
132
+
133
+ if node is None:
134
+ raise JobError(
135
+ JobErrorCode.JOB_ROOT_INVALID,
136
+ "job document root must be a mapping",
137
+ field_path="$",
138
+ )
139
+
140
+ _validate_node(node, path="$")
141
+
142
+ try:
143
+ data = yaml.safe_load(text)
144
+ except (yaml.YAMLError, RecursionError) as exc:
145
+ raise _yaml_syntax_error(exc) from exc
146
+
147
+ if not isinstance(data, Mapping):
148
+ raise JobError(
149
+ JobErrorCode.JOB_ROOT_INVALID,
150
+ "job document root must be a mapping",
151
+ field_path="$",
152
+ )
153
+ return data
154
+
155
+
156
+ def _reject_references(text: str) -> None:
157
+ for token in yaml.scan(text, Loader=yaml.SafeLoader):
158
+ if isinstance(token, AnchorToken):
159
+ raise JobError(
160
+ JobErrorCode.YAML_ANCHOR_FORBIDDEN,
161
+ "YAML anchors are not allowed",
162
+ field_path="$",
163
+ line=token.start_mark.line + 1,
164
+ column=token.start_mark.column + 1,
165
+ )
166
+ if isinstance(token, AliasToken):
167
+ raise JobError(
168
+ JobErrorCode.YAML_ALIAS_FORBIDDEN,
169
+ "YAML aliases are not allowed",
170
+ field_path="$",
171
+ line=token.start_mark.line + 1,
172
+ column=token.start_mark.column + 1,
173
+ )
174
+
175
+
176
+ def _validate_node(node: Node, *, path: str) -> None:
177
+ if node.tag not in _SAFE_STANDARD_TAGS:
178
+ raise JobError(
179
+ JobErrorCode.YAML_TAG_FORBIDDEN,
180
+ "custom YAML tags are not allowed",
181
+ field_path=path,
182
+ line=node.start_mark.line + 1,
183
+ column=node.start_mark.column + 1,
184
+ )
185
+
186
+ if isinstance(node, MappingNode):
187
+ seen: set[str] = set()
188
+ for key_node, value_node in node.value:
189
+ if not isinstance(key_node, ScalarNode) or key_node.tag != _STRING_TAG:
190
+ raise JobError(
191
+ JobErrorCode.YAML_MAPPING_KEY_INVALID,
192
+ "YAML mapping keys must be strings",
193
+ field_path=path,
194
+ line=key_node.start_mark.line + 1,
195
+ column=key_node.start_mark.column + 1,
196
+ )
197
+
198
+ key = key_node.value
199
+ child_path = _append_field(path, key)
200
+ if key in seen:
201
+ raise JobError(
202
+ JobErrorCode.YAML_DUPLICATE_KEY,
203
+ f"duplicate mapping key {key!r}",
204
+ field_path=child_path,
205
+ line=key_node.start_mark.line + 1,
206
+ column=key_node.start_mark.column + 1,
207
+ )
208
+ seen.add(key)
209
+ _validate_node(value_node, path=child_path)
210
+
211
+ elif isinstance(node, SequenceNode):
212
+ for index, item in enumerate(node.value):
213
+ _validate_node(item, path=f"{path}[{index}]")
214
+
215
+
216
+ def _yaml_syntax_error(exc: BaseException) -> JobError:
217
+ mark = getattr(exc, "problem_mark", None) or getattr(exc, "context_mark", None)
218
+ return JobError(
219
+ JobErrorCode.YAML_INVALID,
220
+ "job file contains invalid YAML syntax",
221
+ field_path="$",
222
+ line=mark.line + 1 if mark is not None else 1,
223
+ column=mark.column + 1 if mark is not None else 1,
224
+ )
225
+
226
+
227
+ def _format_validation_path(location: tuple[int | str, ...]) -> str:
228
+ path = "$"
229
+ for part in location:
230
+ if isinstance(part, int):
231
+ path = f"{path}[{part}]"
232
+ else:
233
+ path = _append_field(path, str(part))
234
+ return path
235
+
236
+
237
+ def _append_field(path: str, field: str) -> str:
238
+ if _PLAIN_PATH_SEGMENT.fullmatch(field):
239
+ return f"{path}.{field}"
240
+ return f"{path}[{json.dumps(field, ensure_ascii=False)}]"
241
+
242
+
243
+ __all__ = ["DEFAULT_MAX_JOB_BYTES", "load_job", "validate_job"]