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,240 @@
|
|
|
1
|
+
"""Controlled isort and Black execution for immutable formatting scopes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from typing import Literal, TypeAlias
|
|
12
|
+
|
|
13
|
+
from patchshuttle._process import ProcessCommand, run_process
|
|
14
|
+
from patchshuttle.planner import Plan
|
|
15
|
+
from patchshuttle.policy import PathKind, Policy
|
|
16
|
+
|
|
17
|
+
FormatterName: TypeAlias = Literal["isort", "black"]
|
|
18
|
+
_FORMATTER_ORDER: tuple[FormatterName, FormatterName] = ("isort", "black")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FormatterStatus(str, Enum):
|
|
22
|
+
"""Observable outcome of one controlled formatter process."""
|
|
23
|
+
|
|
24
|
+
PASSED = "PASSED"
|
|
25
|
+
FAILED = "FAILED"
|
|
26
|
+
TIMED_OUT = "TIMED_OUT"
|
|
27
|
+
ERROR = "ERROR"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class PreparedFormatter:
|
|
32
|
+
"""One fixed formatter command over the approved changed-Python scope."""
|
|
33
|
+
|
|
34
|
+
id: str
|
|
35
|
+
name: FormatterName
|
|
36
|
+
argv: tuple[str, ...]
|
|
37
|
+
working_directory: Path
|
|
38
|
+
timeout_seconds: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class FormatterResult:
|
|
43
|
+
"""Bounded captured outcome of one launched formatter."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
name: FormatterName
|
|
47
|
+
status: FormatterStatus
|
|
48
|
+
argv: tuple[str, ...]
|
|
49
|
+
working_directory: Path
|
|
50
|
+
timeout_seconds: int
|
|
51
|
+
return_code: int | None
|
|
52
|
+
duration_ms: int
|
|
53
|
+
stdout: str
|
|
54
|
+
stderr: str
|
|
55
|
+
stdout_truncated: bool
|
|
56
|
+
stderr_truncated: bool
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def success(self) -> bool:
|
|
60
|
+
return self.status is FormatterStatus.PASSED
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class FormatterRunResult:
|
|
65
|
+
"""Ordered formatter results through the first failure, if any."""
|
|
66
|
+
|
|
67
|
+
results: tuple[FormatterResult, ...]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def success(self) -> bool:
|
|
71
|
+
return all(result.success for result in self.results)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def failed(self) -> FormatterResult | None:
|
|
75
|
+
return next((result for result in self.results if not result.success), None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class FormattedFileState:
|
|
80
|
+
"""Exact bounded post-formatter state retained through final checks."""
|
|
81
|
+
|
|
82
|
+
path: PurePosixPath
|
|
83
|
+
sha256: str
|
|
84
|
+
size: int
|
|
85
|
+
mode: int
|
|
86
|
+
content: bytes = field(repr=False)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def prepare_formatters(plan: Plan) -> tuple[PreparedFormatter, ...]:
|
|
90
|
+
"""Build fixed isort-then-Black commands from the immutable plan scope."""
|
|
91
|
+
|
|
92
|
+
formatting = plan.workspace.config.formatting
|
|
93
|
+
expected_targets = (
|
|
94
|
+
tuple(
|
|
95
|
+
change.path for change in plan.file_changes if change.path.suffix == ".py"
|
|
96
|
+
)
|
|
97
|
+
if formatting.enabled
|
|
98
|
+
else ()
|
|
99
|
+
)
|
|
100
|
+
if plan.formatting_targets != expected_targets:
|
|
101
|
+
raise ValueError("plan formatting targets do not match changed Python files")
|
|
102
|
+
if not expected_targets:
|
|
103
|
+
return ()
|
|
104
|
+
if formatting.order != _FORMATTER_ORDER:
|
|
105
|
+
raise ValueError("protocol 1 requires isort then Black formatter order")
|
|
106
|
+
|
|
107
|
+
paths = tuple(path.as_posix() for path in expected_targets)
|
|
108
|
+
timeout = plan.workspace.config.execution.default_timeout_seconds
|
|
109
|
+
commands: list[PreparedFormatter] = []
|
|
110
|
+
for index, name in enumerate(_FORMATTER_ORDER, start=1):
|
|
111
|
+
options = ("--overwrite-in-place",) if name == "isort" else ()
|
|
112
|
+
commands.append(
|
|
113
|
+
PreparedFormatter(
|
|
114
|
+
id=f"formatter_{index:03d}",
|
|
115
|
+
name=name,
|
|
116
|
+
argv=(
|
|
117
|
+
sys.executable,
|
|
118
|
+
"-I",
|
|
119
|
+
"-m",
|
|
120
|
+
name,
|
|
121
|
+
*options,
|
|
122
|
+
"--",
|
|
123
|
+
*paths,
|
|
124
|
+
),
|
|
125
|
+
working_directory=plan.workspace.root,
|
|
126
|
+
timeout_seconds=timeout,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
return tuple(commands)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def run_formatters(plan: Plan) -> FormatterRunResult:
|
|
133
|
+
"""Run isort and Black sequentially, stopping at the first failure."""
|
|
134
|
+
|
|
135
|
+
maximum = plan.workspace.config.execution.max_command_output_bytes
|
|
136
|
+
results: list[FormatterResult] = []
|
|
137
|
+
for formatter in prepare_formatters(plan):
|
|
138
|
+
process = run_process(
|
|
139
|
+
ProcessCommand(
|
|
140
|
+
argv=formatter.argv,
|
|
141
|
+
working_directory=formatter.working_directory,
|
|
142
|
+
timeout_seconds=formatter.timeout_seconds,
|
|
143
|
+
),
|
|
144
|
+
maximum_output_bytes=maximum,
|
|
145
|
+
)
|
|
146
|
+
result = FormatterResult(
|
|
147
|
+
id=formatter.id,
|
|
148
|
+
name=formatter.name,
|
|
149
|
+
status=FormatterStatus(process.status.value),
|
|
150
|
+
argv=formatter.argv,
|
|
151
|
+
working_directory=formatter.working_directory,
|
|
152
|
+
timeout_seconds=formatter.timeout_seconds,
|
|
153
|
+
return_code=process.return_code,
|
|
154
|
+
duration_ms=process.duration_ms,
|
|
155
|
+
stdout=process.stdout,
|
|
156
|
+
stderr=process.stderr,
|
|
157
|
+
stdout_truncated=process.stdout_truncated,
|
|
158
|
+
stderr_truncated=process.stderr_truncated,
|
|
159
|
+
)
|
|
160
|
+
results.append(result)
|
|
161
|
+
if not result.success:
|
|
162
|
+
break
|
|
163
|
+
return FormatterRunResult(results=tuple(results))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def capture_formatted_files(plan: Plan) -> tuple[FormattedFileState, ...]:
|
|
167
|
+
"""Capture exact regular-file states for every approved formatter target."""
|
|
168
|
+
|
|
169
|
+
policy = Policy(plan.workspace)
|
|
170
|
+
maximum = plan.workspace.config.execution.max_single_file_bytes
|
|
171
|
+
return tuple(
|
|
172
|
+
_capture_file(policy, path, maximum=maximum) for path in plan.formatting_targets
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def verify_formatted_files(
|
|
177
|
+
plan: Plan,
|
|
178
|
+
expected: tuple[FormattedFileState, ...],
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Require formatter targets to retain their captured exact final state."""
|
|
181
|
+
|
|
182
|
+
if tuple(item.path for item in expected) != plan.formatting_targets:
|
|
183
|
+
raise ValueError("formatted-file snapshot scope does not match the plan")
|
|
184
|
+
if capture_formatted_files(plan) != expected:
|
|
185
|
+
raise OSError("a formatter target changed after formatting")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _capture_file(
|
|
189
|
+
policy: Policy,
|
|
190
|
+
path: PurePosixPath,
|
|
191
|
+
*,
|
|
192
|
+
maximum: int,
|
|
193
|
+
) -> FormattedFileState:
|
|
194
|
+
target = policy.resolve(path, allow_missing=True)
|
|
195
|
+
if target.kind is not PathKind.FILE:
|
|
196
|
+
raise OSError(f"formatter target is not a regular file: {path}")
|
|
197
|
+
before = target.absolute.lstat()
|
|
198
|
+
if before.st_size > maximum:
|
|
199
|
+
raise OSError(f"formatter output exceeds the configured size limit: {path}")
|
|
200
|
+
raw = target.absolute.read_bytes()
|
|
201
|
+
after_target = policy.resolve(path, allow_missing=True)
|
|
202
|
+
if after_target.kind is not PathKind.FILE:
|
|
203
|
+
raise OSError(f"formatter target is not a regular file: {path}")
|
|
204
|
+
after = after_target.absolute.lstat()
|
|
205
|
+
before_identity = (
|
|
206
|
+
before.st_dev,
|
|
207
|
+
before.st_ino,
|
|
208
|
+
before.st_size,
|
|
209
|
+
before.st_mtime_ns,
|
|
210
|
+
before.st_mode,
|
|
211
|
+
)
|
|
212
|
+
after_identity = (
|
|
213
|
+
after.st_dev,
|
|
214
|
+
after.st_ino,
|
|
215
|
+
after.st_size,
|
|
216
|
+
after.st_mtime_ns,
|
|
217
|
+
after.st_mode,
|
|
218
|
+
)
|
|
219
|
+
if before_identity != after_identity or len(raw) != after.st_size:
|
|
220
|
+
raise OSError(f"formatter target changed while it was captured: {path}")
|
|
221
|
+
return FormattedFileState(
|
|
222
|
+
path=path,
|
|
223
|
+
sha256=hashlib.sha256(raw).hexdigest(),
|
|
224
|
+
size=len(raw),
|
|
225
|
+
mode=stat.S_IMODE(after.st_mode),
|
|
226
|
+
content=raw,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
__all__ = [
|
|
231
|
+
"FormattedFileState",
|
|
232
|
+
"FormatterResult",
|
|
233
|
+
"FormatterRunResult",
|
|
234
|
+
"FormatterStatus",
|
|
235
|
+
"PreparedFormatter",
|
|
236
|
+
"capture_formatted_files",
|
|
237
|
+
"prepare_formatters",
|
|
238
|
+
"run_formatters",
|
|
239
|
+
"verify_formatted_files",
|
|
240
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Canonical project identifiers for PatchShuttle workspaces and jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
from typing import Annotated, TypeAlias
|
|
7
|
+
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
PROJECT_ID_PATTERN = r"^PSH-[0-9A-F]{16}$"
|
|
11
|
+
ProjectId: TypeAlias = Annotated[str, Field(strict=True, pattern=PROJECT_ID_PATTERN)]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def generate_project_id() -> str:
|
|
15
|
+
"""Return a project identifier backed by eight secure random bytes."""
|
|
16
|
+
|
|
17
|
+
return f"PSH-{secrets.token_hex(8).upper()}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ["PROJECT_ID_PATTERN", "ProjectId", "generate_project_id"]
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Bounded workspace inventories and deterministic before/after comparison."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
|
|
12
|
+
from patchshuttle.policy import Policy
|
|
13
|
+
from patchshuttle.workspace import Workspace
|
|
14
|
+
|
|
15
|
+
_HASH_CHUNK_BYTES = 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InventoryEntryKind(str, Enum):
|
|
19
|
+
"""Filesystem kinds recorded without following symbolic links."""
|
|
20
|
+
|
|
21
|
+
FILE = "FILE"
|
|
22
|
+
DIRECTORY = "DIRECTORY"
|
|
23
|
+
SYMLINK = "SYMLINK"
|
|
24
|
+
OTHER = "OTHER"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorkspaceChangeKind(str, Enum):
|
|
28
|
+
"""Stable classifications for a workspace difference."""
|
|
29
|
+
|
|
30
|
+
ADDED = "ADDED"
|
|
31
|
+
REMOVED = "REMOVED"
|
|
32
|
+
MODIFIED = "MODIFIED"
|
|
33
|
+
TYPE_CHANGED = "TYPE_CHANGED"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InventoryErrorCode(str, Enum):
|
|
37
|
+
"""Stable failures for bounded workspace inspection."""
|
|
38
|
+
|
|
39
|
+
ENTRY_LIMIT_EXCEEDED = "ENTRY_LIMIT_EXCEEDED"
|
|
40
|
+
BYTE_LIMIT_EXCEEDED = "BYTE_LIMIT_EXCEEDED"
|
|
41
|
+
INSPECTION_FAILED = "INSPECTION_FAILED"
|
|
42
|
+
FILE_CHANGED_DURING_CAPTURE = "FILE_CHANGED_DURING_CAPTURE"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InventoryError(RuntimeError):
|
|
46
|
+
"""A workspace inventory could not be captured exactly within policy."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
code: InventoryErrorCode,
|
|
51
|
+
message: str,
|
|
52
|
+
*,
|
|
53
|
+
path: PurePosixPath | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.code = code
|
|
56
|
+
self.message = message
|
|
57
|
+
self.path = path
|
|
58
|
+
super().__init__(message)
|
|
59
|
+
|
|
60
|
+
def __str__(self) -> str:
|
|
61
|
+
prefix = f"[{self.code.value}]"
|
|
62
|
+
return (
|
|
63
|
+
f"{prefix} {self.path.as_posix()}: {self.message}"
|
|
64
|
+
if self.path is not None
|
|
65
|
+
else f"{prefix} {self.message}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class InventoryEntry:
|
|
71
|
+
"""Exact metadata and optional content hash for one workspace path."""
|
|
72
|
+
|
|
73
|
+
path: PurePosixPath
|
|
74
|
+
kind: InventoryEntryKind
|
|
75
|
+
size: int
|
|
76
|
+
modified_ns: int
|
|
77
|
+
mode: int
|
|
78
|
+
sha256: str | None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class WorkspaceInventory:
|
|
83
|
+
"""One deterministic bounded snapshot of non-ignored workspace entries."""
|
|
84
|
+
|
|
85
|
+
entries: tuple[InventoryEntry, ...]
|
|
86
|
+
hashed_bytes: int
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class WorkspaceChange:
|
|
91
|
+
"""One classified difference between two workspace inventories."""
|
|
92
|
+
|
|
93
|
+
path: PurePosixPath
|
|
94
|
+
kind: WorkspaceChangeKind
|
|
95
|
+
expected: bool
|
|
96
|
+
before: InventoryEntry | None
|
|
97
|
+
after: InventoryEntry | None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class WorkspaceComparison:
|
|
102
|
+
"""Complete inventory pair and its ordered differences."""
|
|
103
|
+
|
|
104
|
+
before: WorkspaceInventory
|
|
105
|
+
after: WorkspaceInventory
|
|
106
|
+
changes: tuple[WorkspaceChange, ...]
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def unexpected_changes(self) -> tuple[WorkspaceChange, ...]:
|
|
110
|
+
return tuple(change for change in self.changes if not change.expected)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def success(self) -> bool:
|
|
114
|
+
return not self.unexpected_changes
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def capture_inventory(workspace: Workspace) -> WorkspaceInventory:
|
|
118
|
+
"""Hash every non-ignored regular file within configured hard limits."""
|
|
119
|
+
|
|
120
|
+
policy = Policy(workspace)
|
|
121
|
+
maximum_entries = workspace.config.execution.max_inventory_entries
|
|
122
|
+
maximum_bytes = workspace.config.execution.max_inventory_bytes
|
|
123
|
+
entries: list[InventoryEntry] = []
|
|
124
|
+
hashed_bytes = 0
|
|
125
|
+
pending = [(workspace.root, PurePosixPath())]
|
|
126
|
+
|
|
127
|
+
while pending:
|
|
128
|
+
directory, parent = pending.pop()
|
|
129
|
+
children = _scan_directory(directory, parent)
|
|
130
|
+
child_directories: list[tuple[Path, PurePosixPath]] = []
|
|
131
|
+
for child in children:
|
|
132
|
+
relative = parent / child.name
|
|
133
|
+
if policy.is_ignored(relative):
|
|
134
|
+
continue
|
|
135
|
+
if len(entries) >= maximum_entries:
|
|
136
|
+
raise InventoryError(
|
|
137
|
+
InventoryErrorCode.ENTRY_LIMIT_EXCEEDED,
|
|
138
|
+
"workspace inventory entry limit was exceeded",
|
|
139
|
+
path=relative,
|
|
140
|
+
)
|
|
141
|
+
metadata = _entry_metadata(child, relative)
|
|
142
|
+
kind = _entry_kind(metadata.st_mode)
|
|
143
|
+
digest: str | None = None
|
|
144
|
+
size = metadata.st_size
|
|
145
|
+
modified_ns = metadata.st_mtime_ns
|
|
146
|
+
if kind is InventoryEntryKind.FILE:
|
|
147
|
+
if hashed_bytes + size > maximum_bytes:
|
|
148
|
+
raise InventoryError(
|
|
149
|
+
InventoryErrorCode.BYTE_LIMIT_EXCEEDED,
|
|
150
|
+
"workspace inventory byte limit was exceeded",
|
|
151
|
+
path=relative,
|
|
152
|
+
)
|
|
153
|
+
digest = _hash_regular_file(Path(child.path), relative, metadata)
|
|
154
|
+
hashed_bytes += size
|
|
155
|
+
elif kind is InventoryEntryKind.DIRECTORY:
|
|
156
|
+
size = 0
|
|
157
|
+
modified_ns = 0
|
|
158
|
+
child_directories.append((Path(child.path), relative))
|
|
159
|
+
|
|
160
|
+
entries.append(
|
|
161
|
+
InventoryEntry(
|
|
162
|
+
path=relative,
|
|
163
|
+
kind=kind,
|
|
164
|
+
size=size,
|
|
165
|
+
modified_ns=modified_ns,
|
|
166
|
+
mode=stat.S_IMODE(metadata.st_mode),
|
|
167
|
+
sha256=digest,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
pending.extend(reversed(child_directories))
|
|
171
|
+
|
|
172
|
+
entries.sort(key=lambda entry: entry.path.as_posix())
|
|
173
|
+
return WorkspaceInventory(entries=tuple(entries), hashed_bytes=hashed_bytes)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def compare_inventories(
|
|
177
|
+
before: WorkspaceInventory,
|
|
178
|
+
after: WorkspaceInventory,
|
|
179
|
+
*,
|
|
180
|
+
expected_paths: tuple[PurePosixPath, ...] = (),
|
|
181
|
+
) -> WorkspaceComparison:
|
|
182
|
+
"""Classify every before/after difference and mark declared paths."""
|
|
183
|
+
|
|
184
|
+
before_by_path = {entry.path: entry for entry in before.entries}
|
|
185
|
+
after_by_path = {entry.path: entry for entry in after.entries}
|
|
186
|
+
expected = frozenset(expected_paths)
|
|
187
|
+
changes: list[WorkspaceChange] = []
|
|
188
|
+
paths = sorted(
|
|
189
|
+
before_by_path.keys() | after_by_path.keys(),
|
|
190
|
+
key=PurePosixPath.as_posix,
|
|
191
|
+
)
|
|
192
|
+
for path in paths:
|
|
193
|
+
earlier = before_by_path.get(path)
|
|
194
|
+
later = after_by_path.get(path)
|
|
195
|
+
if earlier is None:
|
|
196
|
+
kind = WorkspaceChangeKind.ADDED
|
|
197
|
+
elif later is None:
|
|
198
|
+
kind = WorkspaceChangeKind.REMOVED
|
|
199
|
+
elif earlier.kind is not later.kind:
|
|
200
|
+
kind = WorkspaceChangeKind.TYPE_CHANGED
|
|
201
|
+
elif earlier != later:
|
|
202
|
+
kind = WorkspaceChangeKind.MODIFIED
|
|
203
|
+
else:
|
|
204
|
+
continue
|
|
205
|
+
changes.append(
|
|
206
|
+
WorkspaceChange(
|
|
207
|
+
path=path,
|
|
208
|
+
kind=kind,
|
|
209
|
+
expected=path in expected,
|
|
210
|
+
before=earlier,
|
|
211
|
+
after=later,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return WorkspaceComparison(
|
|
216
|
+
before=before,
|
|
217
|
+
after=after,
|
|
218
|
+
changes=tuple(changes),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _scan_directory(
|
|
223
|
+
directory: Path,
|
|
224
|
+
relative: PurePosixPath,
|
|
225
|
+
) -> tuple[os.DirEntry[str], ...]:
|
|
226
|
+
try:
|
|
227
|
+
with os.scandir(directory) as iterator:
|
|
228
|
+
return tuple(sorted(iterator, key=lambda entry: entry.name))
|
|
229
|
+
except OSError as exc:
|
|
230
|
+
raise InventoryError(
|
|
231
|
+
InventoryErrorCode.INSPECTION_FAILED,
|
|
232
|
+
"workspace directory could not be inspected",
|
|
233
|
+
path=relative if relative.parts else None,
|
|
234
|
+
) from exc
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _entry_metadata(
|
|
238
|
+
entry: os.DirEntry[str],
|
|
239
|
+
relative: PurePosixPath,
|
|
240
|
+
) -> os.stat_result:
|
|
241
|
+
try:
|
|
242
|
+
return entry.stat(follow_symlinks=False)
|
|
243
|
+
except OSError as exc:
|
|
244
|
+
raise InventoryError(
|
|
245
|
+
InventoryErrorCode.INSPECTION_FAILED,
|
|
246
|
+
"workspace entry metadata could not be inspected",
|
|
247
|
+
path=relative,
|
|
248
|
+
) from exc
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _entry_kind(mode: int) -> InventoryEntryKind:
|
|
252
|
+
if stat.S_ISREG(mode):
|
|
253
|
+
return InventoryEntryKind.FILE
|
|
254
|
+
if stat.S_ISDIR(mode):
|
|
255
|
+
return InventoryEntryKind.DIRECTORY
|
|
256
|
+
if stat.S_ISLNK(mode):
|
|
257
|
+
return InventoryEntryKind.SYMLINK
|
|
258
|
+
return InventoryEntryKind.OTHER
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _hash_regular_file(
|
|
262
|
+
path: Path,
|
|
263
|
+
relative: PurePosixPath,
|
|
264
|
+
expected: os.stat_result,
|
|
265
|
+
) -> str:
|
|
266
|
+
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
|
|
267
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
268
|
+
try:
|
|
269
|
+
descriptor = os.open(path, flags)
|
|
270
|
+
except OSError as exc:
|
|
271
|
+
raise InventoryError(
|
|
272
|
+
InventoryErrorCode.INSPECTION_FAILED,
|
|
273
|
+
"workspace file could not be opened for hashing",
|
|
274
|
+
path=relative,
|
|
275
|
+
) from exc
|
|
276
|
+
|
|
277
|
+
digest = hashlib.sha256()
|
|
278
|
+
try:
|
|
279
|
+
opened = os.fstat(descriptor)
|
|
280
|
+
if not _same_file_state(expected, opened):
|
|
281
|
+
raise InventoryError(
|
|
282
|
+
InventoryErrorCode.FILE_CHANGED_DURING_CAPTURE,
|
|
283
|
+
"workspace file changed while inventory was captured",
|
|
284
|
+
path=relative,
|
|
285
|
+
)
|
|
286
|
+
while chunk := os.read(descriptor, _HASH_CHUNK_BYTES):
|
|
287
|
+
digest.update(chunk)
|
|
288
|
+
completed = os.fstat(descriptor)
|
|
289
|
+
if not _same_file_state(opened, completed):
|
|
290
|
+
raise InventoryError(
|
|
291
|
+
InventoryErrorCode.FILE_CHANGED_DURING_CAPTURE,
|
|
292
|
+
"workspace file changed while inventory was captured",
|
|
293
|
+
path=relative,
|
|
294
|
+
)
|
|
295
|
+
except OSError as exc:
|
|
296
|
+
raise InventoryError(
|
|
297
|
+
InventoryErrorCode.INSPECTION_FAILED,
|
|
298
|
+
"workspace file could not be hashed",
|
|
299
|
+
path=relative,
|
|
300
|
+
) from exc
|
|
301
|
+
finally:
|
|
302
|
+
os.close(descriptor)
|
|
303
|
+
return digest.hexdigest()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _same_file_state(left: os.stat_result, right: os.stat_result) -> bool:
|
|
307
|
+
identity_available = left.st_ino != 0 and right.st_ino != 0
|
|
308
|
+
return (
|
|
309
|
+
stat.S_ISREG(right.st_mode)
|
|
310
|
+
and (
|
|
311
|
+
not identity_available
|
|
312
|
+
or (left.st_dev == right.st_dev and left.st_ino == right.st_ino)
|
|
313
|
+
)
|
|
314
|
+
and left.st_size == right.st_size
|
|
315
|
+
and left.st_mtime_ns == right.st_mtime_ns
|
|
316
|
+
and stat.S_IMODE(left.st_mode) == stat.S_IMODE(right.st_mode)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
__all__ = [
|
|
321
|
+
"InventoryEntry",
|
|
322
|
+
"InventoryEntryKind",
|
|
323
|
+
"InventoryError",
|
|
324
|
+
"InventoryErrorCode",
|
|
325
|
+
"WorkspaceChange",
|
|
326
|
+
"WorkspaceChangeKind",
|
|
327
|
+
"WorkspaceComparison",
|
|
328
|
+
"WorkspaceInventory",
|
|
329
|
+
"capture_inventory",
|
|
330
|
+
"compare_inventories",
|
|
331
|
+
]
|