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