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,240 @@
1
+ """Controlled isort and Black execution for immutable formatting scopes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import stat
7
+ import sys
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from pathlib import Path, PurePosixPath
11
+ from typing import Literal, TypeAlias
12
+
13
+ from patchshuttle._process import ProcessCommand, run_process
14
+ from patchshuttle.planner import Plan
15
+ from patchshuttle.policy import PathKind, Policy
16
+
17
+ FormatterName: TypeAlias = Literal["isort", "black"]
18
+ _FORMATTER_ORDER: tuple[FormatterName, FormatterName] = ("isort", "black")
19
+
20
+
21
+ class FormatterStatus(str, Enum):
22
+ """Observable outcome of one controlled formatter process."""
23
+
24
+ PASSED = "PASSED"
25
+ FAILED = "FAILED"
26
+ TIMED_OUT = "TIMED_OUT"
27
+ ERROR = "ERROR"
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class PreparedFormatter:
32
+ """One fixed formatter command over the approved changed-Python scope."""
33
+
34
+ id: str
35
+ name: FormatterName
36
+ argv: tuple[str, ...]
37
+ working_directory: Path
38
+ timeout_seconds: int
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class FormatterResult:
43
+ """Bounded captured outcome of one launched formatter."""
44
+
45
+ id: str
46
+ name: FormatterName
47
+ status: FormatterStatus
48
+ argv: tuple[str, ...]
49
+ working_directory: Path
50
+ timeout_seconds: int
51
+ return_code: int | None
52
+ duration_ms: int
53
+ stdout: str
54
+ stderr: str
55
+ stdout_truncated: bool
56
+ stderr_truncated: bool
57
+
58
+ @property
59
+ def success(self) -> bool:
60
+ return self.status is FormatterStatus.PASSED
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class FormatterRunResult:
65
+ """Ordered formatter results through the first failure, if any."""
66
+
67
+ results: tuple[FormatterResult, ...]
68
+
69
+ @property
70
+ def success(self) -> bool:
71
+ return all(result.success for result in self.results)
72
+
73
+ @property
74
+ def failed(self) -> FormatterResult | None:
75
+ return next((result for result in self.results if not result.success), None)
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class FormattedFileState:
80
+ """Exact bounded post-formatter state retained through final checks."""
81
+
82
+ path: PurePosixPath
83
+ sha256: str
84
+ size: int
85
+ mode: int
86
+ content: bytes = field(repr=False)
87
+
88
+
89
+ def prepare_formatters(plan: Plan) -> tuple[PreparedFormatter, ...]:
90
+ """Build fixed isort-then-Black commands from the immutable plan scope."""
91
+
92
+ formatting = plan.workspace.config.formatting
93
+ expected_targets = (
94
+ tuple(
95
+ change.path for change in plan.file_changes if change.path.suffix == ".py"
96
+ )
97
+ if formatting.enabled
98
+ else ()
99
+ )
100
+ if plan.formatting_targets != expected_targets:
101
+ raise ValueError("plan formatting targets do not match changed Python files")
102
+ if not expected_targets:
103
+ return ()
104
+ if formatting.order != _FORMATTER_ORDER:
105
+ raise ValueError("protocol 1 requires isort then Black formatter order")
106
+
107
+ paths = tuple(path.as_posix() for path in expected_targets)
108
+ timeout = plan.workspace.config.execution.default_timeout_seconds
109
+ commands: list[PreparedFormatter] = []
110
+ for index, name in enumerate(_FORMATTER_ORDER, start=1):
111
+ options = ("--overwrite-in-place",) if name == "isort" else ()
112
+ commands.append(
113
+ PreparedFormatter(
114
+ id=f"formatter_{index:03d}",
115
+ name=name,
116
+ argv=(
117
+ sys.executable,
118
+ "-I",
119
+ "-m",
120
+ name,
121
+ *options,
122
+ "--",
123
+ *paths,
124
+ ),
125
+ working_directory=plan.workspace.root,
126
+ timeout_seconds=timeout,
127
+ )
128
+ )
129
+ return tuple(commands)
130
+
131
+
132
+ def run_formatters(plan: Plan) -> FormatterRunResult:
133
+ """Run isort and Black sequentially, stopping at the first failure."""
134
+
135
+ maximum = plan.workspace.config.execution.max_command_output_bytes
136
+ results: list[FormatterResult] = []
137
+ for formatter in prepare_formatters(plan):
138
+ process = run_process(
139
+ ProcessCommand(
140
+ argv=formatter.argv,
141
+ working_directory=formatter.working_directory,
142
+ timeout_seconds=formatter.timeout_seconds,
143
+ ),
144
+ maximum_output_bytes=maximum,
145
+ )
146
+ result = FormatterResult(
147
+ id=formatter.id,
148
+ name=formatter.name,
149
+ status=FormatterStatus(process.status.value),
150
+ argv=formatter.argv,
151
+ working_directory=formatter.working_directory,
152
+ timeout_seconds=formatter.timeout_seconds,
153
+ return_code=process.return_code,
154
+ duration_ms=process.duration_ms,
155
+ stdout=process.stdout,
156
+ stderr=process.stderr,
157
+ stdout_truncated=process.stdout_truncated,
158
+ stderr_truncated=process.stderr_truncated,
159
+ )
160
+ results.append(result)
161
+ if not result.success:
162
+ break
163
+ return FormatterRunResult(results=tuple(results))
164
+
165
+
166
+ def capture_formatted_files(plan: Plan) -> tuple[FormattedFileState, ...]:
167
+ """Capture exact regular-file states for every approved formatter target."""
168
+
169
+ policy = Policy(plan.workspace)
170
+ maximum = plan.workspace.config.execution.max_single_file_bytes
171
+ return tuple(
172
+ _capture_file(policy, path, maximum=maximum) for path in plan.formatting_targets
173
+ )
174
+
175
+
176
+ def verify_formatted_files(
177
+ plan: Plan,
178
+ expected: tuple[FormattedFileState, ...],
179
+ ) -> None:
180
+ """Require formatter targets to retain their captured exact final state."""
181
+
182
+ if tuple(item.path for item in expected) != plan.formatting_targets:
183
+ raise ValueError("formatted-file snapshot scope does not match the plan")
184
+ if capture_formatted_files(plan) != expected:
185
+ raise OSError("a formatter target changed after formatting")
186
+
187
+
188
+ def _capture_file(
189
+ policy: Policy,
190
+ path: PurePosixPath,
191
+ *,
192
+ maximum: int,
193
+ ) -> FormattedFileState:
194
+ target = policy.resolve(path, allow_missing=True)
195
+ if target.kind is not PathKind.FILE:
196
+ raise OSError(f"formatter target is not a regular file: {path}")
197
+ before = target.absolute.lstat()
198
+ if before.st_size > maximum:
199
+ raise OSError(f"formatter output exceeds the configured size limit: {path}")
200
+ raw = target.absolute.read_bytes()
201
+ after_target = policy.resolve(path, allow_missing=True)
202
+ if after_target.kind is not PathKind.FILE:
203
+ raise OSError(f"formatter target is not a regular file: {path}")
204
+ after = after_target.absolute.lstat()
205
+ before_identity = (
206
+ before.st_dev,
207
+ before.st_ino,
208
+ before.st_size,
209
+ before.st_mtime_ns,
210
+ before.st_mode,
211
+ )
212
+ after_identity = (
213
+ after.st_dev,
214
+ after.st_ino,
215
+ after.st_size,
216
+ after.st_mtime_ns,
217
+ after.st_mode,
218
+ )
219
+ if before_identity != after_identity or len(raw) != after.st_size:
220
+ raise OSError(f"formatter target changed while it was captured: {path}")
221
+ return FormattedFileState(
222
+ path=path,
223
+ sha256=hashlib.sha256(raw).hexdigest(),
224
+ size=len(raw),
225
+ mode=stat.S_IMODE(after.st_mode),
226
+ content=raw,
227
+ )
228
+
229
+
230
+ __all__ = [
231
+ "FormattedFileState",
232
+ "FormatterResult",
233
+ "FormatterRunResult",
234
+ "FormatterStatus",
235
+ "PreparedFormatter",
236
+ "capture_formatted_files",
237
+ "prepare_formatters",
238
+ "run_formatters",
239
+ "verify_formatted_files",
240
+ ]
@@ -0,0 +1,20 @@
1
+ """Canonical project identifiers for PatchShuttle workspaces and jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from typing import Annotated, TypeAlias
7
+
8
+ from pydantic import Field
9
+
10
+ PROJECT_ID_PATTERN = r"^PSH-[0-9A-F]{16}$"
11
+ ProjectId: TypeAlias = Annotated[str, Field(strict=True, pattern=PROJECT_ID_PATTERN)]
12
+
13
+
14
+ def generate_project_id() -> str:
15
+ """Return a project identifier backed by eight secure random bytes."""
16
+
17
+ return f"PSH-{secrets.token_hex(8).upper()}"
18
+
19
+
20
+ __all__ = ["PROJECT_ID_PATTERN", "ProjectId", "generate_project_id"]
@@ -0,0 +1,331 @@
1
+ """Bounded workspace inventories and deterministic before/after comparison."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import stat
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+ from pathlib import Path, PurePosixPath
11
+
12
+ from patchshuttle.policy import Policy
13
+ from patchshuttle.workspace import Workspace
14
+
15
+ _HASH_CHUNK_BYTES = 1024 * 1024
16
+
17
+
18
+ class InventoryEntryKind(str, Enum):
19
+ """Filesystem kinds recorded without following symbolic links."""
20
+
21
+ FILE = "FILE"
22
+ DIRECTORY = "DIRECTORY"
23
+ SYMLINK = "SYMLINK"
24
+ OTHER = "OTHER"
25
+
26
+
27
+ class WorkspaceChangeKind(str, Enum):
28
+ """Stable classifications for a workspace difference."""
29
+
30
+ ADDED = "ADDED"
31
+ REMOVED = "REMOVED"
32
+ MODIFIED = "MODIFIED"
33
+ TYPE_CHANGED = "TYPE_CHANGED"
34
+
35
+
36
+ class InventoryErrorCode(str, Enum):
37
+ """Stable failures for bounded workspace inspection."""
38
+
39
+ ENTRY_LIMIT_EXCEEDED = "ENTRY_LIMIT_EXCEEDED"
40
+ BYTE_LIMIT_EXCEEDED = "BYTE_LIMIT_EXCEEDED"
41
+ INSPECTION_FAILED = "INSPECTION_FAILED"
42
+ FILE_CHANGED_DURING_CAPTURE = "FILE_CHANGED_DURING_CAPTURE"
43
+
44
+
45
+ class InventoryError(RuntimeError):
46
+ """A workspace inventory could not be captured exactly within policy."""
47
+
48
+ def __init__(
49
+ self,
50
+ code: InventoryErrorCode,
51
+ message: str,
52
+ *,
53
+ path: PurePosixPath | None = None,
54
+ ) -> None:
55
+ self.code = code
56
+ self.message = message
57
+ self.path = path
58
+ super().__init__(message)
59
+
60
+ def __str__(self) -> str:
61
+ prefix = f"[{self.code.value}]"
62
+ return (
63
+ f"{prefix} {self.path.as_posix()}: {self.message}"
64
+ if self.path is not None
65
+ else f"{prefix} {self.message}"
66
+ )
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class InventoryEntry:
71
+ """Exact metadata and optional content hash for one workspace path."""
72
+
73
+ path: PurePosixPath
74
+ kind: InventoryEntryKind
75
+ size: int
76
+ modified_ns: int
77
+ mode: int
78
+ sha256: str | None
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class WorkspaceInventory:
83
+ """One deterministic bounded snapshot of non-ignored workspace entries."""
84
+
85
+ entries: tuple[InventoryEntry, ...]
86
+ hashed_bytes: int
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class WorkspaceChange:
91
+ """One classified difference between two workspace inventories."""
92
+
93
+ path: PurePosixPath
94
+ kind: WorkspaceChangeKind
95
+ expected: bool
96
+ before: InventoryEntry | None
97
+ after: InventoryEntry | None
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class WorkspaceComparison:
102
+ """Complete inventory pair and its ordered differences."""
103
+
104
+ before: WorkspaceInventory
105
+ after: WorkspaceInventory
106
+ changes: tuple[WorkspaceChange, ...]
107
+
108
+ @property
109
+ def unexpected_changes(self) -> tuple[WorkspaceChange, ...]:
110
+ return tuple(change for change in self.changes if not change.expected)
111
+
112
+ @property
113
+ def success(self) -> bool:
114
+ return not self.unexpected_changes
115
+
116
+
117
+ def capture_inventory(workspace: Workspace) -> WorkspaceInventory:
118
+ """Hash every non-ignored regular file within configured hard limits."""
119
+
120
+ policy = Policy(workspace)
121
+ maximum_entries = workspace.config.execution.max_inventory_entries
122
+ maximum_bytes = workspace.config.execution.max_inventory_bytes
123
+ entries: list[InventoryEntry] = []
124
+ hashed_bytes = 0
125
+ pending = [(workspace.root, PurePosixPath())]
126
+
127
+ while pending:
128
+ directory, parent = pending.pop()
129
+ children = _scan_directory(directory, parent)
130
+ child_directories: list[tuple[Path, PurePosixPath]] = []
131
+ for child in children:
132
+ relative = parent / child.name
133
+ if policy.is_ignored(relative):
134
+ continue
135
+ if len(entries) >= maximum_entries:
136
+ raise InventoryError(
137
+ InventoryErrorCode.ENTRY_LIMIT_EXCEEDED,
138
+ "workspace inventory entry limit was exceeded",
139
+ path=relative,
140
+ )
141
+ metadata = _entry_metadata(child, relative)
142
+ kind = _entry_kind(metadata.st_mode)
143
+ digest: str | None = None
144
+ size = metadata.st_size
145
+ modified_ns = metadata.st_mtime_ns
146
+ if kind is InventoryEntryKind.FILE:
147
+ if hashed_bytes + size > maximum_bytes:
148
+ raise InventoryError(
149
+ InventoryErrorCode.BYTE_LIMIT_EXCEEDED,
150
+ "workspace inventory byte limit was exceeded",
151
+ path=relative,
152
+ )
153
+ digest = _hash_regular_file(Path(child.path), relative, metadata)
154
+ hashed_bytes += size
155
+ elif kind is InventoryEntryKind.DIRECTORY:
156
+ size = 0
157
+ modified_ns = 0
158
+ child_directories.append((Path(child.path), relative))
159
+
160
+ entries.append(
161
+ InventoryEntry(
162
+ path=relative,
163
+ kind=kind,
164
+ size=size,
165
+ modified_ns=modified_ns,
166
+ mode=stat.S_IMODE(metadata.st_mode),
167
+ sha256=digest,
168
+ )
169
+ )
170
+ pending.extend(reversed(child_directories))
171
+
172
+ entries.sort(key=lambda entry: entry.path.as_posix())
173
+ return WorkspaceInventory(entries=tuple(entries), hashed_bytes=hashed_bytes)
174
+
175
+
176
+ def compare_inventories(
177
+ before: WorkspaceInventory,
178
+ after: WorkspaceInventory,
179
+ *,
180
+ expected_paths: tuple[PurePosixPath, ...] = (),
181
+ ) -> WorkspaceComparison:
182
+ """Classify every before/after difference and mark declared paths."""
183
+
184
+ before_by_path = {entry.path: entry for entry in before.entries}
185
+ after_by_path = {entry.path: entry for entry in after.entries}
186
+ expected = frozenset(expected_paths)
187
+ changes: list[WorkspaceChange] = []
188
+ paths = sorted(
189
+ before_by_path.keys() | after_by_path.keys(),
190
+ key=PurePosixPath.as_posix,
191
+ )
192
+ for path in paths:
193
+ earlier = before_by_path.get(path)
194
+ later = after_by_path.get(path)
195
+ if earlier is None:
196
+ kind = WorkspaceChangeKind.ADDED
197
+ elif later is None:
198
+ kind = WorkspaceChangeKind.REMOVED
199
+ elif earlier.kind is not later.kind:
200
+ kind = WorkspaceChangeKind.TYPE_CHANGED
201
+ elif earlier != later:
202
+ kind = WorkspaceChangeKind.MODIFIED
203
+ else:
204
+ continue
205
+ changes.append(
206
+ WorkspaceChange(
207
+ path=path,
208
+ kind=kind,
209
+ expected=path in expected,
210
+ before=earlier,
211
+ after=later,
212
+ )
213
+ )
214
+
215
+ return WorkspaceComparison(
216
+ before=before,
217
+ after=after,
218
+ changes=tuple(changes),
219
+ )
220
+
221
+
222
+ def _scan_directory(
223
+ directory: Path,
224
+ relative: PurePosixPath,
225
+ ) -> tuple[os.DirEntry[str], ...]:
226
+ try:
227
+ with os.scandir(directory) as iterator:
228
+ return tuple(sorted(iterator, key=lambda entry: entry.name))
229
+ except OSError as exc:
230
+ raise InventoryError(
231
+ InventoryErrorCode.INSPECTION_FAILED,
232
+ "workspace directory could not be inspected",
233
+ path=relative if relative.parts else None,
234
+ ) from exc
235
+
236
+
237
+ def _entry_metadata(
238
+ entry: os.DirEntry[str],
239
+ relative: PurePosixPath,
240
+ ) -> os.stat_result:
241
+ try:
242
+ return entry.stat(follow_symlinks=False)
243
+ except OSError as exc:
244
+ raise InventoryError(
245
+ InventoryErrorCode.INSPECTION_FAILED,
246
+ "workspace entry metadata could not be inspected",
247
+ path=relative,
248
+ ) from exc
249
+
250
+
251
+ def _entry_kind(mode: int) -> InventoryEntryKind:
252
+ if stat.S_ISREG(mode):
253
+ return InventoryEntryKind.FILE
254
+ if stat.S_ISDIR(mode):
255
+ return InventoryEntryKind.DIRECTORY
256
+ if stat.S_ISLNK(mode):
257
+ return InventoryEntryKind.SYMLINK
258
+ return InventoryEntryKind.OTHER
259
+
260
+
261
+ def _hash_regular_file(
262
+ path: Path,
263
+ relative: PurePosixPath,
264
+ expected: os.stat_result,
265
+ ) -> str:
266
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
267
+ flags |= getattr(os, "O_NOFOLLOW", 0)
268
+ try:
269
+ descriptor = os.open(path, flags)
270
+ except OSError as exc:
271
+ raise InventoryError(
272
+ InventoryErrorCode.INSPECTION_FAILED,
273
+ "workspace file could not be opened for hashing",
274
+ path=relative,
275
+ ) from exc
276
+
277
+ digest = hashlib.sha256()
278
+ try:
279
+ opened = os.fstat(descriptor)
280
+ if not _same_file_state(expected, opened):
281
+ raise InventoryError(
282
+ InventoryErrorCode.FILE_CHANGED_DURING_CAPTURE,
283
+ "workspace file changed while inventory was captured",
284
+ path=relative,
285
+ )
286
+ while chunk := os.read(descriptor, _HASH_CHUNK_BYTES):
287
+ digest.update(chunk)
288
+ completed = os.fstat(descriptor)
289
+ if not _same_file_state(opened, completed):
290
+ raise InventoryError(
291
+ InventoryErrorCode.FILE_CHANGED_DURING_CAPTURE,
292
+ "workspace file changed while inventory was captured",
293
+ path=relative,
294
+ )
295
+ except OSError as exc:
296
+ raise InventoryError(
297
+ InventoryErrorCode.INSPECTION_FAILED,
298
+ "workspace file could not be hashed",
299
+ path=relative,
300
+ ) from exc
301
+ finally:
302
+ os.close(descriptor)
303
+ return digest.hexdigest()
304
+
305
+
306
+ def _same_file_state(left: os.stat_result, right: os.stat_result) -> bool:
307
+ identity_available = left.st_ino != 0 and right.st_ino != 0
308
+ return (
309
+ stat.S_ISREG(right.st_mode)
310
+ and (
311
+ not identity_available
312
+ or (left.st_dev == right.st_dev and left.st_ino == right.st_ino)
313
+ )
314
+ and left.st_size == right.st_size
315
+ and left.st_mtime_ns == right.st_mtime_ns
316
+ and stat.S_IMODE(left.st_mode) == stat.S_IMODE(right.st_mode)
317
+ )
318
+
319
+
320
+ __all__ = [
321
+ "InventoryEntry",
322
+ "InventoryEntryKind",
323
+ "InventoryError",
324
+ "InventoryErrorCode",
325
+ "WorkspaceChange",
326
+ "WorkspaceChangeKind",
327
+ "WorkspaceComparison",
328
+ "WorkspaceInventory",
329
+ "capture_inventory",
330
+ "compare_inventories",
331
+ ]