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
patchshuttle/planner.py
ADDED
|
@@ -0,0 +1,1144 @@
|
|
|
1
|
+
"""Immutable plans produced by read-only workspace inspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from importlib.util import find_spec
|
|
14
|
+
from os import PathLike
|
|
15
|
+
from pathlib import Path, PurePosixPath
|
|
16
|
+
from typing import cast
|
|
17
|
+
|
|
18
|
+
from patchshuttle._diff import apply_file_diff, parse_unified_diff
|
|
19
|
+
from patchshuttle.errors import PlanningError, PlanningErrorCode
|
|
20
|
+
from patchshuttle.models import Action, Check, Job, JobKind
|
|
21
|
+
from patchshuttle.policy import PathKind, Policy, WorkspacePath
|
|
22
|
+
from patchshuttle.workspace import Workspace, discover_workspace
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ActionDisposition(str, Enum):
|
|
26
|
+
"""Read-only conclusion for one requested action."""
|
|
27
|
+
|
|
28
|
+
INSPECT = "INSPECT"
|
|
29
|
+
CREATE = "CREATE"
|
|
30
|
+
MODIFY = "MODIFY"
|
|
31
|
+
NO_CHANGE = "NO_CHANGE"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FileDisposition(str, Enum):
|
|
35
|
+
"""Net file operation required by a complete plan."""
|
|
36
|
+
|
|
37
|
+
CREATE = "CREATE"
|
|
38
|
+
MODIFY = "MODIFY"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NewlineStyle(str, Enum):
|
|
42
|
+
"""Newline style preserved or requested for a planned text file."""
|
|
43
|
+
|
|
44
|
+
LF = "lf"
|
|
45
|
+
CRLF = "crlf"
|
|
46
|
+
NONE = "none"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class PlannedAction:
|
|
51
|
+
"""One sequential action and its dry-run disposition."""
|
|
52
|
+
|
|
53
|
+
id: str
|
|
54
|
+
name: str
|
|
55
|
+
disposition: ActionDisposition
|
|
56
|
+
paths: tuple[PurePosixPath, ...] = ()
|
|
57
|
+
detail: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class PlannedCheck:
|
|
62
|
+
"""One requested controlled check and its validated workspace paths."""
|
|
63
|
+
|
|
64
|
+
id: str
|
|
65
|
+
name: str
|
|
66
|
+
paths: tuple[PurePosixPath, ...] = ()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class PlannedFileChange:
|
|
71
|
+
"""The final bytes and fingerprints for one net file change."""
|
|
72
|
+
|
|
73
|
+
path: PurePosixPath
|
|
74
|
+
disposition: FileDisposition
|
|
75
|
+
before_sha256: str | None
|
|
76
|
+
after_sha256: str
|
|
77
|
+
before_size: int | None
|
|
78
|
+
after_size: int
|
|
79
|
+
encoding: str
|
|
80
|
+
newline: NewlineStyle
|
|
81
|
+
content: bytes = field(repr=False)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class PathFingerprint:
|
|
86
|
+
"""Read-only metadata retained for future plan revalidation."""
|
|
87
|
+
|
|
88
|
+
path: PurePosixPath
|
|
89
|
+
kind: PathKind
|
|
90
|
+
size: int
|
|
91
|
+
modified_ns: int
|
|
92
|
+
sha256: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class Plan:
|
|
97
|
+
"""A complete immutable read-only plan for one validated job."""
|
|
98
|
+
|
|
99
|
+
workspace: Workspace = field(repr=False)
|
|
100
|
+
job: Job
|
|
101
|
+
job_hash: str
|
|
102
|
+
actions: tuple[PlannedAction, ...]
|
|
103
|
+
checks: tuple[PlannedCheck, ...]
|
|
104
|
+
file_changes: tuple[PlannedFileChange, ...]
|
|
105
|
+
directories_to_create: tuple[PurePosixPath, ...]
|
|
106
|
+
formatting_targets: tuple[PurePosixPath, ...]
|
|
107
|
+
fingerprints: tuple[PathFingerprint, ...]
|
|
108
|
+
protected_paths_passed: bool
|
|
109
|
+
backup_destination: PurePosixPath | None
|
|
110
|
+
auto_rollback: bool
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def files_to_create(self) -> tuple[PurePosixPath, ...]:
|
|
114
|
+
return tuple(
|
|
115
|
+
change.path
|
|
116
|
+
for change in self.file_changes
|
|
117
|
+
if change.disposition is FileDisposition.CREATE
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def files_to_modify(self) -> tuple[PurePosixPath, ...]:
|
|
122
|
+
return tuple(
|
|
123
|
+
change.path
|
|
124
|
+
for change in self.file_changes
|
|
125
|
+
if change.disposition is FileDisposition.MODIFY
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def requires_confirmation(self) -> bool:
|
|
130
|
+
return (
|
|
131
|
+
self.job.kind is not JobKind.AUDIT
|
|
132
|
+
and self.workspace.config.execution.confirm
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(slots=True)
|
|
137
|
+
class _TextState:
|
|
138
|
+
path: PurePosixPath
|
|
139
|
+
original_bytes: bytes | None
|
|
140
|
+
current_bytes: bytes
|
|
141
|
+
text: str
|
|
142
|
+
encoding: str
|
|
143
|
+
codec: str
|
|
144
|
+
bom: bytes
|
|
145
|
+
newline: NewlineStyle
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
_UTF32_LE_BOM = b"\xff\xfe\x00\x00"
|
|
149
|
+
_UTF32_BE_BOM = b"\x00\x00\xfe\xff"
|
|
150
|
+
_UTF16_LE_BOM = b"\xff\xfe"
|
|
151
|
+
_UTF16_BE_BOM = b"\xfe\xff"
|
|
152
|
+
_UTF8_BOM = b"\xef\xbb\xbf"
|
|
153
|
+
_PYTEST_EXACT_ARGS = frozenset(
|
|
154
|
+
{
|
|
155
|
+
"-q",
|
|
156
|
+
"--quiet",
|
|
157
|
+
"-v",
|
|
158
|
+
"--verbose",
|
|
159
|
+
"-x",
|
|
160
|
+
"--exitfirst",
|
|
161
|
+
"-s",
|
|
162
|
+
"--disable-warnings",
|
|
163
|
+
"--strict-config",
|
|
164
|
+
"--strict-markers",
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
_PYTEST_TB_VALUES = frozenset({"auto", "long", "short", "line", "native", "no"})
|
|
168
|
+
_PYTEST_CAPTURE_VALUES = frozenset({"fd", "sys", "no", "tee-sys"})
|
|
169
|
+
_DJANGO_LABEL = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def plan_job(
|
|
173
|
+
job: Job,
|
|
174
|
+
workspace: Workspace | str | PathLike[str] = ".",
|
|
175
|
+
) -> Plan:
|
|
176
|
+
"""Validate local authority and fully dry-run one immutable job."""
|
|
177
|
+
|
|
178
|
+
resolved_workspace = (
|
|
179
|
+
workspace if isinstance(workspace, Workspace) else discover_workspace(workspace)
|
|
180
|
+
)
|
|
181
|
+
resolved_workspace.require_project_id(job.project_id)
|
|
182
|
+
return _Planner(job, resolved_workspace).build()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _Planner:
|
|
186
|
+
def __init__(self, job: Job, workspace: Workspace) -> None:
|
|
187
|
+
self.job = job
|
|
188
|
+
self.workspace = workspace
|
|
189
|
+
self.policy = Policy(workspace)
|
|
190
|
+
self.action_plans: list[PlannedAction] = []
|
|
191
|
+
self.check_plans: list[PlannedCheck] = []
|
|
192
|
+
self.files: dict[PurePosixPath, _TextState] = {}
|
|
193
|
+
self.created_directories: dict[PurePosixPath, None] = {}
|
|
194
|
+
self.fingerprints: dict[PurePosixPath, PathFingerprint] = {}
|
|
195
|
+
self.max_file_bytes = workspace.config.execution.max_single_file_bytes
|
|
196
|
+
|
|
197
|
+
def build(self) -> Plan:
|
|
198
|
+
self._validate_job_policy()
|
|
199
|
+
for index, action in enumerate(self.job.actions, start=1):
|
|
200
|
+
item_id = f"action_{index:03d}"
|
|
201
|
+
if self.job.kind is JobKind.AUDIT:
|
|
202
|
+
self._plan_audit_action(action, item_id=item_id)
|
|
203
|
+
else:
|
|
204
|
+
self._plan_change_action(action, item_id=item_id)
|
|
205
|
+
|
|
206
|
+
for index, check in enumerate(self.job.checks, start=1):
|
|
207
|
+
self._plan_check(check, item_id=f"check_{index:03d}")
|
|
208
|
+
|
|
209
|
+
file_changes = self._build_file_changes()
|
|
210
|
+
formatting_targets = self._formatting_targets(file_changes)
|
|
211
|
+
if formatting_targets:
|
|
212
|
+
self._require_module("isort", item_id="formatting")
|
|
213
|
+
self._require_module("black", item_id="formatting")
|
|
214
|
+
backup_destination = (
|
|
215
|
+
PurePosixPath(
|
|
216
|
+
"patches",
|
|
217
|
+
"backups",
|
|
218
|
+
self.job.id,
|
|
219
|
+
"<RUN_TIMESTAMP>",
|
|
220
|
+
)
|
|
221
|
+
if self.job.kind is JobKind.PATCH
|
|
222
|
+
else None
|
|
223
|
+
)
|
|
224
|
+
return Plan(
|
|
225
|
+
workspace=self.workspace,
|
|
226
|
+
job=self.job,
|
|
227
|
+
job_hash=normalized_job_hash(self.job),
|
|
228
|
+
actions=tuple(self.action_plans),
|
|
229
|
+
checks=tuple(self.check_plans),
|
|
230
|
+
file_changes=file_changes,
|
|
231
|
+
directories_to_create=tuple(self.created_directories),
|
|
232
|
+
formatting_targets=formatting_targets,
|
|
233
|
+
fingerprints=tuple(self.fingerprints.values()),
|
|
234
|
+
protected_paths_passed=True,
|
|
235
|
+
backup_destination=backup_destination,
|
|
236
|
+
auto_rollback=(
|
|
237
|
+
self.workspace.config.execution.auto_rollback
|
|
238
|
+
if self.job.kind is JobKind.PATCH
|
|
239
|
+
else False
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def _validate_job_policy(self) -> None:
|
|
244
|
+
action_limit = self.workspace.config.execution.max_actions
|
|
245
|
+
if len(self.job.actions) > action_limit:
|
|
246
|
+
raise PlanningError(
|
|
247
|
+
PlanningErrorCode.ACTION_LIMIT_EXCEEDED,
|
|
248
|
+
f"job has more than the configured {action_limit} action(s)",
|
|
249
|
+
)
|
|
250
|
+
if (
|
|
251
|
+
self.job.kind is JobKind.PATCH
|
|
252
|
+
and self.workspace.config.checks.require_at_least_one_for_patch
|
|
253
|
+
and not self.job.checks
|
|
254
|
+
):
|
|
255
|
+
raise PlanningError(
|
|
256
|
+
PlanningErrorCode.PATCH_CHECK_REQUIRED,
|
|
257
|
+
"local policy requires at least one check for a patch job",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def _plan_audit_action(self, action: Action, *, item_id: str) -> None:
|
|
261
|
+
parameters = action.parameters
|
|
262
|
+
paths: tuple[PurePosixPath, ...] = ()
|
|
263
|
+
if action.name in {"tree", "find_files"}:
|
|
264
|
+
target = self._audit_target(
|
|
265
|
+
cast(str, parameters.path),
|
|
266
|
+
item_id=item_id,
|
|
267
|
+
expected=PathKind.DIRECTORY,
|
|
268
|
+
)
|
|
269
|
+
paths = (target.relative,)
|
|
270
|
+
elif action.name == "read":
|
|
271
|
+
requested_max = parameters.max_bytes
|
|
272
|
+
if requested_max is not None and requested_max > self.max_file_bytes:
|
|
273
|
+
raise self._error(
|
|
274
|
+
PlanningErrorCode.FILE_SIZE_LIMIT_EXCEEDED,
|
|
275
|
+
f"read max_bytes exceeds the configured {self.max_file_bytes}-byte limit",
|
|
276
|
+
item_id,
|
|
277
|
+
path=parameters.path,
|
|
278
|
+
)
|
|
279
|
+
target = self._audit_target(
|
|
280
|
+
parameters.path,
|
|
281
|
+
item_id=item_id,
|
|
282
|
+
expected=PathKind.FILE,
|
|
283
|
+
)
|
|
284
|
+
raw = self._read_existing_file(target, item_id=item_id)
|
|
285
|
+
self._decode_existing(raw, item_id=item_id, path=target.relative)
|
|
286
|
+
paths = (target.relative,)
|
|
287
|
+
elif action.name == "search":
|
|
288
|
+
target = self._audit_target(parameters.path, item_id=item_id)
|
|
289
|
+
if target.kind is PathKind.FILE:
|
|
290
|
+
raw = self._read_existing_file(target, item_id=item_id)
|
|
291
|
+
self._decode_existing(raw, item_id=item_id, path=target.relative)
|
|
292
|
+
paths = (target.relative,)
|
|
293
|
+
elif action.name in {"file_info", "hash"}:
|
|
294
|
+
expected = PathKind.FILE if action.name == "hash" else None
|
|
295
|
+
target = self._audit_target(
|
|
296
|
+
parameters.path,
|
|
297
|
+
item_id=item_id,
|
|
298
|
+
expected=expected,
|
|
299
|
+
)
|
|
300
|
+
if action.name == "hash":
|
|
301
|
+
self._read_existing_file(target, item_id=item_id)
|
|
302
|
+
paths = (target.relative,)
|
|
303
|
+
|
|
304
|
+
self.action_plans.append(
|
|
305
|
+
PlannedAction(
|
|
306
|
+
id=item_id,
|
|
307
|
+
name=action.name,
|
|
308
|
+
disposition=ActionDisposition.INSPECT,
|
|
309
|
+
paths=paths,
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def _audit_target(
|
|
314
|
+
self,
|
|
315
|
+
path: str,
|
|
316
|
+
*,
|
|
317
|
+
item_id: str,
|
|
318
|
+
expected: PathKind | None = None,
|
|
319
|
+
) -> WorkspacePath:
|
|
320
|
+
relative = self.policy.normalize(path)
|
|
321
|
+
if self.policy.is_ignored(relative):
|
|
322
|
+
raise self._error(
|
|
323
|
+
PlanningErrorCode.PATH_IGNORED,
|
|
324
|
+
"audit target is ignored by local policy",
|
|
325
|
+
item_id,
|
|
326
|
+
path=relative.as_posix(),
|
|
327
|
+
)
|
|
328
|
+
target = self.policy.resolve(relative, allow_root=True)
|
|
329
|
+
if expected is not None and target.kind is not expected:
|
|
330
|
+
raise self._error(
|
|
331
|
+
PlanningErrorCode.TARGET_TYPE_INVALID,
|
|
332
|
+
f"expected a {expected.value}",
|
|
333
|
+
item_id,
|
|
334
|
+
path=relative.as_posix(),
|
|
335
|
+
)
|
|
336
|
+
self._record_fingerprint(target, item_id=item_id)
|
|
337
|
+
return target
|
|
338
|
+
|
|
339
|
+
def _plan_change_action(self, action: Action, *, item_id: str) -> None:
|
|
340
|
+
if action.name == "create_directory":
|
|
341
|
+
self._plan_create_directory(action, item_id=item_id)
|
|
342
|
+
elif action.name == "create_file":
|
|
343
|
+
self._plan_create_file(action, item_id=item_id)
|
|
344
|
+
elif action.name in {
|
|
345
|
+
"replace_exact",
|
|
346
|
+
"insert_before",
|
|
347
|
+
"insert_after",
|
|
348
|
+
"delete_exact",
|
|
349
|
+
}:
|
|
350
|
+
self._plan_exact_edit(action, item_id=item_id)
|
|
351
|
+
else:
|
|
352
|
+
self._plan_apply_diff(action, item_id=item_id)
|
|
353
|
+
|
|
354
|
+
def _plan_create_directory(self, action: Action, *, item_id: str) -> None:
|
|
355
|
+
path = self.policy.normalize(action.parameters.path)
|
|
356
|
+
target = self.policy.resolve(path, allow_missing=True)
|
|
357
|
+
kind = self._virtual_kind(path, target, item_id=item_id)
|
|
358
|
+
if kind is PathKind.FILE:
|
|
359
|
+
raise self._error(
|
|
360
|
+
PlanningErrorCode.TARGET_TYPE_INVALID,
|
|
361
|
+
"create_directory target is an existing file",
|
|
362
|
+
item_id,
|
|
363
|
+
path=path.as_posix(),
|
|
364
|
+
)
|
|
365
|
+
if kind is PathKind.DIRECTORY:
|
|
366
|
+
disposition = ActionDisposition.NO_CHANGE
|
|
367
|
+
self._record_fingerprint(target, item_id=item_id)
|
|
368
|
+
else:
|
|
369
|
+
self._ensure_parent_directories(path, item_id=item_id)
|
|
370
|
+
self.created_directories.setdefault(path, None)
|
|
371
|
+
disposition = ActionDisposition.CREATE
|
|
372
|
+
self.action_plans.append(
|
|
373
|
+
PlannedAction(
|
|
374
|
+
id=item_id,
|
|
375
|
+
name=action.name,
|
|
376
|
+
disposition=disposition,
|
|
377
|
+
paths=(path,),
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def _plan_create_file(self, action: Action, *, item_id: str) -> None:
|
|
382
|
+
parameters = action.parameters
|
|
383
|
+
path = self.policy.normalize(parameters.path)
|
|
384
|
+
target = self.policy.resolve(path, allow_missing=True)
|
|
385
|
+
self._ensure_parent_directories(path, item_id=item_id)
|
|
386
|
+
kind = self._virtual_kind(path, target, item_id=item_id)
|
|
387
|
+
if kind is PathKind.DIRECTORY:
|
|
388
|
+
raise self._error(
|
|
389
|
+
PlanningErrorCode.TARGET_TYPE_INVALID,
|
|
390
|
+
"create_file target is an existing directory",
|
|
391
|
+
item_id,
|
|
392
|
+
path=path.as_posix(),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
new_state = self._new_file_state(
|
|
396
|
+
path,
|
|
397
|
+
parameters.content,
|
|
398
|
+
parameters.encoding,
|
|
399
|
+
NewlineStyle(parameters.newline),
|
|
400
|
+
item_id=item_id,
|
|
401
|
+
)
|
|
402
|
+
if kind is PathKind.FILE:
|
|
403
|
+
current = self._get_file(path, target, item_id=item_id)
|
|
404
|
+
if current.current_bytes != new_state.current_bytes:
|
|
405
|
+
raise self._error(
|
|
406
|
+
PlanningErrorCode.CREATE_FILE_CONFLICT,
|
|
407
|
+
"create_file target already exists with different content",
|
|
408
|
+
item_id,
|
|
409
|
+
path=path.as_posix(),
|
|
410
|
+
)
|
|
411
|
+
disposition = ActionDisposition.NO_CHANGE
|
|
412
|
+
else:
|
|
413
|
+
self.files[path] = new_state
|
|
414
|
+
disposition = ActionDisposition.CREATE
|
|
415
|
+
|
|
416
|
+
self.action_plans.append(
|
|
417
|
+
PlannedAction(
|
|
418
|
+
id=item_id,
|
|
419
|
+
name=action.name,
|
|
420
|
+
disposition=disposition,
|
|
421
|
+
paths=(path,),
|
|
422
|
+
)
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
def _plan_exact_edit(self, action: Action, *, item_id: str) -> None:
|
|
426
|
+
parameters = action.parameters
|
|
427
|
+
path = self.policy.normalize(parameters.path)
|
|
428
|
+
target = self.policy.resolve(path, allow_missing=True)
|
|
429
|
+
state = self._get_file(path, target, item_id=item_id)
|
|
430
|
+
text = state.text
|
|
431
|
+
|
|
432
|
+
if action.name == "replace_exact":
|
|
433
|
+
old = _normalize_newlines(parameters.old)
|
|
434
|
+
new = _normalize_newlines(parameters.new)
|
|
435
|
+
actual = text.count(old)
|
|
436
|
+
if actual == parameters.expected_count:
|
|
437
|
+
updated = text.replace(old, new)
|
|
438
|
+
elif actual == 0 and new and text.count(new) == parameters.expected_count:
|
|
439
|
+
updated = text
|
|
440
|
+
else:
|
|
441
|
+
raise self._occurrence_error(
|
|
442
|
+
item_id,
|
|
443
|
+
path,
|
|
444
|
+
expected=parameters.expected_count,
|
|
445
|
+
actual=actual,
|
|
446
|
+
)
|
|
447
|
+
elif action.name in {"insert_before", "insert_after"}:
|
|
448
|
+
anchor = _normalize_newlines(parameters.anchor)
|
|
449
|
+
content = _normalize_newlines(parameters.content)
|
|
450
|
+
positions = _non_overlapping_positions(text, anchor)
|
|
451
|
+
if len(positions) != parameters.expected_count:
|
|
452
|
+
raise self._occurrence_error(
|
|
453
|
+
item_id,
|
|
454
|
+
path,
|
|
455
|
+
expected=parameters.expected_count,
|
|
456
|
+
actual=len(positions),
|
|
457
|
+
)
|
|
458
|
+
adjacency = [
|
|
459
|
+
_is_adjacent(
|
|
460
|
+
text,
|
|
461
|
+
position,
|
|
462
|
+
anchor,
|
|
463
|
+
content,
|
|
464
|
+
before=action.name == "insert_before",
|
|
465
|
+
)
|
|
466
|
+
for position in positions
|
|
467
|
+
]
|
|
468
|
+
if all(adjacency):
|
|
469
|
+
updated = text
|
|
470
|
+
elif any(adjacency):
|
|
471
|
+
raise self._error(
|
|
472
|
+
PlanningErrorCode.INSERTION_STATE_CONFLICT,
|
|
473
|
+
"insert content is adjacent to only some expected anchors",
|
|
474
|
+
item_id,
|
|
475
|
+
path=path.as_posix(),
|
|
476
|
+
)
|
|
477
|
+
elif action.name == "insert_before":
|
|
478
|
+
updated = text.replace(anchor, f"{content}{anchor}")
|
|
479
|
+
else:
|
|
480
|
+
updated = text.replace(anchor, f"{anchor}{content}")
|
|
481
|
+
else:
|
|
482
|
+
deleted = _normalize_newlines(parameters.text)
|
|
483
|
+
actual = text.count(deleted)
|
|
484
|
+
if actual != parameters.expected_count:
|
|
485
|
+
raise self._occurrence_error(
|
|
486
|
+
item_id,
|
|
487
|
+
path,
|
|
488
|
+
expected=parameters.expected_count,
|
|
489
|
+
actual=actual,
|
|
490
|
+
)
|
|
491
|
+
updated = text.replace(deleted, "")
|
|
492
|
+
|
|
493
|
+
disposition = self._update_state(state, updated, item_id=item_id)
|
|
494
|
+
self.action_plans.append(
|
|
495
|
+
PlannedAction(
|
|
496
|
+
id=item_id,
|
|
497
|
+
name=action.name,
|
|
498
|
+
disposition=disposition,
|
|
499
|
+
paths=(path,),
|
|
500
|
+
)
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
def _plan_apply_diff(self, action: Action, *, item_id: str) -> None:
|
|
504
|
+
parameters = action.parameters
|
|
505
|
+
file_diffs = parse_unified_diff(
|
|
506
|
+
parameters.diff,
|
|
507
|
+
strip=parameters.strip,
|
|
508
|
+
item_id=item_id,
|
|
509
|
+
)
|
|
510
|
+
paths: list[PurePosixPath] = []
|
|
511
|
+
changed = False
|
|
512
|
+
for file_diff in file_diffs:
|
|
513
|
+
path = self.policy.normalize(file_diff.path)
|
|
514
|
+
target = self.policy.resolve(path, allow_missing=True)
|
|
515
|
+
state = self._get_file(path, target, item_id=item_id)
|
|
516
|
+
if state.original_bytes is None:
|
|
517
|
+
raise self._error(
|
|
518
|
+
PlanningErrorCode.DIFF_PATH_INVALID,
|
|
519
|
+
"apply_diff accepts only files that existed before this job",
|
|
520
|
+
item_id,
|
|
521
|
+
path=path.as_posix(),
|
|
522
|
+
)
|
|
523
|
+
updated = apply_file_diff(state.text, file_diff, item_id=item_id)
|
|
524
|
+
disposition = self._update_state(state, updated, item_id=item_id)
|
|
525
|
+
changed = changed or disposition is ActionDisposition.MODIFY
|
|
526
|
+
paths.append(path)
|
|
527
|
+
self.action_plans.append(
|
|
528
|
+
PlannedAction(
|
|
529
|
+
id=item_id,
|
|
530
|
+
name=action.name,
|
|
531
|
+
disposition=(
|
|
532
|
+
ActionDisposition.MODIFY if changed else ActionDisposition.NO_CHANGE
|
|
533
|
+
),
|
|
534
|
+
paths=tuple(paths),
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def _plan_check(self, check: Check, *, item_id: str) -> None:
|
|
539
|
+
parameters = check.parameters
|
|
540
|
+
paths: tuple[PurePosixPath, ...] = ()
|
|
541
|
+
if check.name == "compileall":
|
|
542
|
+
paths = tuple(
|
|
543
|
+
self._check_target(path, item_id=item_id).relative
|
|
544
|
+
for path in parameters.paths
|
|
545
|
+
)
|
|
546
|
+
elif check.name == "pytest":
|
|
547
|
+
self._validate_pytest_args(parameters.args, item_id=item_id)
|
|
548
|
+
paths = tuple(
|
|
549
|
+
self._check_target(path, item_id=item_id).relative
|
|
550
|
+
for path in parameters.paths
|
|
551
|
+
)
|
|
552
|
+
self._require_module("pytest", item_id=item_id)
|
|
553
|
+
elif check.name == "unittest":
|
|
554
|
+
target = self._check_target(
|
|
555
|
+
parameters.discover,
|
|
556
|
+
item_id=item_id,
|
|
557
|
+
expected=PathKind.DIRECTORY,
|
|
558
|
+
)
|
|
559
|
+
paths = (target.relative,)
|
|
560
|
+
elif check.name in {
|
|
561
|
+
"django_check",
|
|
562
|
+
"django_migrations_check",
|
|
563
|
+
"django_test",
|
|
564
|
+
}:
|
|
565
|
+
target = self._check_target(
|
|
566
|
+
parameters.manage_py,
|
|
567
|
+
item_id=item_id,
|
|
568
|
+
expected=PathKind.FILE,
|
|
569
|
+
)
|
|
570
|
+
paths = (target.relative,)
|
|
571
|
+
if check.name == "django_test":
|
|
572
|
+
invalid_labels = [
|
|
573
|
+
label
|
|
574
|
+
for label in parameters.labels
|
|
575
|
+
if _DJANGO_LABEL.fullmatch(label) is None
|
|
576
|
+
]
|
|
577
|
+
if invalid_labels:
|
|
578
|
+
raise self._error(
|
|
579
|
+
PlanningErrorCode.CHECK_ARGUMENT_INVALID,
|
|
580
|
+
"Django test labels must be dotted Python identifiers",
|
|
581
|
+
item_id,
|
|
582
|
+
path=invalid_labels[0],
|
|
583
|
+
)
|
|
584
|
+
self._require_module("django", item_id=item_id)
|
|
585
|
+
elif check.name == "profile":
|
|
586
|
+
if parameters.name not in self.workspace.config.checks.profiles:
|
|
587
|
+
raise self._error(
|
|
588
|
+
PlanningErrorCode.CHECK_PROFILE_NOT_FOUND,
|
|
589
|
+
"requested check profile is not defined in local configuration",
|
|
590
|
+
item_id,
|
|
591
|
+
path=parameters.name,
|
|
592
|
+
)
|
|
593
|
+
self._require_profile_command(parameters.name, item_id=item_id)
|
|
594
|
+
|
|
595
|
+
self.check_plans.append(PlannedCheck(id=item_id, name=check.name, paths=paths))
|
|
596
|
+
|
|
597
|
+
def _check_target(
|
|
598
|
+
self,
|
|
599
|
+
value: str,
|
|
600
|
+
*,
|
|
601
|
+
item_id: str,
|
|
602
|
+
expected: PathKind | None = None,
|
|
603
|
+
) -> WorkspacePath:
|
|
604
|
+
path = self.policy.normalize(value)
|
|
605
|
+
if self.policy.is_ignored(path):
|
|
606
|
+
raise self._error(
|
|
607
|
+
PlanningErrorCode.PATH_IGNORED,
|
|
608
|
+
"check target is ignored by local policy",
|
|
609
|
+
item_id,
|
|
610
|
+
path=path.as_posix(),
|
|
611
|
+
)
|
|
612
|
+
target = self.policy.resolve(path, allow_root=True, allow_missing=True)
|
|
613
|
+
kind = self._virtual_kind(path, target, item_id=item_id)
|
|
614
|
+
if kind is PathKind.MISSING:
|
|
615
|
+
raise self._error(
|
|
616
|
+
PlanningErrorCode.CHECK_PATH_NOT_FOUND,
|
|
617
|
+
"check target does not exist in the planned workspace",
|
|
618
|
+
item_id,
|
|
619
|
+
path=path.as_posix(),
|
|
620
|
+
)
|
|
621
|
+
if expected is not None and kind is not expected:
|
|
622
|
+
raise self._error(
|
|
623
|
+
PlanningErrorCode.TARGET_TYPE_INVALID,
|
|
624
|
+
f"expected a {expected.value} check target",
|
|
625
|
+
item_id,
|
|
626
|
+
path=path.as_posix(),
|
|
627
|
+
)
|
|
628
|
+
if target.exists:
|
|
629
|
+
self._record_fingerprint(target, item_id=item_id)
|
|
630
|
+
return WorkspacePath(relative=path, absolute=target.absolute, kind=kind)
|
|
631
|
+
|
|
632
|
+
def _validate_pytest_args(self, args: tuple[str, ...], *, item_id: str) -> None:
|
|
633
|
+
for argument in args:
|
|
634
|
+
allowed = argument in _PYTEST_EXACT_ARGS
|
|
635
|
+
if argument.startswith("--maxfail="):
|
|
636
|
+
value = argument.removeprefix("--maxfail=")
|
|
637
|
+
allowed = value.isdigit() and int(value) > 0
|
|
638
|
+
elif argument.startswith("--tb="):
|
|
639
|
+
allowed = argument.removeprefix("--tb=") in _PYTEST_TB_VALUES
|
|
640
|
+
elif argument.startswith("--capture="):
|
|
641
|
+
allowed = argument.removeprefix("--capture=") in _PYTEST_CAPTURE_VALUES
|
|
642
|
+
if not allowed:
|
|
643
|
+
raise self._error(
|
|
644
|
+
PlanningErrorCode.PYTEST_ARGUMENT_FORBIDDEN,
|
|
645
|
+
"pytest argument is not in the local safe allowlist",
|
|
646
|
+
item_id,
|
|
647
|
+
path=argument,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
def _require_module(self, name: str, *, item_id: str) -> None:
|
|
651
|
+
try:
|
|
652
|
+
available = find_spec(name) is not None
|
|
653
|
+
except (ImportError, ValueError):
|
|
654
|
+
available = False
|
|
655
|
+
if not available:
|
|
656
|
+
raise self._error(
|
|
657
|
+
PlanningErrorCode.DEPENDENCY_NOT_AVAILABLE,
|
|
658
|
+
f"required Python module {name!r} is not available",
|
|
659
|
+
item_id,
|
|
660
|
+
path=name,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
def _require_profile_command(self, name: str, *, item_id: str) -> None:
|
|
664
|
+
profile = self.workspace.config.checks.profiles[name]
|
|
665
|
+
command = profile.argv[0]
|
|
666
|
+
if command == "{python}":
|
|
667
|
+
available = Path(sys.executable).is_file()
|
|
668
|
+
elif Path(command).is_absolute() or "/" in command or "\\" in command:
|
|
669
|
+
candidate = Path(command)
|
|
670
|
+
if not candidate.is_absolute():
|
|
671
|
+
candidate = self.workspace.root / candidate
|
|
672
|
+
available = candidate.is_file()
|
|
673
|
+
else:
|
|
674
|
+
available = shutil.which(command) is not None
|
|
675
|
+
if not available:
|
|
676
|
+
raise self._error(
|
|
677
|
+
PlanningErrorCode.DEPENDENCY_NOT_AVAILABLE,
|
|
678
|
+
"local check profile executable is not available",
|
|
679
|
+
item_id,
|
|
680
|
+
path=command,
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
def _virtual_kind(
|
|
684
|
+
self,
|
|
685
|
+
path: PurePosixPath,
|
|
686
|
+
target: WorkspacePath,
|
|
687
|
+
*,
|
|
688
|
+
item_id: str,
|
|
689
|
+
) -> PathKind:
|
|
690
|
+
self._reject_virtual_file_parent(path, item_id=item_id)
|
|
691
|
+
if path in self.files:
|
|
692
|
+
return PathKind.FILE
|
|
693
|
+
if path in self.created_directories:
|
|
694
|
+
return PathKind.DIRECTORY
|
|
695
|
+
return target.kind
|
|
696
|
+
|
|
697
|
+
def _reject_virtual_file_parent(
|
|
698
|
+
self,
|
|
699
|
+
path: PurePosixPath,
|
|
700
|
+
*,
|
|
701
|
+
item_id: str,
|
|
702
|
+
) -> None:
|
|
703
|
+
for index in range(1, len(path.parts)):
|
|
704
|
+
parent = PurePosixPath(*path.parts[:index])
|
|
705
|
+
if parent in self.files:
|
|
706
|
+
raise self._error(
|
|
707
|
+
PlanningErrorCode.VIRTUAL_PATH_CONFLICT,
|
|
708
|
+
"a planned file cannot be used as a parent directory",
|
|
709
|
+
item_id,
|
|
710
|
+
path=parent.as_posix(),
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
def _ensure_parent_directories(
|
|
714
|
+
self,
|
|
715
|
+
path: PurePosixPath,
|
|
716
|
+
*,
|
|
717
|
+
item_id: str,
|
|
718
|
+
) -> None:
|
|
719
|
+
for index in range(1, len(path.parts)):
|
|
720
|
+
parent = PurePosixPath(*path.parts[:index])
|
|
721
|
+
if parent in self.files:
|
|
722
|
+
raise self._error(
|
|
723
|
+
PlanningErrorCode.VIRTUAL_PATH_CONFLICT,
|
|
724
|
+
"a planned file cannot be used as a parent directory",
|
|
725
|
+
item_id,
|
|
726
|
+
path=parent.as_posix(),
|
|
727
|
+
)
|
|
728
|
+
if parent in self.created_directories:
|
|
729
|
+
continue
|
|
730
|
+
target = self.policy.resolve(parent, allow_missing=True)
|
|
731
|
+
if target.kind is PathKind.FILE:
|
|
732
|
+
raise self._error(
|
|
733
|
+
PlanningErrorCode.TARGET_TYPE_INVALID,
|
|
734
|
+
"an existing parent is not a directory",
|
|
735
|
+
item_id,
|
|
736
|
+
path=parent.as_posix(),
|
|
737
|
+
)
|
|
738
|
+
if target.kind is PathKind.MISSING:
|
|
739
|
+
self.created_directories.setdefault(parent, None)
|
|
740
|
+
else:
|
|
741
|
+
self._record_fingerprint(target, item_id=item_id)
|
|
742
|
+
|
|
743
|
+
def _get_file(
|
|
744
|
+
self,
|
|
745
|
+
path: PurePosixPath,
|
|
746
|
+
target: WorkspacePath,
|
|
747
|
+
*,
|
|
748
|
+
item_id: str,
|
|
749
|
+
) -> _TextState:
|
|
750
|
+
self._reject_virtual_file_parent(path, item_id=item_id)
|
|
751
|
+
if path in self.created_directories:
|
|
752
|
+
raise self._error(
|
|
753
|
+
PlanningErrorCode.TARGET_TYPE_INVALID,
|
|
754
|
+
"text action target is a planned directory",
|
|
755
|
+
item_id,
|
|
756
|
+
path=path.as_posix(),
|
|
757
|
+
)
|
|
758
|
+
existing = self.files.get(path)
|
|
759
|
+
if existing is not None:
|
|
760
|
+
return existing
|
|
761
|
+
if target.kind is not PathKind.FILE:
|
|
762
|
+
raise self._error(
|
|
763
|
+
PlanningErrorCode.TARGET_TYPE_INVALID,
|
|
764
|
+
"text action requires an existing regular file",
|
|
765
|
+
item_id,
|
|
766
|
+
path=path.as_posix(),
|
|
767
|
+
)
|
|
768
|
+
raw = self._read_existing_file(target, item_id=item_id)
|
|
769
|
+
state = self._decode_existing(raw, item_id=item_id, path=path)
|
|
770
|
+
self.files[path] = state
|
|
771
|
+
return state
|
|
772
|
+
|
|
773
|
+
def _new_file_state(
|
|
774
|
+
self,
|
|
775
|
+
path: PurePosixPath,
|
|
776
|
+
content: str,
|
|
777
|
+
encoding: str,
|
|
778
|
+
newline: NewlineStyle,
|
|
779
|
+
*,
|
|
780
|
+
item_id: str,
|
|
781
|
+
) -> _TextState:
|
|
782
|
+
normalized = _normalize_newlines(content)
|
|
783
|
+
if any(_is_binary_control(character) for character in normalized):
|
|
784
|
+
raise self._error(
|
|
785
|
+
PlanningErrorCode.CONTENT_BINARY_FORBIDDEN,
|
|
786
|
+
"create_file content contains binary control characters",
|
|
787
|
+
item_id,
|
|
788
|
+
path=path.as_posix(),
|
|
789
|
+
)
|
|
790
|
+
rendered = _render_newlines(normalized, newline)
|
|
791
|
+
try:
|
|
792
|
+
codec = codecs.lookup(encoding).name
|
|
793
|
+
raw = rendered.encode(encoding)
|
|
794
|
+
except (LookupError, UnicodeError, TypeError) as exc:
|
|
795
|
+
raise self._error(
|
|
796
|
+
PlanningErrorCode.CONTENT_ENCODING_INVALID,
|
|
797
|
+
"content cannot be encoded with the requested text encoding",
|
|
798
|
+
item_id,
|
|
799
|
+
path=path.as_posix(),
|
|
800
|
+
) from exc
|
|
801
|
+
self._require_size(raw, item_id=item_id, path=path)
|
|
802
|
+
return _TextState(
|
|
803
|
+
path=path,
|
|
804
|
+
original_bytes=None,
|
|
805
|
+
current_bytes=raw,
|
|
806
|
+
text=normalized,
|
|
807
|
+
encoding=codec,
|
|
808
|
+
codec=encoding,
|
|
809
|
+
bom=b"",
|
|
810
|
+
newline=newline,
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
def _decode_existing(
|
|
814
|
+
self,
|
|
815
|
+
raw: bytes,
|
|
816
|
+
*,
|
|
817
|
+
item_id: str,
|
|
818
|
+
path: PurePosixPath,
|
|
819
|
+
) -> _TextState:
|
|
820
|
+
bom = b""
|
|
821
|
+
if raw.startswith(_UTF32_LE_BOM):
|
|
822
|
+
bom, codec, encoding = _UTF32_LE_BOM, "utf-32-le", "utf-32-le"
|
|
823
|
+
elif raw.startswith(_UTF32_BE_BOM):
|
|
824
|
+
bom, codec, encoding = _UTF32_BE_BOM, "utf-32-be", "utf-32-be"
|
|
825
|
+
elif raw.startswith(_UTF8_BOM):
|
|
826
|
+
bom, codec, encoding = _UTF8_BOM, "utf-8", "utf-8-sig"
|
|
827
|
+
elif raw.startswith(_UTF16_LE_BOM):
|
|
828
|
+
bom, codec, encoding = _UTF16_LE_BOM, "utf-16-le", "utf-16-le"
|
|
829
|
+
elif raw.startswith(_UTF16_BE_BOM):
|
|
830
|
+
bom, codec, encoding = _UTF16_BE_BOM, "utf-16-be", "utf-16-be"
|
|
831
|
+
else:
|
|
832
|
+
codec = encoding = "utf-8"
|
|
833
|
+
if b"\0" in raw:
|
|
834
|
+
raise self._error(
|
|
835
|
+
PlanningErrorCode.FILE_BINARY,
|
|
836
|
+
"file contains null bytes",
|
|
837
|
+
item_id,
|
|
838
|
+
path=path.as_posix(),
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
try:
|
|
842
|
+
text = raw[len(bom) :].decode(codec)
|
|
843
|
+
except UnicodeDecodeError as exc:
|
|
844
|
+
raise self._error(
|
|
845
|
+
PlanningErrorCode.FILE_ENCODING_UNSUPPORTED,
|
|
846
|
+
"file is not valid UTF text in a supported encoding",
|
|
847
|
+
item_id,
|
|
848
|
+
path=path.as_posix(),
|
|
849
|
+
) from exc
|
|
850
|
+
if any(_is_binary_control(character) for character in text):
|
|
851
|
+
raise self._error(
|
|
852
|
+
PlanningErrorCode.FILE_BINARY,
|
|
853
|
+
"file contains binary control characters",
|
|
854
|
+
item_id,
|
|
855
|
+
path=path.as_posix(),
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
newline, normalized = self._detect_newlines(
|
|
859
|
+
text,
|
|
860
|
+
item_id=item_id,
|
|
861
|
+
path=path,
|
|
862
|
+
)
|
|
863
|
+
return _TextState(
|
|
864
|
+
path=path,
|
|
865
|
+
original_bytes=raw,
|
|
866
|
+
current_bytes=raw,
|
|
867
|
+
text=normalized,
|
|
868
|
+
encoding=encoding,
|
|
869
|
+
codec=codec,
|
|
870
|
+
bom=bom,
|
|
871
|
+
newline=newline,
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
def _detect_newlines(
|
|
875
|
+
self,
|
|
876
|
+
text: str,
|
|
877
|
+
*,
|
|
878
|
+
item_id: str,
|
|
879
|
+
path: PurePosixPath,
|
|
880
|
+
) -> tuple[NewlineStyle, str]:
|
|
881
|
+
without_crlf = text.replace("\r\n", "")
|
|
882
|
+
has_crlf = "\r\n" in text
|
|
883
|
+
has_lf = "\n" in without_crlf
|
|
884
|
+
if "\r" in without_crlf or (has_crlf and has_lf):
|
|
885
|
+
raise self._error(
|
|
886
|
+
PlanningErrorCode.FILE_NEWLINE_UNSUPPORTED,
|
|
887
|
+
"mixed or CR-only newlines are not supported for text changes",
|
|
888
|
+
item_id,
|
|
889
|
+
path=path.as_posix(),
|
|
890
|
+
)
|
|
891
|
+
if has_crlf:
|
|
892
|
+
return NewlineStyle.CRLF, text.replace("\r\n", "\n")
|
|
893
|
+
if has_lf:
|
|
894
|
+
return NewlineStyle.LF, text
|
|
895
|
+
return NewlineStyle.NONE, text
|
|
896
|
+
|
|
897
|
+
def _update_state(
|
|
898
|
+
self,
|
|
899
|
+
state: _TextState,
|
|
900
|
+
updated: str,
|
|
901
|
+
*,
|
|
902
|
+
item_id: str,
|
|
903
|
+
) -> ActionDisposition:
|
|
904
|
+
newline = state.newline
|
|
905
|
+
if newline is NewlineStyle.NONE and "\n" in updated:
|
|
906
|
+
newline = NewlineStyle.LF
|
|
907
|
+
rendered = _render_newlines(updated, newline)
|
|
908
|
+
try:
|
|
909
|
+
raw = state.bom + rendered.encode(state.codec)
|
|
910
|
+
except (LookupError, UnicodeError) as exc:
|
|
911
|
+
raise self._error(
|
|
912
|
+
PlanningErrorCode.CONTENT_ENCODING_INVALID,
|
|
913
|
+
"planned content cannot be represented in the target encoding",
|
|
914
|
+
item_id,
|
|
915
|
+
path=state.path.as_posix(),
|
|
916
|
+
) from exc
|
|
917
|
+
self._require_size(raw, item_id=item_id, path=state.path)
|
|
918
|
+
if raw == state.current_bytes:
|
|
919
|
+
return ActionDisposition.NO_CHANGE
|
|
920
|
+
state.text = updated
|
|
921
|
+
state.current_bytes = raw
|
|
922
|
+
state.newline = newline
|
|
923
|
+
return ActionDisposition.MODIFY
|
|
924
|
+
|
|
925
|
+
def _read_existing_file(
|
|
926
|
+
self,
|
|
927
|
+
target: WorkspacePath,
|
|
928
|
+
*,
|
|
929
|
+
item_id: str,
|
|
930
|
+
) -> bytes:
|
|
931
|
+
try:
|
|
932
|
+
size = target.absolute.lstat().st_size
|
|
933
|
+
except OSError as exc:
|
|
934
|
+
raise self._error(
|
|
935
|
+
PlanningErrorCode.FILE_READ_FAILED,
|
|
936
|
+
"file metadata could not be read",
|
|
937
|
+
item_id,
|
|
938
|
+
path=target.relative.as_posix(),
|
|
939
|
+
) from exc
|
|
940
|
+
if size > self.max_file_bytes:
|
|
941
|
+
raise self._error(
|
|
942
|
+
PlanningErrorCode.FILE_SIZE_LIMIT_EXCEEDED,
|
|
943
|
+
f"file exceeds the configured {self.max_file_bytes}-byte limit",
|
|
944
|
+
item_id,
|
|
945
|
+
path=target.relative.as_posix(),
|
|
946
|
+
)
|
|
947
|
+
try:
|
|
948
|
+
raw = target.absolute.read_bytes()
|
|
949
|
+
except OSError as exc:
|
|
950
|
+
raise self._error(
|
|
951
|
+
PlanningErrorCode.FILE_READ_FAILED,
|
|
952
|
+
"file could not be read",
|
|
953
|
+
item_id,
|
|
954
|
+
path=target.relative.as_posix(),
|
|
955
|
+
) from exc
|
|
956
|
+
self._require_size(raw, item_id=item_id, path=target.relative)
|
|
957
|
+
revalidated = self.policy.resolve(target.relative)
|
|
958
|
+
if revalidated.absolute != target.absolute:
|
|
959
|
+
raise self._error(
|
|
960
|
+
PlanningErrorCode.FILE_READ_FAILED,
|
|
961
|
+
"file path changed during planning",
|
|
962
|
+
item_id,
|
|
963
|
+
path=target.relative.as_posix(),
|
|
964
|
+
)
|
|
965
|
+
self._record_fingerprint(target, item_id=item_id, raw=raw)
|
|
966
|
+
return raw
|
|
967
|
+
|
|
968
|
+
def _record_fingerprint(
|
|
969
|
+
self,
|
|
970
|
+
target: WorkspacePath,
|
|
971
|
+
*,
|
|
972
|
+
item_id: str,
|
|
973
|
+
raw: bytes | None = None,
|
|
974
|
+
) -> None:
|
|
975
|
+
if not target.exists:
|
|
976
|
+
return
|
|
977
|
+
try:
|
|
978
|
+
metadata = target.absolute.lstat()
|
|
979
|
+
except OSError as exc:
|
|
980
|
+
raise self._error(
|
|
981
|
+
PlanningErrorCode.FILE_READ_FAILED,
|
|
982
|
+
"target metadata could not be retained for revalidation",
|
|
983
|
+
item_id,
|
|
984
|
+
path=target.relative.as_posix(),
|
|
985
|
+
) from exc
|
|
986
|
+
fingerprint = PathFingerprint(
|
|
987
|
+
path=target.relative,
|
|
988
|
+
kind=target.kind,
|
|
989
|
+
size=metadata.st_size,
|
|
990
|
+
modified_ns=metadata.st_mtime_ns,
|
|
991
|
+
sha256=hashlib.sha256(raw).hexdigest() if raw is not None else None,
|
|
992
|
+
)
|
|
993
|
+
previous = self.fingerprints.get(target.relative)
|
|
994
|
+
if previous is None or fingerprint.sha256 is not None:
|
|
995
|
+
self.fingerprints[target.relative] = fingerprint
|
|
996
|
+
|
|
997
|
+
def _require_size(
|
|
998
|
+
self,
|
|
999
|
+
raw: bytes,
|
|
1000
|
+
*,
|
|
1001
|
+
item_id: str,
|
|
1002
|
+
path: PurePosixPath,
|
|
1003
|
+
) -> None:
|
|
1004
|
+
if len(raw) > self.max_file_bytes:
|
|
1005
|
+
raise self._error(
|
|
1006
|
+
PlanningErrorCode.FILE_SIZE_LIMIT_EXCEEDED,
|
|
1007
|
+
f"planned file exceeds the configured {self.max_file_bytes}-byte limit",
|
|
1008
|
+
item_id,
|
|
1009
|
+
path=path.as_posix(),
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
def _occurrence_error(
|
|
1013
|
+
self,
|
|
1014
|
+
item_id: str,
|
|
1015
|
+
path: PurePosixPath,
|
|
1016
|
+
*,
|
|
1017
|
+
expected: int,
|
|
1018
|
+
actual: int,
|
|
1019
|
+
) -> PlanningError:
|
|
1020
|
+
return self._error(
|
|
1021
|
+
PlanningErrorCode.OCCURRENCE_COUNT_MISMATCH,
|
|
1022
|
+
f"expected {expected} exact occurrence(s), found {actual}",
|
|
1023
|
+
item_id,
|
|
1024
|
+
path=path.as_posix(),
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
def _build_file_changes(self) -> tuple[PlannedFileChange, ...]:
|
|
1028
|
+
changes: list[PlannedFileChange] = []
|
|
1029
|
+
for state in self.files.values():
|
|
1030
|
+
if (
|
|
1031
|
+
state.original_bytes is not None
|
|
1032
|
+
and state.current_bytes == state.original_bytes
|
|
1033
|
+
):
|
|
1034
|
+
continue
|
|
1035
|
+
disposition = (
|
|
1036
|
+
FileDisposition.CREATE
|
|
1037
|
+
if state.original_bytes is None
|
|
1038
|
+
else FileDisposition.MODIFY
|
|
1039
|
+
)
|
|
1040
|
+
changes.append(
|
|
1041
|
+
PlannedFileChange(
|
|
1042
|
+
path=state.path,
|
|
1043
|
+
disposition=disposition,
|
|
1044
|
+
before_sha256=(
|
|
1045
|
+
hashlib.sha256(state.original_bytes).hexdigest()
|
|
1046
|
+
if state.original_bytes is not None
|
|
1047
|
+
else None
|
|
1048
|
+
),
|
|
1049
|
+
after_sha256=hashlib.sha256(state.current_bytes).hexdigest(),
|
|
1050
|
+
before_size=(
|
|
1051
|
+
len(state.original_bytes)
|
|
1052
|
+
if state.original_bytes is not None
|
|
1053
|
+
else None
|
|
1054
|
+
),
|
|
1055
|
+
after_size=len(state.current_bytes),
|
|
1056
|
+
encoding=state.encoding,
|
|
1057
|
+
newline=state.newline,
|
|
1058
|
+
content=state.current_bytes,
|
|
1059
|
+
)
|
|
1060
|
+
)
|
|
1061
|
+
return tuple(changes)
|
|
1062
|
+
|
|
1063
|
+
def _formatting_targets(
|
|
1064
|
+
self,
|
|
1065
|
+
changes: tuple[PlannedFileChange, ...],
|
|
1066
|
+
) -> tuple[PurePosixPath, ...]:
|
|
1067
|
+
if not self.workspace.config.formatting.enabled:
|
|
1068
|
+
return ()
|
|
1069
|
+
return tuple(change.path for change in changes if change.path.suffix == ".py")
|
|
1070
|
+
|
|
1071
|
+
@staticmethod
|
|
1072
|
+
def _error(
|
|
1073
|
+
code: PlanningErrorCode,
|
|
1074
|
+
message: str,
|
|
1075
|
+
item_id: str,
|
|
1076
|
+
*,
|
|
1077
|
+
path: str | None = None,
|
|
1078
|
+
) -> PlanningError:
|
|
1079
|
+
return PlanningError(code, message, item_id=item_id, path=path)
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def normalized_job_hash(job: Job) -> str:
|
|
1083
|
+
"""Return the stable protocol hash used by plans and the registry."""
|
|
1084
|
+
|
|
1085
|
+
normalized = json.dumps(
|
|
1086
|
+
job.model_dump(mode="json"),
|
|
1087
|
+
ensure_ascii=False,
|
|
1088
|
+
sort_keys=True,
|
|
1089
|
+
separators=(",", ":"),
|
|
1090
|
+
).encode("utf-8")
|
|
1091
|
+
return hashlib.sha256(normalized).hexdigest()
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _normalize_newlines(value: str) -> str:
|
|
1095
|
+
return value.replace("\r\n", "\n").replace("\r", "\n")
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def _render_newlines(value: str, newline: NewlineStyle) -> str:
|
|
1099
|
+
return value.replace("\n", "\r\n") if newline is NewlineStyle.CRLF else value
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _is_binary_control(character: str) -> bool:
|
|
1103
|
+
value = ord(character)
|
|
1104
|
+
return (value < 32 and character not in "\t\n\r") or value == 127
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _non_overlapping_positions(text: str, value: str) -> tuple[int, ...]:
|
|
1108
|
+
positions: list[int] = []
|
|
1109
|
+
start = 0
|
|
1110
|
+
while True:
|
|
1111
|
+
position = text.find(value, start)
|
|
1112
|
+
if position < 0:
|
|
1113
|
+
return tuple(positions)
|
|
1114
|
+
positions.append(position)
|
|
1115
|
+
start = position + len(value)
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def _is_adjacent(
|
|
1119
|
+
text: str,
|
|
1120
|
+
position: int,
|
|
1121
|
+
anchor: str,
|
|
1122
|
+
content: str,
|
|
1123
|
+
*,
|
|
1124
|
+
before: bool,
|
|
1125
|
+
) -> bool:
|
|
1126
|
+
if before:
|
|
1127
|
+
start = position - len(content)
|
|
1128
|
+
return start >= 0 and text[start:position] == content
|
|
1129
|
+
start = position + len(anchor)
|
|
1130
|
+
return text[start : start + len(content)] == content
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
__all__ = [
|
|
1134
|
+
"ActionDisposition",
|
|
1135
|
+
"FileDisposition",
|
|
1136
|
+
"NewlineStyle",
|
|
1137
|
+
"PathFingerprint",
|
|
1138
|
+
"Plan",
|
|
1139
|
+
"PlannedAction",
|
|
1140
|
+
"PlannedCheck",
|
|
1141
|
+
"PlannedFileChange",
|
|
1142
|
+
"normalized_job_hash",
|
|
1143
|
+
"plan_job",
|
|
1144
|
+
]
|