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,107 @@
1
+ """One-pass controlled verification jobs with workspace observation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from patchshuttle.checks import CheckResult, CheckStatus, run_checks
8
+ from patchshuttle.errors import ExecutionError, ExecutionErrorCode, PolicyError
9
+ from patchshuttle.inventory import (
10
+ InventoryError,
11
+ WorkspaceComparison,
12
+ capture_inventory,
13
+ compare_inventories,
14
+ )
15
+ from patchshuttle.models import JobKind
16
+ from patchshuttle.planner import Plan, plan_job
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class VerificationRunResult:
21
+ """Checks and workspace comparison from one verify plan."""
22
+
23
+ plan: Plan = field(repr=False)
24
+ checks: tuple[CheckResult, ...]
25
+ workspace_comparison: WorkspaceComparison
26
+
27
+
28
+ def execute_verification_locked(plan: Plan) -> VerificationRunResult:
29
+ """Execute a verify job while the caller holds the workspace run lock."""
30
+
31
+ if plan.job.kind is not JobKind.VERIFY:
32
+ raise ExecutionError(
33
+ ExecutionErrorCode.JOB_KIND_UNSUPPORTED,
34
+ "the verification runner accepts only verify jobs",
35
+ )
36
+ _revalidate_plan(plan)
37
+ baseline = _capture(plan, final=False)
38
+ check_run = run_checks(plan)
39
+ comparison = _compare(plan, baseline)
40
+ if check_run.failed is not None:
41
+ messages = {
42
+ CheckStatus.FAILED: "project check returned a non-zero exit code",
43
+ CheckStatus.TIMED_OUT: "project check timed out",
44
+ CheckStatus.ERROR: "project check could not be started",
45
+ }
46
+ raise ExecutionError(
47
+ ExecutionErrorCode.CHECK_FAILED,
48
+ messages[check_run.failed.status],
49
+ item_id=check_run.failed.id,
50
+ path=check_run.failed.name,
51
+ check_results=check_run.results,
52
+ workspace_comparison=comparison,
53
+ )
54
+ if comparison.unexpected_changes:
55
+ first = comparison.unexpected_changes[0]
56
+ raise ExecutionError(
57
+ ExecutionErrorCode.UNEXPECTED_WORKSPACE_CHANGE,
58
+ "project checks changed the workspace during verification",
59
+ path=first.path.as_posix(),
60
+ check_results=check_run.results,
61
+ workspace_comparison=comparison,
62
+ )
63
+ return VerificationRunResult(
64
+ plan=plan,
65
+ checks=check_run.results,
66
+ workspace_comparison=comparison,
67
+ )
68
+
69
+
70
+ def _capture(plan: Plan, *, final: bool):
71
+ try:
72
+ return capture_inventory(plan.workspace)
73
+ except InventoryError as exc:
74
+ raise ExecutionError(
75
+ ExecutionErrorCode.WORKSPACE_INVENTORY_FAILED,
76
+ (
77
+ "final workspace inventory could not be captured"
78
+ if final
79
+ else "workspace baseline inventory could not be captured"
80
+ ),
81
+ path=exc.path.as_posix() if exc.path is not None else None,
82
+ ) from exc
83
+
84
+
85
+ def _compare(plan: Plan, baseline) -> WorkspaceComparison:
86
+ current = _capture(plan, final=True)
87
+ return compare_inventories(baseline, current)
88
+
89
+
90
+ def _revalidate_plan(plan: Plan) -> None:
91
+ try:
92
+ current = plan_job(plan.job, plan.workspace)
93
+ except (PolicyError, ValueError) as exc:
94
+ raise ExecutionError(
95
+ ExecutionErrorCode.PLAN_STALE,
96
+ "the workspace no longer matches the approved verification plan",
97
+ item_id=getattr(exc, "item_id", None),
98
+ path=getattr(exc, "path", None),
99
+ ) from exc
100
+ if current != plan:
101
+ raise ExecutionError(
102
+ ExecutionErrorCode.PLAN_STALE,
103
+ "the workspace no longer matches the approved verification plan",
104
+ )
105
+
106
+
107
+ __all__ = ["VerificationRunResult", "execute_verification_locked"]
@@ -0,0 +1,382 @@
1
+ """Workspace discovery and non-overwriting initialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from importlib import resources
9
+ from os import PathLike
10
+ from pathlib import Path
11
+
12
+ from patchshuttle.config import (
13
+ PatchShuttleConfig,
14
+ ProjectOrigin,
15
+ load_config,
16
+ render_default_config,
17
+ )
18
+ from patchshuttle.errors import WorkspaceError, WorkspaceErrorCode
19
+ from patchshuttle.identifiers import generate_project_id
20
+ from patchshuttle.models import Job
21
+
22
+ CONFIG_RELATIVE_PATH = Path("patches/patchshuttle.toml")
23
+ _MANAGED_DIRECTORIES = (
24
+ Path("patches"),
25
+ Path("patches/inbox"),
26
+ Path("patches/applied"),
27
+ Path("patches/failed"),
28
+ Path("patches/logs"),
29
+ Path("patches/backups"),
30
+ Path("patches/state"),
31
+ Path("patches/examples"),
32
+ )
33
+ _MANAGED_FILES = (
34
+ CONFIG_RELATIVE_PATH,
35
+ Path("patches/AI_GUIDE.md"),
36
+ Path("patches/PATCHSHUTTLE_PROTOCOL.md"),
37
+ Path("patches/patchshuttle.schema.json"),
38
+ Path("patches/state/registry.json"),
39
+ Path("patches/state/run.lock"),
40
+ Path("patches/examples/AUDIT-EXAMPLE.psh.yaml"),
41
+ Path("patches/examples/PATCH-EXAMPLE.psh.yaml"),
42
+ )
43
+ _OS_METADATA_FILES = frozenset({".DS_Store", "Thumbs.db", "desktop.ini"})
44
+
45
+
46
+ class WorkspaceInitStatus(str, Enum):
47
+ """Observable result of one safe initialization attempt."""
48
+
49
+ INITIALIZED = "INITIALIZED"
50
+ UPDATED = "UPDATED"
51
+ UNCHANGED = "UNCHANGED"
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class Workspace:
56
+ """One resolved project root and its typed local configuration."""
57
+
58
+ root: Path
59
+ config: PatchShuttleConfig
60
+
61
+ @property
62
+ def project_id(self) -> str:
63
+ return self.config.project.project_id
64
+
65
+ @property
66
+ def origin(self) -> ProjectOrigin:
67
+ return self.config.project.origin
68
+
69
+ @property
70
+ def patches_dir(self) -> Path:
71
+ return self.root / "patches"
72
+
73
+ @property
74
+ def config_path(self) -> Path:
75
+ return self.root / CONFIG_RELATIVE_PATH
76
+
77
+ def require_project_id(self, project_id: str) -> None:
78
+ """Reject a job created for a different workspace."""
79
+
80
+ if project_id != self.project_id:
81
+ raise WorkspaceError(
82
+ WorkspaceErrorCode.PROJECT_ID_MISMATCH,
83
+ "job project_id does not match the initialized workspace",
84
+ path="$.project_id",
85
+ )
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class WorkspaceInitResult:
90
+ """Workspace plus the exact relative entries created by ``init``."""
91
+
92
+ workspace: Workspace
93
+ status: WorkspaceInitStatus
94
+ created_paths: tuple[Path, ...]
95
+
96
+
97
+ def init_workspace(
98
+ root: str | PathLike[str] = ".",
99
+ *,
100
+ new_project: bool = False,
101
+ ) -> WorkspaceInitResult:
102
+ """Initialize one project root without overwriting any existing entry."""
103
+
104
+ workspace_root = _resolve_directory(root)
105
+ _preflight_managed_paths(workspace_root)
106
+ config_path = workspace_root / CONFIG_RELATIVE_PATH
107
+ config_preexisted = _path_exists(config_path)
108
+ created_paths: list[Path] = []
109
+
110
+ if config_preexisted:
111
+ _require_regular_managed_file(config_path, CONFIG_RELATIVE_PATH)
112
+ config = load_config(config_path)
113
+ _require_compatible_origin(config, new_project=new_project)
114
+ else:
115
+ if new_project:
116
+ _require_new_project_contents(workspace_root)
117
+
118
+ if _ensure_directory(workspace_root, Path("patches")):
119
+ created_paths.append(Path("patches"))
120
+
121
+ project_id = generate_project_id()
122
+ origin = ProjectOrigin.NEW if new_project else ProjectOrigin.EXISTING
123
+ config_created = _create_file_if_missing(
124
+ workspace_root,
125
+ CONFIG_RELATIVE_PATH,
126
+ render_default_config(project_id, origin),
127
+ )
128
+ if config_created:
129
+ created_paths.append(CONFIG_RELATIVE_PATH)
130
+ config = load_config(config_path)
131
+ _require_compatible_origin(config, new_project=new_project)
132
+
133
+ for relative_path in _MANAGED_DIRECTORIES:
134
+ if _ensure_directory(workspace_root, relative_path):
135
+ created_paths.append(relative_path)
136
+
137
+ for relative_path, content in _generated_files(config).items():
138
+ if relative_path == CONFIG_RELATIVE_PATH:
139
+ continue
140
+ if _create_file_if_missing(workspace_root, relative_path, content):
141
+ created_paths.append(relative_path)
142
+
143
+ status = (
144
+ WorkspaceInitStatus.INITIALIZED
145
+ if not config_preexisted
146
+ else (
147
+ WorkspaceInitStatus.UPDATED
148
+ if created_paths
149
+ else WorkspaceInitStatus.UNCHANGED
150
+ )
151
+ )
152
+ workspace = Workspace(root=workspace_root, config=config)
153
+ return WorkspaceInitResult(
154
+ workspace=workspace,
155
+ status=status,
156
+ created_paths=tuple(created_paths),
157
+ )
158
+
159
+
160
+ def load_workspace(root: str | PathLike[str] = ".") -> Workspace:
161
+ """Load an initialized workspace at an exact project root."""
162
+
163
+ workspace_root = _resolve_directory(root)
164
+ _require_managed_directory_if_present(workspace_root, Path("patches"))
165
+ config_path = workspace_root / CONFIG_RELATIVE_PATH
166
+ if not _path_exists(config_path):
167
+ raise WorkspaceError(
168
+ WorkspaceErrorCode.WORKSPACE_NOT_INITIALIZED,
169
+ "no patches/patchshuttle.toml was found at the workspace root",
170
+ )
171
+ _require_regular_managed_file(config_path, CONFIG_RELATIVE_PATH)
172
+ return Workspace(root=workspace_root, config=load_config(config_path))
173
+
174
+
175
+ def discover_workspace(start: str | PathLike[str] = ".") -> Workspace:
176
+ """Find the nearest initialized workspace at ``start`` or one of its parents."""
177
+
178
+ current = _resolve_directory(start)
179
+ for candidate in (current, *current.parents):
180
+ config_path = candidate / CONFIG_RELATIVE_PATH
181
+ if _path_exists(config_path):
182
+ return load_workspace(candidate)
183
+ raise WorkspaceError(
184
+ WorkspaceErrorCode.WORKSPACE_NOT_INITIALIZED,
185
+ "no initialized PatchShuttle workspace was found",
186
+ )
187
+
188
+
189
+ def _resolve_directory(path: str | PathLike[str]) -> Path:
190
+ candidate = Path(path)
191
+ try:
192
+ resolved = candidate.resolve(strict=True)
193
+ except FileNotFoundError as exc:
194
+ raise WorkspaceError(
195
+ WorkspaceErrorCode.WORKSPACE_NOT_FOUND,
196
+ "workspace path was not found",
197
+ ) from exc
198
+ except OSError as exc:
199
+ raise WorkspaceError(
200
+ WorkspaceErrorCode.WORKSPACE_READ_FAILED,
201
+ "workspace path could not be resolved",
202
+ ) from exc
203
+
204
+ if not resolved.is_dir():
205
+ raise WorkspaceError(
206
+ WorkspaceErrorCode.WORKSPACE_NOT_DIRECTORY,
207
+ "workspace path must identify a directory",
208
+ )
209
+ return resolved
210
+
211
+
212
+ def _require_new_project_contents(root: Path) -> None:
213
+ try:
214
+ entries = tuple(root.iterdir())
215
+ except OSError as exc:
216
+ raise WorkspaceError(
217
+ WorkspaceErrorCode.WORKSPACE_READ_FAILED,
218
+ "new-project directory could not be inspected",
219
+ ) from exc
220
+
221
+ unexpected = [entry.name for entry in entries if not _is_allowed_metadata(entry)]
222
+ if unexpected:
223
+ visible = ", ".join(sorted(unexpected)[:5])
224
+ raise WorkspaceError(
225
+ WorkspaceErrorCode.NEW_PROJECT_NOT_EMPTY,
226
+ f"--new-project requires an empty directory; found: {visible}",
227
+ )
228
+
229
+
230
+ def _is_allowed_metadata(entry: Path) -> bool:
231
+ if entry.is_symlink():
232
+ return False
233
+ if entry.name == ".git":
234
+ return entry.is_dir()
235
+ if entry.name in _OS_METADATA_FILES or entry.name.startswith("._"):
236
+ return entry.is_file()
237
+ return False
238
+
239
+
240
+ def _require_compatible_origin(
241
+ config: PatchShuttleConfig,
242
+ *,
243
+ new_project: bool,
244
+ ) -> None:
245
+ if new_project and config.project.origin is not ProjectOrigin.NEW:
246
+ raise WorkspaceError(
247
+ WorkspaceErrorCode.PROJECT_ORIGIN_CONFLICT,
248
+ "--new-project cannot change an existing-project workspace",
249
+ path="$.project.origin",
250
+ )
251
+
252
+
253
+ def _ensure_directory(root: Path, relative_path: Path) -> bool:
254
+ path = root / relative_path
255
+ if path.is_symlink():
256
+ raise _managed_path_conflict(relative_path, "symbolic links are not allowed")
257
+ try:
258
+ path.mkdir()
259
+ except FileExistsError:
260
+ if path.is_dir() and not path.is_symlink():
261
+ return False
262
+ raise _managed_path_conflict(relative_path, "expected a directory")
263
+ except OSError as exc:
264
+ raise WorkspaceError(
265
+ WorkspaceErrorCode.WORKSPACE_WRITE_FAILED,
266
+ "managed directory could not be created",
267
+ path=relative_path.as_posix(),
268
+ ) from exc
269
+ return True
270
+
271
+
272
+ def _require_managed_directory_if_present(root: Path, relative_path: Path) -> None:
273
+ path = root / relative_path
274
+ if path.is_symlink():
275
+ raise _managed_path_conflict(relative_path, "symbolic links are not allowed")
276
+ if path.exists() and not path.is_dir():
277
+ raise _managed_path_conflict(relative_path, "expected a directory")
278
+
279
+
280
+ def _preflight_managed_paths(root: Path) -> None:
281
+ for relative_path in _MANAGED_DIRECTORIES:
282
+ _require_managed_directory_if_present(root, relative_path)
283
+ for relative_path in _MANAGED_FILES:
284
+ path = root / relative_path
285
+ if _path_exists(path):
286
+ _require_regular_managed_file(path, relative_path)
287
+
288
+
289
+ def _create_file_if_missing(root: Path, relative_path: Path, content: str) -> bool:
290
+ path = root / relative_path
291
+ if path.is_symlink():
292
+ raise _managed_path_conflict(relative_path, "symbolic links are not allowed")
293
+
294
+ created = False
295
+ try:
296
+ with path.open("x", encoding="utf-8", newline="\n") as stream:
297
+ created = True
298
+ stream.write(content)
299
+ except FileExistsError:
300
+ _require_regular_managed_file(path, relative_path)
301
+ return False
302
+ except OSError as exc:
303
+ if created:
304
+ try:
305
+ path.unlink()
306
+ except OSError:
307
+ pass # pragma: no cover - best-effort cleanup after write failure
308
+ raise WorkspaceError(
309
+ WorkspaceErrorCode.WORKSPACE_WRITE_FAILED,
310
+ "managed file could not be created",
311
+ path=relative_path.as_posix(),
312
+ ) from exc
313
+ return True
314
+
315
+
316
+ def _require_regular_managed_file(path: Path, relative_path: Path) -> None:
317
+ if path.is_symlink() or not path.is_file():
318
+ raise _managed_path_conflict(relative_path, "expected a regular file")
319
+
320
+
321
+ def _managed_path_conflict(relative_path: Path, message: str) -> WorkspaceError:
322
+ return WorkspaceError(
323
+ WorkspaceErrorCode.MANAGED_PATH_CONFLICT,
324
+ message,
325
+ path=relative_path.as_posix(),
326
+ )
327
+
328
+
329
+ def _path_exists(path: Path) -> bool:
330
+ return path.exists() or path.is_symlink()
331
+
332
+
333
+ def _generated_files(config: PatchShuttleConfig) -> dict[Path, str]:
334
+ project_id = config.project.project_id
335
+ return {
336
+ CONFIG_RELATIVE_PATH: render_default_config(project_id, config.project.origin),
337
+ Path("patches/AI_GUIDE.md"): _render_resource("AI_GUIDE.md", project_id),
338
+ Path("patches/PATCHSHUTTLE_PROTOCOL.md"): _render_resource(
339
+ "PATCHSHUTTLE_PROTOCOL.md", project_id
340
+ ),
341
+ Path("patches/patchshuttle.schema.json"): json.dumps(
342
+ Job.model_json_schema(),
343
+ ensure_ascii=False,
344
+ indent=2,
345
+ sort_keys=True,
346
+ )
347
+ + "\n",
348
+ Path("patches/state/registry.json"): json.dumps(
349
+ {"jobs": {}, "project_id": project_id},
350
+ ensure_ascii=False,
351
+ indent=2,
352
+ sort_keys=True,
353
+ )
354
+ + "\n",
355
+ Path("patches/state/run.lock"): "",
356
+ Path("patches/examples/AUDIT-EXAMPLE.psh.yaml"): _render_resource(
357
+ "AUDIT-EXAMPLE.psh.yaml", project_id
358
+ ),
359
+ Path("patches/examples/PATCH-EXAMPLE.psh.yaml"): _render_resource(
360
+ "PATCH-EXAMPLE.psh.yaml", project_id
361
+ ),
362
+ }
363
+
364
+
365
+ def _render_resource(name: str, project_id: str) -> str:
366
+ template = (
367
+ resources.files("patchshuttle.resources")
368
+ .joinpath(name)
369
+ .read_text(encoding="utf-8")
370
+ )
371
+ return template.replace("{{PROJECT_ID}}", project_id)
372
+
373
+
374
+ __all__ = [
375
+ "CONFIG_RELATIVE_PATH",
376
+ "Workspace",
377
+ "WorkspaceInitResult",
378
+ "WorkspaceInitStatus",
379
+ "discover_workspace",
380
+ "init_workspace",
381
+ "load_workspace",
382
+ ]