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/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