astoncli 0.1.0__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.
- aston/__init__.py +0 -0
- aston/axios.py +525 -0
- aston/config.py +88 -0
- aston/docker.py +402 -0
- aston/exceptions.py +49 -0
- aston/ingestion.py +1105 -0
- aston/llm.py +182 -0
- aston/pipeline.py +749 -0
- aston/poller.py +201 -0
- aston/relevance.py +175 -0
- aston/repo_verification.py +243 -0
- aston/rule_validation.py +437 -0
- aston/scanner.py +211 -0
- aston/watcher.py +66 -0
- astoncli-0.1.0.dist-info/METADATA +883 -0
- astoncli-0.1.0.dist-info/RECORD +19 -0
- astoncli-0.1.0.dist-info/WHEEL +5 -0
- astoncli-0.1.0.dist-info/entry_points.txt +2 -0
- astoncli-0.1.0.dist-info/top_level.txt +1 -0
aston/docker.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VerificationResult(str, Enum):
|
|
14
|
+
PASS = "PASS"
|
|
15
|
+
MIGRATION_ERROR = "MIGRATION_ERROR"
|
|
16
|
+
ENVIRONMENT_ERROR = "ENVIRONMENT_ERROR"
|
|
17
|
+
EXTERNAL_SERVICE_ERROR = "EXTERNAL_SERVICE_ERROR"
|
|
18
|
+
TIMEOUT = "TIMEOUT"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class VerificationOutput:
|
|
23
|
+
status: VerificationResult
|
|
24
|
+
target_file: str
|
|
25
|
+
error_type: Optional[str] = None
|
|
26
|
+
error_message: Optional[str] = None
|
|
27
|
+
raw_stderr: str = ""
|
|
28
|
+
raw_stdout: str = ""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def stderr(self) -> str:
|
|
32
|
+
return self.raw_stderr
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def stdout(self) -> str:
|
|
36
|
+
return self.raw_stdout
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ignore_repo_files(directory: str, names: list[str]) -> set[str]:
|
|
40
|
+
"""Exclude large or machine-specific directories from the Docker context."""
|
|
41
|
+
excluded = {
|
|
42
|
+
".git",
|
|
43
|
+
".venv",
|
|
44
|
+
"venv",
|
|
45
|
+
"__pycache__",
|
|
46
|
+
".pytest_cache",
|
|
47
|
+
".mypy_cache",
|
|
48
|
+
".ruff_cache",
|
|
49
|
+
"node_modules",
|
|
50
|
+
}
|
|
51
|
+
return {name for name in names if name in excluded}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _classify_runtime_failure(stderr: str) -> tuple[VerificationResult, str]:
|
|
55
|
+
lower = stderr.lower()
|
|
56
|
+
|
|
57
|
+
external_markers = (
|
|
58
|
+
"connectionerror",
|
|
59
|
+
"connecttimeout",
|
|
60
|
+
"readtimeout",
|
|
61
|
+
"name resolution",
|
|
62
|
+
"failed to establish a new connection",
|
|
63
|
+
"api connection error",
|
|
64
|
+
"service unavailable",
|
|
65
|
+
"rate limit",
|
|
66
|
+
)
|
|
67
|
+
if any(marker in lower for marker in external_markers):
|
|
68
|
+
return VerificationResult.EXTERNAL_SERVICE_ERROR, "ExternalServiceError"
|
|
69
|
+
|
|
70
|
+
if "modulenotfounderror" in lower or "importerror" in lower:
|
|
71
|
+
return VerificationResult.MIGRATION_ERROR, "ImportError"
|
|
72
|
+
|
|
73
|
+
if "syntaxerror" in lower:
|
|
74
|
+
return VerificationResult.MIGRATION_ERROR, "SyntaxError"
|
|
75
|
+
|
|
76
|
+
if "attributeerror" in lower:
|
|
77
|
+
return VerificationResult.MIGRATION_ERROR, "AttributeError"
|
|
78
|
+
|
|
79
|
+
if "typeerror" in lower:
|
|
80
|
+
return VerificationResult.MIGRATION_ERROR, "TypeError"
|
|
81
|
+
|
|
82
|
+
if "assertionerror" in lower:
|
|
83
|
+
return VerificationResult.MIGRATION_ERROR, "AssertionError"
|
|
84
|
+
|
|
85
|
+
return VerificationResult.MIGRATION_ERROR, "RuntimeError"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def verify_single_file_in_docker(
|
|
89
|
+
target_file: str | Path = "before.py",
|
|
90
|
+
req_file: str | Path = "temp_requirements.txt",
|
|
91
|
+
repo_root: str | Path | None = None,
|
|
92
|
+
timeout: int = 300,
|
|
93
|
+
build_timeout: int = 600,
|
|
94
|
+
python_image: str = "python:3.11-slim",
|
|
95
|
+
) -> VerificationOutput:
|
|
96
|
+
"""
|
|
97
|
+
Verify one migrated Python file inside Docker.
|
|
98
|
+
|
|
99
|
+
The full repository is copied into an isolated temporary build context so
|
|
100
|
+
imports between local Python files can resolve.
|
|
101
|
+
|
|
102
|
+
Existing usage remains valid:
|
|
103
|
+
verify_single_file_in_docker(path, temp_req_path)
|
|
104
|
+
|
|
105
|
+
Recommended for multi-file migrations:
|
|
106
|
+
verify_single_file_in_docker(
|
|
107
|
+
path,
|
|
108
|
+
temp_req_path,
|
|
109
|
+
repo_root=root_path,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
This checks execution of the target file. It does not replace full-repo
|
|
113
|
+
tests or guarantee that all application behavior is correct.
|
|
114
|
+
"""
|
|
115
|
+
started = time.monotonic()
|
|
116
|
+
|
|
117
|
+
target_input = Path(target_file).expanduser()
|
|
118
|
+
requirements_input = Path(req_file).expanduser()
|
|
119
|
+
|
|
120
|
+
if repo_root is not None:
|
|
121
|
+
root = Path(repo_root).expanduser().resolve()
|
|
122
|
+
elif not target_input.is_absolute():
|
|
123
|
+
root = Path.cwd().resolve()
|
|
124
|
+
else:
|
|
125
|
+
# For nested absolute paths, pass repo_root explicitly so sibling
|
|
126
|
+
# modules and package imports remain available.
|
|
127
|
+
root = target_input.resolve().parent
|
|
128
|
+
|
|
129
|
+
target_abs = (
|
|
130
|
+
target_input.resolve()
|
|
131
|
+
if target_input.is_absolute()
|
|
132
|
+
else (root / target_input).resolve()
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
target_rel = target_abs.relative_to(root)
|
|
137
|
+
except ValueError:
|
|
138
|
+
return VerificationOutput(
|
|
139
|
+
status=VerificationResult.ENVIRONMENT_ERROR,
|
|
140
|
+
target_file=str(target_abs),
|
|
141
|
+
error_type="PathError",
|
|
142
|
+
error_message=(
|
|
143
|
+
f"Target file {target_abs} is outside repository root {root}. "
|
|
144
|
+
"Pass the correct repo_root."
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if not root.is_dir():
|
|
149
|
+
return VerificationOutput(
|
|
150
|
+
status=VerificationResult.ENVIRONMENT_ERROR,
|
|
151
|
+
target_file=str(target_abs),
|
|
152
|
+
error_type="RepositoryNotFound",
|
|
153
|
+
error_message=f"Repository root does not exist: {root}",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if not target_abs.is_file():
|
|
157
|
+
return VerificationOutput(
|
|
158
|
+
status=VerificationResult.ENVIRONMENT_ERROR,
|
|
159
|
+
target_file=str(target_abs),
|
|
160
|
+
error_type="TargetFileNotFound",
|
|
161
|
+
error_message=f"Target file does not exist: {target_abs}",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if not requirements_input.is_absolute():
|
|
165
|
+
requirements_input = (Path.cwd() / requirements_input).resolve()
|
|
166
|
+
else:
|
|
167
|
+
requirements_input = requirements_input.resolve()
|
|
168
|
+
|
|
169
|
+
if not requirements_input.is_file():
|
|
170
|
+
return VerificationOutput(
|
|
171
|
+
status=VerificationResult.ENVIRONMENT_ERROR,
|
|
172
|
+
target_file=str(target_abs),
|
|
173
|
+
error_type="RequirementsNotFound",
|
|
174
|
+
error_message=f"Requirements file does not exist: {requirements_input}",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
docker = shutil.which("docker")
|
|
178
|
+
if docker is None:
|
|
179
|
+
return VerificationOutput(
|
|
180
|
+
status=VerificationResult.ENVIRONMENT_ERROR,
|
|
181
|
+
target_file=str(target_abs),
|
|
182
|
+
error_type="DockerNotFound",
|
|
183
|
+
error_message="Docker CLI was not found on PATH.",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
daemon_check = subprocess.run(
|
|
188
|
+
[docker, "info"],
|
|
189
|
+
capture_output=True,
|
|
190
|
+
text=True,
|
|
191
|
+
timeout=20,
|
|
192
|
+
check=False,
|
|
193
|
+
)
|
|
194
|
+
except subprocess.TimeoutExpired:
|
|
195
|
+
return VerificationOutput(
|
|
196
|
+
status=VerificationResult.ENVIRONMENT_ERROR,
|
|
197
|
+
target_file=str(target_abs),
|
|
198
|
+
error_type="DockerUnavailable",
|
|
199
|
+
error_message="Timed out checking whether the Docker daemon is available.",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if daemon_check.returncode != 0:
|
|
203
|
+
return VerificationOutput(
|
|
204
|
+
status=VerificationResult.ENVIRONMENT_ERROR,
|
|
205
|
+
target_file=str(target_abs),
|
|
206
|
+
error_type="DockerUnavailable",
|
|
207
|
+
error_message=(
|
|
208
|
+
daemon_check.stderr.strip()
|
|
209
|
+
or daemon_check.stdout.strip()
|
|
210
|
+
or "Docker daemon is unavailable."
|
|
211
|
+
),
|
|
212
|
+
raw_stderr=daemon_check.stderr,
|
|
213
|
+
raw_stdout=daemon_check.stdout,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Unique temporary context prevents concurrent verifier runs from
|
|
217
|
+
# overwriting each other's Dockerfiles or runner scripts.
|
|
218
|
+
with tempfile.TemporaryDirectory(prefix="aston_verify_") as temp_dir:
|
|
219
|
+
context = Path(temp_dir)
|
|
220
|
+
repo_copy = context / "repo"
|
|
221
|
+
repo_copy.mkdir()
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
shutil.copytree(
|
|
225
|
+
root,
|
|
226
|
+
repo_copy,
|
|
227
|
+
dirs_exist_ok=True,
|
|
228
|
+
ignore=_ignore_repo_files,
|
|
229
|
+
)
|
|
230
|
+
except OSError as exc:
|
|
231
|
+
return VerificationOutput(
|
|
232
|
+
status=VerificationResult.ENVIRONMENT_ERROR,
|
|
233
|
+
target_file=str(target_abs),
|
|
234
|
+
error_type="RepositoryCopyError",
|
|
235
|
+
error_message=str(exc),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
copied_target = repo_copy / target_rel
|
|
239
|
+
if not copied_target.is_file():
|
|
240
|
+
return VerificationOutput(
|
|
241
|
+
status=VerificationResult.ENVIRONMENT_ERROR,
|
|
242
|
+
target_file=str(target_abs),
|
|
243
|
+
error_type="TargetCopyError",
|
|
244
|
+
error_message=f"Target file was not copied into Docker context: {target_rel}",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Use the supplied target-version requirements, not the repository's
|
|
248
|
+
# original requirements file.
|
|
249
|
+
shutil.copy2(requirements_input, context / "requirements.txt")
|
|
250
|
+
|
|
251
|
+
runner = r'''
|
|
252
|
+
import runpy
|
|
253
|
+
import sys
|
|
254
|
+
import traceback
|
|
255
|
+
from pathlib import Path
|
|
256
|
+
|
|
257
|
+
target = Path(sys.argv[1]).resolve()
|
|
258
|
+
repo_root = Path("/app/repo").resolve()
|
|
259
|
+
|
|
260
|
+
# Make repository-root imports and local package imports available.
|
|
261
|
+
sys.path.insert(0, str(repo_root))
|
|
262
|
+
sys.path.insert(0, str(target.parent))
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
runpy.run_path(str(target), run_name="__main__")
|
|
266
|
+
except SystemExit as exc:
|
|
267
|
+
# Treat sys.exit(0) as success; non-zero exits as failure.
|
|
268
|
+
code = exc.code
|
|
269
|
+
if code not in (None, 0):
|
|
270
|
+
traceback.print_exc()
|
|
271
|
+
raise
|
|
272
|
+
except BaseException:
|
|
273
|
+
traceback.print_exc()
|
|
274
|
+
raise
|
|
275
|
+
'''
|
|
276
|
+
(context / "run_target.py").write_text(runner, encoding="utf-8")
|
|
277
|
+
|
|
278
|
+
dockerfile = f"""
|
|
279
|
+
FROM {python_image}
|
|
280
|
+
WORKDIR /app
|
|
281
|
+
|
|
282
|
+
ENV PYTHONDONTWRITEBYTECODE=1
|
|
283
|
+
ENV PYTHONUNBUFFERED=1
|
|
284
|
+
ENV OPENAI_API_KEY=sk-test-aston-verification
|
|
285
|
+
|
|
286
|
+
COPY requirements.txt /app/requirements.txt
|
|
287
|
+
RUN python -m pip install --no-cache-dir --default-timeout=120 --retries 3 -r /app/requirements.txt
|
|
288
|
+
|
|
289
|
+
COPY repo/ /app/repo/
|
|
290
|
+
COPY run_target.py /app/run_target.py
|
|
291
|
+
|
|
292
|
+
CMD ["python", "/app/run_target.py", "/app/repo/{target_rel.as_posix()}"]
|
|
293
|
+
"""
|
|
294
|
+
(context / "Dockerfile").write_text(dockerfile, encoding="utf-8")
|
|
295
|
+
|
|
296
|
+
# Unique image tag prevents concurrent invocations from sharing a tag.
|
|
297
|
+
import uuid
|
|
298
|
+
image_tag = f"aston-verify-{uuid.uuid4().hex[:12]}"
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
build = subprocess.run(
|
|
302
|
+
[
|
|
303
|
+
docker,
|
|
304
|
+
"build",
|
|
305
|
+
"--tag",
|
|
306
|
+
image_tag,
|
|
307
|
+
"--file",
|
|
308
|
+
str(context / "Dockerfile"),
|
|
309
|
+
str(context),
|
|
310
|
+
],
|
|
311
|
+
capture_output=True,
|
|
312
|
+
text=True,
|
|
313
|
+
timeout=build_timeout,
|
|
314
|
+
check=False,
|
|
315
|
+
)
|
|
316
|
+
except subprocess.TimeoutExpired as exc:
|
|
317
|
+
return VerificationOutput(
|
|
318
|
+
status=VerificationResult.TIMEOUT,
|
|
319
|
+
target_file=str(target_abs),
|
|
320
|
+
error_type="DockerBuildTimeout",
|
|
321
|
+
error_message=f"Docker image build exceeded {build_timeout} seconds.",
|
|
322
|
+
raw_stdout=exc.stdout or "",
|
|
323
|
+
raw_stderr=exc.stderr or "",
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if build.returncode != 0:
|
|
327
|
+
return VerificationOutput(
|
|
328
|
+
status=VerificationResult.ENVIRONMENT_ERROR,
|
|
329
|
+
target_file=str(target_abs),
|
|
330
|
+
error_type="DockerBuildError",
|
|
331
|
+
error_message=(
|
|
332
|
+
build.stderr.strip()
|
|
333
|
+
or build.stdout.strip()
|
|
334
|
+
or "Docker image build failed."
|
|
335
|
+
),
|
|
336
|
+
raw_stderr=build.stderr,
|
|
337
|
+
raw_stdout=build.stdout,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
run = subprocess.run(
|
|
342
|
+
[
|
|
343
|
+
docker,
|
|
344
|
+
"run",
|
|
345
|
+
"--rm",
|
|
346
|
+
"--network",
|
|
347
|
+
"none",
|
|
348
|
+
image_tag,
|
|
349
|
+
],
|
|
350
|
+
capture_output=True,
|
|
351
|
+
text=True,
|
|
352
|
+
timeout=timeout,
|
|
353
|
+
check=False,
|
|
354
|
+
)
|
|
355
|
+
except subprocess.TimeoutExpired as exc:
|
|
356
|
+
return VerificationOutput(
|
|
357
|
+
status=VerificationResult.TIMEOUT,
|
|
358
|
+
target_file=str(target_abs),
|
|
359
|
+
error_type="ExecutionTimeout",
|
|
360
|
+
error_message=f"Target execution exceeded {timeout} seconds.",
|
|
361
|
+
raw_stdout=exc.stdout or "",
|
|
362
|
+
raw_stderr=exc.stderr or "",
|
|
363
|
+
)
|
|
364
|
+
finally:
|
|
365
|
+
# Cleanup must never override the verification result.
|
|
366
|
+
try:
|
|
367
|
+
subprocess.run(
|
|
368
|
+
[docker, "image", "rm", "-f", image_tag],
|
|
369
|
+
capture_output=True,
|
|
370
|
+
text=True,
|
|
371
|
+
timeout=30,
|
|
372
|
+
check=False,
|
|
373
|
+
)
|
|
374
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
elapsed = time.monotonic() - started
|
|
378
|
+
|
|
379
|
+
if run.returncode == 0:
|
|
380
|
+
return VerificationOutput(
|
|
381
|
+
status=VerificationResult.PASS,
|
|
382
|
+
target_file=str(target_abs),
|
|
383
|
+
raw_stdout=(
|
|
384
|
+
f"Target executed successfully in {elapsed:.1f}s.\n"
|
|
385
|
+
+ run.stdout
|
|
386
|
+
),
|
|
387
|
+
raw_stderr=run.stderr,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
status, error_type = _classify_runtime_failure(run.stderr)
|
|
391
|
+
return VerificationOutput(
|
|
392
|
+
status=status,
|
|
393
|
+
target_file=str(target_abs),
|
|
394
|
+
error_type=error_type,
|
|
395
|
+
error_message=(
|
|
396
|
+
run.stderr.strip()
|
|
397
|
+
or run.stdout.strip()
|
|
398
|
+
or f"Target exited with code {run.returncode}."
|
|
399
|
+
),
|
|
400
|
+
raw_stderr=run.stderr,
|
|
401
|
+
raw_stdout=run.stdout,
|
|
402
|
+
)
|
aston/exceptions.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Centralized exception hierarchy for ASTon.
|
|
2
|
+
|
|
3
|
+
Maps every pipeline failure category to a distinct exception type
|
|
4
|
+
and standardized exit code for predictable CLI behavior.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
class AstonError(Exception):
|
|
8
|
+
"""Base exception for all ASTon pipeline errors."""
|
|
9
|
+
exit_code = 1
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, details: str | None = None):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.message = message
|
|
14
|
+
self.details = details
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigurationError(AstonError):
|
|
18
|
+
"""Configuration or environment setup failures (e.g., missing API keys)."""
|
|
19
|
+
exit_code = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IngestionError(AstonError):
|
|
23
|
+
"""Repository discovery, documentation ingestion, or changelog fetching failures."""
|
|
24
|
+
exit_code = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LLMError(AstonError):
|
|
28
|
+
"""Mistral API rate limits, connection timeouts, or invalid parser responses."""
|
|
29
|
+
exit_code = 4
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ScanningError(AstonError):
|
|
33
|
+
"""AST parsing, symbol indexing, or syntax inspection failures."""
|
|
34
|
+
exit_code = 5
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MigrationError(AstonError):
|
|
38
|
+
"""Code patching, rule application, or repair loop failures."""
|
|
39
|
+
exit_code = 6
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class VerificationError(AstonError):
|
|
43
|
+
"""Docker container execution, test suite failures, or verification errors."""
|
|
44
|
+
exit_code = 7
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class GitHubError(AstonError):
|
|
48
|
+
"""Git workspace, remote resolution, authentication, or PR creation failures."""
|
|
49
|
+
exit_code = 8
|