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.
- patchshuttle/__init__.py +98 -0
- patchshuttle/_diff.py +317 -0
- patchshuttle/_process.py +198 -0
- patchshuttle/_version.py +3 -0
- patchshuttle/actions/__init__.py +80 -0
- patchshuttle/actions/constructors.py +211 -0
- patchshuttle/actions/create.py +155 -0
- patchshuttle/actions/modify.py +174 -0
- patchshuttle/audit.py +588 -0
- patchshuttle/backup.py +712 -0
- patchshuttle/checks/__init__.py +37 -0
- patchshuttle/checks/constructors.py +67 -0
- patchshuttle/checks/runner.py +233 -0
- patchshuttle/cli.py +766 -0
- patchshuttle/config.py +247 -0
- patchshuttle/context.py +370 -0
- patchshuttle/errors.py +291 -0
- patchshuttle/execution.py +651 -0
- patchshuttle/formatters/__init__.py +25 -0
- patchshuttle/formatters/runner.py +240 -0
- patchshuttle/identifiers.py +20 -0
- patchshuttle/inventory.py +331 -0
- patchshuttle/logging.py +741 -0
- patchshuttle/models.py +496 -0
- patchshuttle/operations.py +292 -0
- patchshuttle/parser.py +243 -0
- patchshuttle/planner.py +1144 -0
- patchshuttle/policy.py +377 -0
- patchshuttle/py.typed +1 -0
- patchshuttle/registry.py +275 -0
- patchshuttle/resources/AI_GUIDE.md +163 -0
- patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
- patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
- patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
- patchshuttle/resources/__init__.py +1 -0
- patchshuttle/rollback.py +306 -0
- patchshuttle/runner.py +880 -0
- patchshuttle/verification.py +107 -0
- patchshuttle/workspace.py +382 -0
- patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
- patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
- patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
- patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|