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,98 @@
1
+ """Public package surface for PatchShuttle."""
2
+
3
+ from patchshuttle._version import __version__
4
+ from patchshuttle.audit import AuditActionResult, AuditRunResult, AuditStatus
5
+ from patchshuttle.context import (
6
+ HandoffResult,
7
+ SnapshotResult,
8
+ create_handoff,
9
+ create_snapshot,
10
+ )
11
+ from patchshuttle.errors import (
12
+ ExecutionError,
13
+ ExecutionErrorCode,
14
+ JobError,
15
+ JobErrorCode,
16
+ PlanningError,
17
+ PlanningErrorCode,
18
+ PolicyError,
19
+ PolicyErrorCode,
20
+ WorkspaceError,
21
+ WorkspaceErrorCode,
22
+ )
23
+ from patchshuttle.execution import RunResult, RunStatus, execute_plan
24
+ from patchshuttle.models import Action, Check, Job, JobKind
25
+ from patchshuttle.operations import ManualRollbackResult, rollback_job
26
+ from patchshuttle.parser import load_job, validate_job
27
+ from patchshuttle.planner import (
28
+ ActionDisposition,
29
+ FileDisposition,
30
+ NewlineStyle,
31
+ PathFingerprint,
32
+ Plan,
33
+ PlannedAction,
34
+ PlannedCheck,
35
+ PlannedFileChange,
36
+ plan_job,
37
+ )
38
+ from patchshuttle.policy import PathKind, Policy, WorkspacePath
39
+ from patchshuttle.verification import VerificationRunResult
40
+ from patchshuttle.workspace import (
41
+ Workspace,
42
+ WorkspaceInitResult,
43
+ WorkspaceInitStatus,
44
+ discover_workspace,
45
+ init_workspace,
46
+ load_workspace,
47
+ )
48
+
49
+ __all__ = [
50
+ "Action",
51
+ "ActionDisposition",
52
+ "AuditActionResult",
53
+ "AuditRunResult",
54
+ "AuditStatus",
55
+ "Check",
56
+ "ExecutionError",
57
+ "ExecutionErrorCode",
58
+ "FileDisposition",
59
+ "HandoffResult",
60
+ "Job",
61
+ "JobError",
62
+ "JobErrorCode",
63
+ "JobKind",
64
+ "ManualRollbackResult",
65
+ "NewlineStyle",
66
+ "PathKind",
67
+ "PathFingerprint",
68
+ "Plan",
69
+ "PlannedAction",
70
+ "PlannedCheck",
71
+ "PlannedFileChange",
72
+ "PlanningError",
73
+ "PlanningErrorCode",
74
+ "Policy",
75
+ "PolicyError",
76
+ "PolicyErrorCode",
77
+ "RunResult",
78
+ "RunStatus",
79
+ "SnapshotResult",
80
+ "VerificationRunResult",
81
+ "Workspace",
82
+ "WorkspaceError",
83
+ "WorkspaceErrorCode",
84
+ "WorkspaceInitResult",
85
+ "WorkspaceInitStatus",
86
+ "WorkspacePath",
87
+ "__version__",
88
+ "create_handoff",
89
+ "create_snapshot",
90
+ "discover_workspace",
91
+ "execute_plan",
92
+ "init_workspace",
93
+ "load_job",
94
+ "load_workspace",
95
+ "plan_job",
96
+ "rollback_job",
97
+ "validate_job",
98
+ ]
patchshuttle/_diff.py ADDED
@@ -0,0 +1,317 @@
1
+ """Strict in-process unified-diff parsing and dry-run application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import PurePosixPath
8
+
9
+ from patchshuttle.errors import PlanningError, PlanningErrorCode
10
+
11
+ _HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?:.*)?(?:\n)?$")
12
+ _BINARY_MARKERS = ("Binary files ", "GIT binary patch")
13
+ _FORBIDDEN_METADATA = (
14
+ "new file mode ",
15
+ "deleted file mode ",
16
+ "old mode ",
17
+ "new mode ",
18
+ "similarity index ",
19
+ "rename from ",
20
+ "rename to ",
21
+ "copy from ",
22
+ "copy to ",
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class DiffHunk:
28
+ old_start: int
29
+ old_count: int
30
+ new_start: int
31
+ new_count: int
32
+ old_lines: tuple[str, ...]
33
+ new_lines: tuple[str, ...]
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class FileDiff:
38
+ path: str
39
+ hunks: tuple[DiffHunk, ...]
40
+
41
+
42
+ def parse_unified_diff(
43
+ value: str,
44
+ *,
45
+ strip: int,
46
+ item_id: str,
47
+ ) -> tuple[FileDiff, ...]:
48
+ """Parse a text-only, existing-file unified diff without side effects."""
49
+
50
+ normalized = value.replace("\r\n", "\n").replace("\r", "\n")
51
+ lines = normalized.splitlines(keepends=True)
52
+ if not lines:
53
+ raise _error(
54
+ PlanningErrorCode.DIFF_INVALID,
55
+ "unified diff is empty",
56
+ item_id,
57
+ )
58
+
59
+ parsed: list[FileDiff] = []
60
+ index = 0
61
+ while index < len(lines):
62
+ line = lines[index]
63
+ if line.startswith(_BINARY_MARKERS):
64
+ raise _error(
65
+ PlanningErrorCode.DIFF_BINARY_FORBIDDEN,
66
+ "binary diffs are not allowed",
67
+ item_id,
68
+ )
69
+ if line.startswith(_FORBIDDEN_METADATA):
70
+ raise _error(
71
+ PlanningErrorCode.DIFF_PATH_INVALID,
72
+ "file creation, deletion, rename, copy, and mode changes are forbidden",
73
+ item_id,
74
+ )
75
+ if line.startswith("diff --git ") or line.startswith("index "):
76
+ index += 1
77
+ continue
78
+ if not line.startswith("--- "):
79
+ raise _error(
80
+ PlanningErrorCode.DIFF_INVALID,
81
+ "expected an old-file header beginning with '--- '",
82
+ item_id,
83
+ )
84
+
85
+ old_path = _parse_header_path(line, strip=strip, item_id=item_id)
86
+ index += 1
87
+ if index >= len(lines) or not lines[index].startswith("+++ "):
88
+ raise _error(
89
+ PlanningErrorCode.DIFF_INVALID,
90
+ "old-file header must be followed by a new-file header",
91
+ item_id,
92
+ )
93
+ new_path = _parse_header_path(lines[index], strip=strip, item_id=item_id)
94
+ if old_path != new_path:
95
+ raise _error(
96
+ PlanningErrorCode.DIFF_PATH_INVALID,
97
+ "unified diff may not rename or copy files",
98
+ item_id,
99
+ path=new_path,
100
+ )
101
+ index += 1
102
+
103
+ hunks: list[DiffHunk] = []
104
+ while index < len(lines) and lines[index].startswith("@@ "):
105
+ hunk, index = _parse_hunk(lines, index, item_id=item_id)
106
+ hunks.append(hunk)
107
+ if not hunks:
108
+ raise _error(
109
+ PlanningErrorCode.DIFF_INVALID,
110
+ "each file diff requires at least one hunk",
111
+ item_id,
112
+ path=new_path,
113
+ )
114
+ if any(existing.path == new_path for existing in parsed):
115
+ raise _error(
116
+ PlanningErrorCode.DIFF_INVALID,
117
+ "a unified diff may contain each target file only once",
118
+ item_id,
119
+ path=new_path,
120
+ )
121
+ parsed.append(FileDiff(path=new_path, hunks=tuple(hunks)))
122
+
123
+ if not parsed:
124
+ raise _error(
125
+ PlanningErrorCode.DIFF_INVALID,
126
+ "unified diff does not contain a file patch",
127
+ item_id,
128
+ )
129
+ return tuple(parsed)
130
+
131
+
132
+ def apply_file_diff(text: str, diff: FileDiff, *, item_id: str) -> str:
133
+ """Apply parsed hunks to normalized LF text in memory."""
134
+
135
+ source = text.splitlines(keepends=True)
136
+ output: list[str] = []
137
+ cursor = 0
138
+ for hunk in diff.hunks:
139
+ start = hunk.old_start if hunk.old_count == 0 else hunk.old_start - 1
140
+ end = start + len(hunk.old_lines)
141
+ if start < cursor or end > len(source):
142
+ raise _error(
143
+ PlanningErrorCode.DIFF_HUNK_MISMATCH,
144
+ "hunk range does not match the current file",
145
+ item_id,
146
+ path=diff.path,
147
+ )
148
+ if tuple(source[start:end]) != hunk.old_lines:
149
+ raise _error(
150
+ PlanningErrorCode.DIFF_HUNK_MISMATCH,
151
+ "hunk context does not match the current file",
152
+ item_id,
153
+ path=diff.path,
154
+ )
155
+ output.extend(source[cursor:start])
156
+ output.extend(hunk.new_lines)
157
+ cursor = end
158
+ output.extend(source[cursor:])
159
+ return "".join(output)
160
+
161
+
162
+ def _parse_header_path(line: str, *, strip: int, item_id: str) -> str:
163
+ raw = line[4:].rstrip("\n").split("\t", 1)[0]
164
+ if raw == "/dev/null":
165
+ raise _error(
166
+ PlanningErrorCode.DIFF_PATH_INVALID,
167
+ "file creation and deletion diffs are not allowed",
168
+ item_id,
169
+ )
170
+ if not raw or raw.startswith('"'):
171
+ raise _error(
172
+ PlanningErrorCode.DIFF_PATH_INVALID,
173
+ "quoted or empty diff paths are not supported",
174
+ item_id,
175
+ )
176
+ if raw.startswith(("/", "\\")):
177
+ raise _error(
178
+ PlanningErrorCode.DIFF_PATH_INVALID,
179
+ "absolute diff paths are not allowed",
180
+ item_id,
181
+ path=raw,
182
+ )
183
+
184
+ parts = raw.replace("\\", "/").split("/")
185
+ if len(parts) <= strip:
186
+ raise _error(
187
+ PlanningErrorCode.DIFF_PATH_INVALID,
188
+ f"diff path has fewer than {strip + 1} component(s)",
189
+ item_id,
190
+ path=raw,
191
+ )
192
+ stripped = PurePosixPath(*parts[strip:]).as_posix()
193
+ return stripped
194
+
195
+
196
+ def _parse_hunk(
197
+ lines: list[str],
198
+ index: int,
199
+ *,
200
+ item_id: str,
201
+ ) -> tuple[DiffHunk, int]:
202
+ match = _HUNK_HEADER.fullmatch(lines[index])
203
+ if match is None:
204
+ raise _error(
205
+ PlanningErrorCode.DIFF_INVALID,
206
+ "invalid unified-diff hunk header",
207
+ item_id,
208
+ )
209
+ old_start = int(match.group(1))
210
+ old_count = int(match.group(2) or 1)
211
+ new_start = int(match.group(3))
212
+ new_count = int(match.group(4) or 1)
213
+ if (old_start == 0 and old_count != 0) or (new_start == 0 and new_count != 0):
214
+ raise _error(
215
+ PlanningErrorCode.DIFF_INVALID,
216
+ "a zero hunk start is valid only for an empty range",
217
+ item_id,
218
+ )
219
+ index += 1
220
+ old_lines: list[str] = []
221
+ new_lines: list[str] = []
222
+ previous_prefix: str | None = None
223
+
224
+ while index < len(lines):
225
+ line = lines[index]
226
+ if line.startswith(("@@ ", "--- ", "diff --git ")):
227
+ break
228
+ if line.startswith(_BINARY_MARKERS) or line.startswith(_FORBIDDEN_METADATA):
229
+ break
230
+ if line.startswith("\\"):
231
+ if line.rstrip("\n") != "\":
232
+ raise _error(
233
+ PlanningErrorCode.DIFF_INVALID,
234
+ "unknown backslash marker in unified diff",
235
+ item_id,
236
+ )
237
+ _remove_last_newline(
238
+ old_lines,
239
+ new_lines,
240
+ previous_prefix=previous_prefix,
241
+ item_id=item_id,
242
+ )
243
+ index += 1
244
+ continue
245
+ if not line or line[0] not in " +-":
246
+ raise _error(
247
+ PlanningErrorCode.DIFF_INVALID,
248
+ "hunk lines must begin with space, '+', or '-'",
249
+ item_id,
250
+ )
251
+
252
+ previous_prefix = line[0]
253
+ content = line[1:]
254
+ if previous_prefix in " -":
255
+ old_lines.append(content)
256
+ if previous_prefix in " +":
257
+ new_lines.append(content)
258
+ index += 1
259
+
260
+ if len(old_lines) != old_count or len(new_lines) != new_count:
261
+ raise _error(
262
+ PlanningErrorCode.DIFF_INVALID,
263
+ "hunk body line counts do not match its header",
264
+ item_id,
265
+ )
266
+ return (
267
+ DiffHunk(
268
+ old_start=old_start,
269
+ old_count=old_count,
270
+ new_start=new_start,
271
+ new_count=new_count,
272
+ old_lines=tuple(old_lines),
273
+ new_lines=tuple(new_lines),
274
+ ),
275
+ index,
276
+ )
277
+
278
+
279
+ def _remove_last_newline(
280
+ old_lines: list[str],
281
+ new_lines: list[str],
282
+ *,
283
+ previous_prefix: str | None,
284
+ item_id: str,
285
+ ) -> None:
286
+ if previous_prefix is None:
287
+ raise _error(
288
+ PlanningErrorCode.DIFF_INVALID,
289
+ "no-newline marker must follow a hunk content line",
290
+ item_id,
291
+ )
292
+ targets = []
293
+ if previous_prefix in " -":
294
+ targets.append(old_lines)
295
+ if previous_prefix in " +":
296
+ targets.append(new_lines)
297
+ for target in targets:
298
+ if not target or not target[-1].endswith("\n"):
299
+ raise _error(
300
+ PlanningErrorCode.DIFF_INVALID,
301
+ "no-newline marker is duplicated or misplaced",
302
+ item_id,
303
+ )
304
+ target[-1] = target[-1][:-1]
305
+
306
+
307
+ def _error(
308
+ code: PlanningErrorCode,
309
+ message: str,
310
+ item_id: str,
311
+ *,
312
+ path: str | None = None,
313
+ ) -> PlanningError:
314
+ return PlanningError(code, message, item_id=item_id, path=path)
315
+
316
+
317
+ __all__ = ["FileDiff", "apply_file_diff", "parse_unified_diff"]
@@ -0,0 +1,198 @@
1
+ """Shared bounded subprocess execution for trusted fixed command adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import tempfile
9
+ import time
10
+ from dataclasses import dataclass
11
+ from enum import Enum
12
+ from pathlib import Path
13
+ from typing import BinaryIO
14
+
15
+
16
+ class ProcessStatus(str, Enum):
17
+ """Low-level outcome of one controlled child process."""
18
+
19
+ PASSED = "PASSED"
20
+ FAILED = "FAILED"
21
+ TIMED_OUT = "TIMED_OUT"
22
+ ERROR = "ERROR"
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class ProcessCommand:
27
+ """A fixed argument array and its local execution limits."""
28
+
29
+ argv: tuple[str, ...]
30
+ working_directory: Path
31
+ timeout_seconds: int
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ProcessResult:
36
+ """Bounded output and status returned by a controlled child process."""
37
+
38
+ status: ProcessStatus
39
+ return_code: int | None
40
+ duration_ms: int
41
+ stdout: str
42
+ stderr: str
43
+ stdout_truncated: bool
44
+ stderr_truncated: bool
45
+
46
+ @property
47
+ def success(self) -> bool:
48
+ return self.status is ProcessStatus.PASSED
49
+
50
+
51
+ def run_process(
52
+ command: ProcessCommand,
53
+ *,
54
+ maximum_output_bytes: int,
55
+ ) -> ProcessResult:
56
+ """Run one fixed command without a shell and with bounded captured output."""
57
+
58
+ started = time.monotonic_ns()
59
+ with (
60
+ tempfile.TemporaryFile(mode="w+b") as stdout_stream,
61
+ tempfile.TemporaryFile(mode="w+b") as stderr_stream,
62
+ ):
63
+ try:
64
+ process = subprocess.Popen(
65
+ command.argv,
66
+ cwd=command.working_directory,
67
+ stdin=subprocess.DEVNULL,
68
+ stdout=stdout_stream,
69
+ stderr=stderr_stream,
70
+ shell=False,
71
+ **_process_group_options(),
72
+ )
73
+ except OSError as exc:
74
+ stderr, stderr_truncated = _bounded_text(
75
+ str(exc).encode("utf-8", errors="replace"),
76
+ maximum_output_bytes,
77
+ )
78
+ return _result(
79
+ status=ProcessStatus.ERROR,
80
+ return_code=None,
81
+ started=started,
82
+ stdout="",
83
+ stderr=stderr,
84
+ stdout_truncated=False,
85
+ stderr_truncated=stderr_truncated,
86
+ )
87
+
88
+ timed_out = False
89
+ try:
90
+ return_code = process.wait(timeout=command.timeout_seconds)
91
+ except subprocess.TimeoutExpired:
92
+ timed_out = True
93
+ return_code = None
94
+ _terminate_process(process)
95
+ except BaseException:
96
+ _terminate_process(process)
97
+ raise
98
+
99
+ stdout, stdout_truncated = _read_stream(
100
+ stdout_stream,
101
+ maximum_output_bytes,
102
+ )
103
+ stderr, stderr_truncated = _read_stream(
104
+ stderr_stream,
105
+ maximum_output_bytes,
106
+ )
107
+ status = (
108
+ ProcessStatus.TIMED_OUT
109
+ if timed_out
110
+ else ProcessStatus.PASSED if return_code == 0 else ProcessStatus.FAILED
111
+ )
112
+ return _result(
113
+ status=status,
114
+ return_code=return_code,
115
+ started=started,
116
+ stdout=stdout,
117
+ stderr=stderr,
118
+ stdout_truncated=stdout_truncated,
119
+ stderr_truncated=stderr_truncated,
120
+ )
121
+
122
+
123
+ def _result(
124
+ *,
125
+ status: ProcessStatus,
126
+ return_code: int | None,
127
+ started: int,
128
+ stdout: str,
129
+ stderr: str,
130
+ stdout_truncated: bool,
131
+ stderr_truncated: bool,
132
+ ) -> ProcessResult:
133
+ return ProcessResult(
134
+ status=status,
135
+ return_code=return_code,
136
+ duration_ms=(time.monotonic_ns() - started) // 1_000_000,
137
+ stdout=stdout,
138
+ stderr=stderr,
139
+ stdout_truncated=stdout_truncated,
140
+ stderr_truncated=stderr_truncated,
141
+ )
142
+
143
+
144
+ def _read_stream(stream: BinaryIO, maximum: int) -> tuple[str, bool]:
145
+ stream.flush()
146
+ stream.seek(0)
147
+ return _bounded_text(stream.read(maximum + 1), maximum)
148
+
149
+
150
+ def _bounded_text(raw: bytes, maximum: int) -> tuple[str, bool]:
151
+ truncated = len(raw) > maximum
152
+ text = raw[:maximum].decode("utf-8", errors="replace")
153
+ return text.replace("\r\n", "\n").replace("\r", "\n"), truncated
154
+
155
+
156
+ def _process_group_options() -> dict[str, object]:
157
+ if os.name == "nt": # pragma: no cover - exercised by Windows CI
158
+ return {"creationflags": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)}
159
+ return {"start_new_session": True}
160
+
161
+
162
+ def _terminate_process(process) -> None:
163
+ if process.poll() is not None:
164
+ return
165
+ _signal_process(process, force=False)
166
+ try:
167
+ process.wait(timeout=1)
168
+ return
169
+ except (OSError, subprocess.TimeoutExpired):
170
+ pass
171
+ _signal_process(process, force=True)
172
+ try:
173
+ process.wait(timeout=1)
174
+ except (OSError, subprocess.TimeoutExpired):
175
+ pass
176
+
177
+
178
+ def _signal_process(process, *, force: bool) -> None:
179
+ if os.name != "nt":
180
+ selected = signal.SIGKILL if force else signal.SIGTERM
181
+ try:
182
+ os.killpg(process.pid, selected)
183
+ return
184
+ except OSError:
185
+ pass
186
+ operation = process.kill if force else process.terminate
187
+ try:
188
+ operation()
189
+ except OSError:
190
+ pass
191
+
192
+
193
+ __all__ = [
194
+ "ProcessCommand",
195
+ "ProcessResult",
196
+ "ProcessStatus",
197
+ "run_process",
198
+ ]
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the PatchShuttle package version."""
2
+
3
+ __version__ = "0.1.0a2"
@@ -0,0 +1,80 @@
1
+ """Guarded low-level actions used by the transaction runner."""
2
+
3
+ from patchshuttle.actions.constructors import (
4
+ apply_diff,
5
+ )
6
+ from patchshuttle.actions.constructors import (
7
+ create_directory as _declarative_create_directory,
8
+ )
9
+ from patchshuttle.actions.constructors import (
10
+ create_file,
11
+ delete_exact,
12
+ environment,
13
+ file_info,
14
+ find_files,
15
+ git_status,
16
+ hash,
17
+ insert_after,
18
+ insert_before,
19
+ read,
20
+ replace_exact,
21
+ search,
22
+ tree,
23
+ )
24
+ from patchshuttle.actions.create import (
25
+ FilePublishError,
26
+ atomic_create_file,
27
+ )
28
+ from patchshuttle.actions.create import create_directory as apply_create_directory
29
+ from patchshuttle.actions.create import (
30
+ verify_created_directory,
31
+ verify_created_file,
32
+ )
33
+ from patchshuttle.actions.modify import (
34
+ FileReplaceError,
35
+ atomic_replace_file,
36
+ atomic_restore_file,
37
+ verify_modified_file,
38
+ verify_restored_file,
39
+ )
40
+ from patchshuttle.workspace import Workspace
41
+
42
+
43
+ def create_directory(*args, **kwargs):
44
+ """Create a declarative action, retaining the legacy internal call form."""
45
+
46
+ if (args and isinstance(args[0], Workspace)) or isinstance(
47
+ kwargs.get("workspace"),
48
+ Workspace,
49
+ ):
50
+ return apply_create_directory(*args, **kwargs)
51
+ return _declarative_create_directory(*args, **kwargs)
52
+
53
+
54
+ __all__ = [
55
+ "FilePublishError",
56
+ "FileReplaceError",
57
+ "atomic_create_file",
58
+ "atomic_replace_file",
59
+ "atomic_restore_file",
60
+ "apply_create_directory",
61
+ "apply_diff",
62
+ "create_directory",
63
+ "create_file",
64
+ "delete_exact",
65
+ "environment",
66
+ "file_info",
67
+ "find_files",
68
+ "git_status",
69
+ "hash",
70
+ "insert_after",
71
+ "insert_before",
72
+ "read",
73
+ "replace_exact",
74
+ "search",
75
+ "tree",
76
+ "verify_created_directory",
77
+ "verify_created_file",
78
+ "verify_modified_file",
79
+ "verify_restored_file",
80
+ ]