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
patchshuttle/models.py ADDED
@@ -0,0 +1,496 @@
1
+ """Immutable declarative models for PatchShuttle jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from enum import Enum
8
+ from typing import Annotated, Literal, TypeAlias, cast
9
+
10
+ from pydantic import (
11
+ BaseModel,
12
+ ConfigDict,
13
+ Field,
14
+ RootModel,
15
+ field_validator,
16
+ model_validator,
17
+ )
18
+
19
+ from patchshuttle.identifiers import ProjectId
20
+
21
+ StrictString: TypeAlias = Annotated[str, Field(strict=True)]
22
+ NonEmptyString: TypeAlias = Annotated[str, Field(strict=True, min_length=1)]
23
+ PositiveInteger: TypeAlias = Annotated[int, Field(strict=True, ge=1)]
24
+ Depth: TypeAlias = Annotated[int, Field(strict=True, ge=1, le=10)]
25
+ DiffStrip: TypeAlias = Annotated[int, Field(strict=True, ge=0, le=2)]
26
+ QuietLevel: TypeAlias = Annotated[int, Field(strict=True, ge=0, le=2)]
27
+ NonEmptyStringTuple: TypeAlias = Annotated[
28
+ tuple[NonEmptyString, ...], Field(min_length=1)
29
+ ]
30
+
31
+
32
+ class _FrozenModel(BaseModel):
33
+ """Shared configuration for immutable models with a closed schema."""
34
+
35
+ model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True)
36
+
37
+
38
+ class TreeParameters(_FrozenModel):
39
+ path: NonEmptyString = "."
40
+ depth: Depth = 4
41
+ max_entries: PositiveInteger = 500
42
+ include_hidden: bool = Field(default=False, strict=True)
43
+
44
+
45
+ class ReadParameters(_FrozenModel):
46
+ path: NonEmptyString
47
+ start_line: PositiveInteger = 1
48
+ end_line: PositiveInteger | None = None
49
+ max_bytes: PositiveInteger | None = None
50
+
51
+ @model_validator(mode="after")
52
+ def validate_line_range(self) -> ReadParameters:
53
+ if self.end_line is not None and self.end_line < self.start_line:
54
+ raise ValueError("end_line must be greater than or equal to start_line")
55
+ return self
56
+
57
+
58
+ class SearchParameters(_FrozenModel):
59
+ path: NonEmptyString = "."
60
+ text: NonEmptyString
61
+ glob: StrictString | None = None
62
+ case_sensitive: bool = Field(default=True, strict=True)
63
+ max_results: PositiveInteger = 200
64
+
65
+
66
+ class FindFilesParameters(_FrozenModel):
67
+ path: NonEmptyString = "."
68
+ glob: NonEmptyString
69
+ max_results: PositiveInteger = 500
70
+
71
+
72
+ class FileInfoParameters(_FrozenModel):
73
+ path: NonEmptyString
74
+
75
+
76
+ class HashParameters(_FrozenModel):
77
+ path: NonEmptyString
78
+ algorithm: Literal["sha256"] = "sha256"
79
+
80
+
81
+ class GitStatusParameters(_FrozenModel):
82
+ pass
83
+
84
+
85
+ class EnvironmentParameters(_FrozenModel):
86
+ pass
87
+
88
+
89
+ class CreateDirectoryParameters(_FrozenModel):
90
+ path: NonEmptyString
91
+
92
+
93
+ class CreateFileParameters(_FrozenModel):
94
+ path: NonEmptyString
95
+ content: StrictString
96
+ encoding: NonEmptyString = "utf-8"
97
+ newline: Literal["lf", "crlf"] = "lf"
98
+
99
+
100
+ class ReplaceExactParameters(_FrozenModel):
101
+ path: NonEmptyString
102
+ old: NonEmptyString
103
+ new: StrictString
104
+ expected_count: PositiveInteger = 1
105
+
106
+
107
+ class InsertBeforeParameters(_FrozenModel):
108
+ path: NonEmptyString
109
+ anchor: NonEmptyString
110
+ content: StrictString
111
+ expected_count: PositiveInteger = 1
112
+
113
+
114
+ class InsertAfterParameters(_FrozenModel):
115
+ path: NonEmptyString
116
+ anchor: NonEmptyString
117
+ content: StrictString
118
+ expected_count: PositiveInteger = 1
119
+
120
+
121
+ class DeleteExactParameters(_FrozenModel):
122
+ path: NonEmptyString
123
+ text: NonEmptyString
124
+ expected_count: PositiveInteger = 1
125
+
126
+
127
+ class ApplyDiffParameters(_FrozenModel):
128
+ diff: NonEmptyString
129
+ strip: DiffStrip = 1
130
+
131
+
132
+ class CompileallParameters(_FrozenModel):
133
+ paths: NonEmptyStringTuple
134
+ quiet: QuietLevel = 1
135
+
136
+
137
+ class PytestParameters(_FrozenModel):
138
+ paths: tuple[NonEmptyString, ...] = ()
139
+ args: tuple[StrictString, ...] = ()
140
+ timeout_seconds: PositiveInteger | None = None
141
+
142
+
143
+ class UnittestParameters(_FrozenModel):
144
+ discover: NonEmptyString
145
+ pattern: NonEmptyString
146
+
147
+
148
+ class DjangoCheckParameters(_FrozenModel):
149
+ manage_py: NonEmptyString
150
+
151
+
152
+ class DjangoMigrationsCheckParameters(_FrozenModel):
153
+ manage_py: NonEmptyString
154
+
155
+
156
+ class DjangoTestParameters(_FrozenModel):
157
+ manage_py: NonEmptyString
158
+ labels: tuple[NonEmptyString, ...] = ()
159
+
160
+
161
+ class ImportCheckParameters(_FrozenModel):
162
+ modules: NonEmptyStringTuple
163
+
164
+ @field_validator("modules")
165
+ @classmethod
166
+ def validate_module_names(cls, modules: tuple[str, ...]) -> tuple[str, ...]:
167
+ invalid = [module for module in modules if not _MODULE_NAME.fullmatch(module)]
168
+ if invalid:
169
+ raise ValueError("modules must contain only dotted Python identifiers")
170
+ return modules
171
+
172
+
173
+ class ProfileParameters(_FrozenModel):
174
+ name: NonEmptyString
175
+
176
+
177
+ class TreeAction(_FrozenModel):
178
+ tree: TreeParameters
179
+
180
+
181
+ class ReadAction(_FrozenModel):
182
+ read: ReadParameters
183
+
184
+
185
+ class SearchAction(_FrozenModel):
186
+ search: SearchParameters
187
+
188
+
189
+ class FindFilesAction(_FrozenModel):
190
+ find_files: FindFilesParameters
191
+
192
+
193
+ class FileInfoAction(_FrozenModel):
194
+ file_info: FileInfoParameters
195
+
196
+
197
+ class HashAction(_FrozenModel):
198
+ hash: HashParameters
199
+
200
+
201
+ class GitStatusAction(_FrozenModel):
202
+ git_status: GitStatusParameters
203
+
204
+
205
+ class EnvironmentAction(_FrozenModel):
206
+ environment: EnvironmentParameters
207
+
208
+
209
+ class CreateDirectoryAction(_FrozenModel):
210
+ create_directory: CreateDirectoryParameters
211
+
212
+
213
+ class CreateFileAction(_FrozenModel):
214
+ create_file: CreateFileParameters
215
+
216
+
217
+ class ReplaceExactAction(_FrozenModel):
218
+ replace_exact: ReplaceExactParameters
219
+
220
+
221
+ class InsertBeforeAction(_FrozenModel):
222
+ insert_before: InsertBeforeParameters
223
+
224
+
225
+ class InsertAfterAction(_FrozenModel):
226
+ insert_after: InsertAfterParameters
227
+
228
+
229
+ class DeleteExactAction(_FrozenModel):
230
+ delete_exact: DeleteExactParameters
231
+
232
+
233
+ class ApplyDiffAction(_FrozenModel):
234
+ apply_diff: ApplyDiffParameters
235
+
236
+
237
+ ActionValue: TypeAlias = (
238
+ TreeAction
239
+ | ReadAction
240
+ | SearchAction
241
+ | FindFilesAction
242
+ | FileInfoAction
243
+ | HashAction
244
+ | GitStatusAction
245
+ | EnvironmentAction
246
+ | CreateDirectoryAction
247
+ | CreateFileAction
248
+ | ReplaceExactAction
249
+ | InsertBeforeAction
250
+ | InsertAfterAction
251
+ | DeleteExactAction
252
+ | ApplyDiffAction
253
+ )
254
+ ActionName: TypeAlias = Literal[
255
+ "tree",
256
+ "read",
257
+ "search",
258
+ "find_files",
259
+ "file_info",
260
+ "hash",
261
+ "git_status",
262
+ "environment",
263
+ "create_directory",
264
+ "create_file",
265
+ "replace_exact",
266
+ "insert_before",
267
+ "insert_after",
268
+ "delete_exact",
269
+ "apply_diff",
270
+ ]
271
+
272
+ _ACTION_MODELS: dict[str, type[_FrozenModel]] = {
273
+ "tree": TreeAction,
274
+ "read": ReadAction,
275
+ "search": SearchAction,
276
+ "find_files": FindFilesAction,
277
+ "file_info": FileInfoAction,
278
+ "hash": HashAction,
279
+ "git_status": GitStatusAction,
280
+ "environment": EnvironmentAction,
281
+ "create_directory": CreateDirectoryAction,
282
+ "create_file": CreateFileAction,
283
+ "replace_exact": ReplaceExactAction,
284
+ "insert_before": InsertBeforeAction,
285
+ "insert_after": InsertAfterAction,
286
+ "delete_exact": DeleteExactAction,
287
+ "apply_diff": ApplyDiffAction,
288
+ }
289
+
290
+ AUDIT_ACTION_NAMES = frozenset(
291
+ {
292
+ "tree",
293
+ "read",
294
+ "search",
295
+ "find_files",
296
+ "file_info",
297
+ "hash",
298
+ "git_status",
299
+ "environment",
300
+ }
301
+ )
302
+ CHANGE_ACTION_NAMES = frozenset(
303
+ {
304
+ "create_directory",
305
+ "create_file",
306
+ "replace_exact",
307
+ "insert_before",
308
+ "insert_after",
309
+ "delete_exact",
310
+ "apply_diff",
311
+ }
312
+ )
313
+
314
+
315
+ class Action(RootModel[ActionValue]):
316
+ """One YAML-shaped action entry containing exactly one action name."""
317
+
318
+ model_config = ConfigDict(frozen=True)
319
+
320
+ @model_validator(mode="before")
321
+ @classmethod
322
+ def select_action_model(cls, value: object) -> object:
323
+ if isinstance(value, Mapping):
324
+ keys = list(value)
325
+ if len(keys) != 1:
326
+ raise ValueError("an action entry must contain exactly one action name")
327
+ action_model = _ACTION_MODELS.get(keys[0])
328
+ if action_model is None:
329
+ raise ValueError(f"unknown action name {keys[0]!r}")
330
+ return action_model.model_validate(value)
331
+ return value
332
+
333
+ @property
334
+ def name(self) -> ActionName:
335
+ return cast(ActionName, next(iter(type(self.root).model_fields)))
336
+
337
+ @property
338
+ def parameters(self) -> _FrozenModel:
339
+ return cast(_FrozenModel, getattr(self.root, self.name))
340
+
341
+ @property
342
+ def is_audit(self) -> bool:
343
+ return self.name in AUDIT_ACTION_NAMES
344
+
345
+ @property
346
+ def is_change(self) -> bool:
347
+ return self.name in CHANGE_ACTION_NAMES
348
+
349
+
350
+ class CompileallCheck(_FrozenModel):
351
+ compileall: CompileallParameters
352
+
353
+
354
+ class PytestCheck(_FrozenModel):
355
+ pytest: PytestParameters
356
+
357
+
358
+ class UnittestCheck(_FrozenModel):
359
+ unittest: UnittestParameters
360
+
361
+
362
+ class DjangoCheck(_FrozenModel):
363
+ django_check: DjangoCheckParameters
364
+
365
+
366
+ class DjangoMigrationsCheck(_FrozenModel):
367
+ django_migrations_check: DjangoMigrationsCheckParameters
368
+
369
+
370
+ class DjangoTestCheck(_FrozenModel):
371
+ django_test: DjangoTestParameters
372
+
373
+
374
+ class ImportCheck(_FrozenModel):
375
+ import_check: ImportCheckParameters
376
+
377
+
378
+ class ProfileCheck(_FrozenModel):
379
+ profile: ProfileParameters
380
+
381
+
382
+ CheckValue: TypeAlias = (
383
+ CompileallCheck
384
+ | PytestCheck
385
+ | UnittestCheck
386
+ | DjangoCheck
387
+ | DjangoMigrationsCheck
388
+ | DjangoTestCheck
389
+ | ImportCheck
390
+ | ProfileCheck
391
+ )
392
+ CheckName: TypeAlias = Literal[
393
+ "compileall",
394
+ "pytest",
395
+ "unittest",
396
+ "django_check",
397
+ "django_migrations_check",
398
+ "django_test",
399
+ "import_check",
400
+ "profile",
401
+ ]
402
+
403
+ _CHECK_MODELS: dict[str, type[_FrozenModel]] = {
404
+ "compileall": CompileallCheck,
405
+ "pytest": PytestCheck,
406
+ "unittest": UnittestCheck,
407
+ "django_check": DjangoCheck,
408
+ "django_migrations_check": DjangoMigrationsCheck,
409
+ "django_test": DjangoTestCheck,
410
+ "import_check": ImportCheck,
411
+ "profile": ProfileCheck,
412
+ }
413
+
414
+
415
+ class Check(RootModel[CheckValue]):
416
+ """One YAML-shaped check entry containing exactly one check name."""
417
+
418
+ model_config = ConfigDict(frozen=True)
419
+
420
+ @model_validator(mode="before")
421
+ @classmethod
422
+ def select_check_model(cls, value: object) -> object:
423
+ if isinstance(value, Mapping):
424
+ keys = list(value)
425
+ if len(keys) != 1:
426
+ raise ValueError("a check entry must contain exactly one check name")
427
+ check_model = _CHECK_MODELS.get(keys[0])
428
+ if check_model is None:
429
+ raise ValueError(f"unknown check name {keys[0]!r}")
430
+ return check_model.model_validate(value)
431
+ return value
432
+
433
+ @property
434
+ def name(self) -> CheckName:
435
+ return cast(CheckName, next(iter(type(self.root).model_fields)))
436
+
437
+ @property
438
+ def parameters(self) -> _FrozenModel:
439
+ return cast(_FrozenModel, getattr(self.root, self.name))
440
+
441
+
442
+ class JobKind(str, Enum):
443
+ """Supported v0.1 job kinds."""
444
+
445
+ AUDIT = "audit"
446
+ PATCH = "patch"
447
+ VERIFY = "verify"
448
+
449
+
450
+ class Job(_FrozenModel):
451
+ """One structurally validated PatchShuttle protocol v1 job."""
452
+
453
+ protocol: Literal[1]
454
+ project_id: ProjectId
455
+ id: Annotated[str, Field(strict=True, pattern=r"^[A-Z][A-Z0-9_-]{2,63}$")]
456
+ kind: JobKind
457
+ title: StrictString | None = None
458
+ description: StrictString | None = None
459
+ actions: tuple[Action, ...] = ()
460
+ checks: tuple[Check, ...] = ()
461
+
462
+ @field_validator("protocol", mode="before")
463
+ @classmethod
464
+ def validate_protocol_is_strict_integer(cls, value: object) -> object:
465
+ if type(value) is not int:
466
+ raise ValueError("protocol must be the integer 1")
467
+ return value
468
+
469
+ @model_validator(mode="after")
470
+ def validate_kind_contract(self) -> Job:
471
+ if self.kind is JobKind.AUDIT:
472
+ if not self.actions:
473
+ raise ValueError("audit jobs require at least one action")
474
+ if any(not action.is_audit for action in self.actions):
475
+ raise ValueError("audit jobs may contain only audit actions")
476
+ if self.checks:
477
+ raise ValueError("audit jobs may not contain checks")
478
+
479
+ elif self.kind is JobKind.PATCH:
480
+ if not self.actions:
481
+ raise ValueError("patch jobs require at least one action")
482
+ if any(not action.is_change for action in self.actions):
483
+ raise ValueError("patch jobs may contain only change actions")
484
+
485
+ else:
486
+ if self.actions:
487
+ raise ValueError("verify jobs may not contain actions")
488
+ if not self.checks:
489
+ raise ValueError("verify jobs require at least one check")
490
+
491
+ return self
492
+
493
+
494
+ _MODULE_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*")
495
+
496
+ __all__ = ["Action", "ActionName", "Check", "CheckName", "Job", "JobKind"]