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,37 @@
1
+ """Declarative check constructors and controlled execution results."""
2
+
3
+ from patchshuttle.checks.constructors import (
4
+ compileall,
5
+ django_check,
6
+ django_migrations_check,
7
+ django_test,
8
+ import_check,
9
+ profile,
10
+ pytest,
11
+ unittest,
12
+ )
13
+ from patchshuttle.checks.runner import (
14
+ CheckResult,
15
+ CheckRunResult,
16
+ CheckStatus,
17
+ PreparedCheck,
18
+ prepare_checks,
19
+ run_checks,
20
+ )
21
+
22
+ __all__ = [
23
+ "CheckResult",
24
+ "CheckRunResult",
25
+ "CheckStatus",
26
+ "PreparedCheck",
27
+ "compileall",
28
+ "django_check",
29
+ "django_migrations_check",
30
+ "django_test",
31
+ "import_check",
32
+ "prepare_checks",
33
+ "profile",
34
+ "pytest",
35
+ "run_checks",
36
+ "unittest",
37
+ ]
@@ -0,0 +1,67 @@
1
+ """Declarative public check constructors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from patchshuttle.models import Check
8
+
9
+
10
+ def compileall(paths: Iterable[str], *, quiet: int = 1) -> Check:
11
+ return Check({"compileall": {"paths": tuple(paths), "quiet": quiet}})
12
+
13
+
14
+ def pytest(
15
+ paths: Iterable[str] = (),
16
+ *,
17
+ args: Iterable[str] = (),
18
+ timeout_seconds: int | None = None,
19
+ ) -> Check:
20
+ parameters = {"paths": tuple(paths), "args": tuple(args)}
21
+ if timeout_seconds is not None:
22
+ parameters["timeout_seconds"] = timeout_seconds
23
+ return Check({"pytest": parameters})
24
+
25
+
26
+ def unittest(
27
+ *,
28
+ discover: str = "tests",
29
+ pattern: str = "test_*.py",
30
+ ) -> Check:
31
+ return Check({"unittest": {"discover": discover, "pattern": pattern}})
32
+
33
+
34
+ def django_check(*, manage_py: str = "manage.py") -> Check:
35
+ return Check({"django_check": {"manage_py": manage_py}})
36
+
37
+
38
+ def django_migrations_check(*, manage_py: str = "manage.py") -> Check:
39
+ return Check({"django_migrations_check": {"manage_py": manage_py}})
40
+
41
+
42
+ def django_test(
43
+ *,
44
+ manage_py: str = "manage.py",
45
+ labels: Iterable[str] = (),
46
+ ) -> Check:
47
+ return Check({"django_test": {"manage_py": manage_py, "labels": tuple(labels)}})
48
+
49
+
50
+ def import_check(modules: Iterable[str]) -> Check:
51
+ return Check({"import_check": {"modules": tuple(modules)}})
52
+
53
+
54
+ def profile(name: str) -> Check:
55
+ return Check({"profile": {"name": name}})
56
+
57
+
58
+ __all__ = [
59
+ "compileall",
60
+ "django_check",
61
+ "django_migrations_check",
62
+ "django_test",
63
+ "import_check",
64
+ "profile",
65
+ "pytest",
66
+ "unittest",
67
+ ]
@@ -0,0 +1,233 @@
1
+ """Controlled subprocess execution for immutable planned checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+ from pathlib import Path
11
+
12
+ from patchshuttle._process import (
13
+ ProcessCommand,
14
+ _signal_process,
15
+ _terminate_process,
16
+ run_process,
17
+ )
18
+ from patchshuttle.models import CheckName
19
+ from patchshuttle.planner import Plan, PlannedCheck
20
+
21
+ _IMPORT_CHECK_CODE = (
22
+ "import importlib, sys\n"
23
+ "for module_name in sys.argv[1:]:\n"
24
+ " importlib.import_module(module_name)\n"
25
+ )
26
+
27
+
28
+ class CheckStatus(str, Enum):
29
+ """Observable outcome of one controlled check process."""
30
+
31
+ PASSED = "PASSED"
32
+ FAILED = "FAILED"
33
+ TIMED_OUT = "TIMED_OUT"
34
+ ERROR = "ERROR"
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class PreparedCheck:
39
+ """One fixed command prepared from a validated job and local policy."""
40
+
41
+ id: str
42
+ name: CheckName
43
+ argv: tuple[str, ...]
44
+ working_directory: Path
45
+ timeout_seconds: int
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class CheckResult:
50
+ """Bounded captured outcome of one launched check."""
51
+
52
+ id: str
53
+ name: CheckName
54
+ status: CheckStatus
55
+ argv: tuple[str, ...]
56
+ working_directory: Path
57
+ timeout_seconds: int
58
+ return_code: int | None
59
+ duration_ms: int
60
+ stdout: str
61
+ stderr: str
62
+ stdout_truncated: bool
63
+ stderr_truncated: bool
64
+
65
+ @property
66
+ def success(self) -> bool:
67
+ return self.status is CheckStatus.PASSED
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class CheckRunResult:
72
+ """Ordered results through the first failure, if any."""
73
+
74
+ results: tuple[CheckResult, ...]
75
+
76
+ @property
77
+ def success(self) -> bool:
78
+ return all(result.success for result in self.results)
79
+
80
+ @property
81
+ def failed(self) -> CheckResult | None:
82
+ return next((result for result in self.results if not result.success), None)
83
+
84
+
85
+ def prepare_checks(plan: Plan) -> tuple[PreparedCheck, ...]:
86
+ """Build fixed argument arrays from validated models and normalized paths."""
87
+
88
+ if len(plan.job.checks) != len(plan.checks):
89
+ raise ValueError("plan check records do not match job checks")
90
+
91
+ prepared: list[PreparedCheck] = []
92
+ for index, (check, planned) in enumerate(
93
+ zip(plan.job.checks, plan.checks),
94
+ start=1,
95
+ ):
96
+ expected_id = f"check_{index:03d}"
97
+ if planned.id != expected_id or planned.name != check.name:
98
+ raise ValueError("plan check records do not match job checks")
99
+ prepared.append(_prepare_check(plan, planned, check.parameters))
100
+ return tuple(prepared)
101
+
102
+
103
+ def run_checks(plan: Plan) -> CheckRunResult:
104
+ """Run checks sequentially and stop immediately after the first failure."""
105
+
106
+ maximum = plan.workspace.config.execution.max_command_output_bytes
107
+ results: list[CheckResult] = []
108
+ for check in prepare_checks(plan):
109
+ result = _run_check(check, maximum_output_bytes=maximum)
110
+ results.append(result)
111
+ if not result.success:
112
+ break
113
+ return CheckRunResult(results=tuple(results))
114
+
115
+
116
+ def _prepare_check(
117
+ plan: Plan,
118
+ planned: PlannedCheck,
119
+ parameters,
120
+ ) -> PreparedCheck:
121
+ paths = tuple(path.as_posix() for path in planned.paths)
122
+ timeout = plan.workspace.config.execution.default_timeout_seconds
123
+
124
+ if planned.name == "compileall":
125
+ quiet = (f"-{'q' * parameters.quiet}",) if parameters.quiet else ()
126
+ argv = (sys.executable, "-m", "compileall", *quiet, "--", *paths)
127
+ elif planned.name == "pytest":
128
+ path_arguments = ("--", *paths) if paths else ()
129
+ argv = (
130
+ sys.executable,
131
+ "-m",
132
+ "pytest",
133
+ *parameters.args,
134
+ *path_arguments,
135
+ )
136
+ timeout = parameters.timeout_seconds or timeout
137
+ elif planned.name == "unittest":
138
+ argv = (
139
+ sys.executable,
140
+ "-m",
141
+ "unittest",
142
+ "discover",
143
+ "-s",
144
+ _only_path(planned),
145
+ "-p",
146
+ parameters.pattern,
147
+ )
148
+ elif planned.name == "django_check":
149
+ argv = (sys.executable, _only_path(planned), "check")
150
+ elif planned.name == "django_migrations_check":
151
+ argv = (
152
+ sys.executable,
153
+ _only_path(planned),
154
+ "makemigrations",
155
+ "--check",
156
+ "--dry-run",
157
+ )
158
+ elif planned.name == "django_test":
159
+ argv = (
160
+ sys.executable,
161
+ _only_path(planned),
162
+ "test",
163
+ *parameters.labels,
164
+ )
165
+ elif planned.name == "import_check":
166
+ argv = (
167
+ sys.executable,
168
+ "-c",
169
+ _IMPORT_CHECK_CODE,
170
+ *parameters.modules,
171
+ )
172
+ elif planned.name == "profile":
173
+ profile = plan.workspace.config.checks.profiles[parameters.name]
174
+ argv = tuple(
175
+ sys.executable if argument == "{python}" else argument
176
+ for argument in profile.argv
177
+ )
178
+ timeout = profile.timeout_seconds
179
+ else: # pragma: no cover - closed CheckName and planner contract
180
+ raise ValueError(f"unsupported planned check: {planned.name}")
181
+
182
+ return PreparedCheck(
183
+ id=planned.id,
184
+ name=planned.name,
185
+ argv=argv,
186
+ working_directory=plan.workspace.root,
187
+ timeout_seconds=timeout,
188
+ )
189
+
190
+
191
+ def _only_path(planned: PlannedCheck) -> str:
192
+ if len(planned.paths) != 1:
193
+ raise ValueError("planned check requires exactly one normalized path")
194
+ return planned.paths[0].as_posix()
195
+
196
+
197
+ def _run_check(
198
+ check: PreparedCheck,
199
+ *,
200
+ maximum_output_bytes: int,
201
+ ) -> CheckResult:
202
+ process = run_process(
203
+ ProcessCommand(
204
+ argv=check.argv,
205
+ working_directory=check.working_directory,
206
+ timeout_seconds=check.timeout_seconds,
207
+ ),
208
+ maximum_output_bytes=maximum_output_bytes,
209
+ )
210
+ return CheckResult(
211
+ id=check.id,
212
+ name=check.name,
213
+ status=CheckStatus(process.status.value),
214
+ argv=check.argv,
215
+ working_directory=check.working_directory,
216
+ timeout_seconds=check.timeout_seconds,
217
+ return_code=process.return_code,
218
+ duration_ms=process.duration_ms,
219
+ stdout=process.stdout,
220
+ stderr=process.stderr,
221
+ stdout_truncated=process.stdout_truncated,
222
+ stderr_truncated=process.stderr_truncated,
223
+ )
224
+
225
+
226
+ __all__ = [
227
+ "CheckResult",
228
+ "CheckRunResult",
229
+ "CheckStatus",
230
+ "PreparedCheck",
231
+ "prepare_checks",
232
+ "run_checks",
233
+ ]