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/poller.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from packaging.requirements import Requirement
|
|
4
|
+
from packaging.version import InvalidVersion, Version
|
|
5
|
+
from aston.ingestion import get_latest_pypi_version
|
|
6
|
+
from aston.cli.console import info, error, warning, rich_success
|
|
7
|
+
from aston.exceptions import ConfigurationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
MAX_WORKERS = 10
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_major_version_change(
|
|
14
|
+
current_version: str,
|
|
15
|
+
target_version: str,
|
|
16
|
+
) -> bool:
|
|
17
|
+
"""Return whether the target crosses a major-version boundary."""
|
|
18
|
+
try:
|
|
19
|
+
current = Version(current_version)
|
|
20
|
+
target = Version(target_version)
|
|
21
|
+
|
|
22
|
+
# For pre-1.0 packages, minor releases can introduce breaking changes.
|
|
23
|
+
if current.major == 0 and target.major == 0:
|
|
24
|
+
return target.minor > current.minor
|
|
25
|
+
|
|
26
|
+
return target.major > current.major
|
|
27
|
+
|
|
28
|
+
except InvalidVersion:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _check_package(
|
|
33
|
+
line: str,
|
|
34
|
+
) -> tuple[str, str | None, str | None, str | None] | None:
|
|
35
|
+
"""Fetch the latest PyPI version for one requirement."""
|
|
36
|
+
line = line.strip()
|
|
37
|
+
|
|
38
|
+
if not line or line.startswith("#") or line.startswith("-"):
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
req = Requirement(line)
|
|
43
|
+
package_name = req.name
|
|
44
|
+
|
|
45
|
+
# Exact pins are the most reliable declared-version baseline.
|
|
46
|
+
# For ranges, choose a lower bound when available.
|
|
47
|
+
declared_version = None
|
|
48
|
+
|
|
49
|
+
for spec in req.specifier:
|
|
50
|
+
if spec.operator == "==":
|
|
51
|
+
declared_version = spec.version
|
|
52
|
+
break
|
|
53
|
+
|
|
54
|
+
if declared_version is None:
|
|
55
|
+
lower_bounds = [
|
|
56
|
+
spec.version
|
|
57
|
+
for spec in req.specifier
|
|
58
|
+
if spec.operator in (">=", "~=")
|
|
59
|
+
]
|
|
60
|
+
if lower_bounds:
|
|
61
|
+
declared_version = lower_bounds[0]
|
|
62
|
+
|
|
63
|
+
latest_version = get_latest_pypi_version(package_name)
|
|
64
|
+
|
|
65
|
+
if latest_version is None:
|
|
66
|
+
return package_name, declared_version, None, "PyPI lookup failed"
|
|
67
|
+
|
|
68
|
+
# Validate the version returned by the registry.
|
|
69
|
+
Version(latest_version)
|
|
70
|
+
|
|
71
|
+
return package_name, declared_version, latest_version, None
|
|
72
|
+
|
|
73
|
+
except (InvalidVersion, ValueError) as exc:
|
|
74
|
+
return line, None, None, f"Invalid requirement/version: {exc}"
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
return line, None, None, str(exc)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def watcher(repo_path: str | Path) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Check requirements against PyPI and report potential major-version changes.
|
|
82
|
+
|
|
83
|
+
This is a one-time check, not a continuously running polling loop.
|
|
84
|
+
"""
|
|
85
|
+
repo_dir = Path(repo_path).expanduser().resolve()
|
|
86
|
+
req_file = repo_dir / "requirements.txt"
|
|
87
|
+
|
|
88
|
+
if not req_file.is_file():
|
|
89
|
+
raise ConfigurationError(
|
|
90
|
+
message=f"No requirements.txt found in repository path: {repo_dir}",
|
|
91
|
+
details="Ensure the target directory contains a valid requirements.txt file before running the watcher."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
lines = req_file.read_text(encoding="utf-8").splitlines()
|
|
96
|
+
except OSError as exc:
|
|
97
|
+
raise ConfigurationError(
|
|
98
|
+
message=f"Could not read requirements file: {req_file}",
|
|
99
|
+
details=str(exc)
|
|
100
|
+
) from exc
|
|
101
|
+
|
|
102
|
+
# Ignore blank lines, comments, and pip directives.
|
|
103
|
+
requirement_lines = [
|
|
104
|
+
line for line in lines
|
|
105
|
+
if line.strip() and not line.strip().startswith(("#", "-"))
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
if not requirement_lines:
|
|
109
|
+
warning("No package requirements found.")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
info(
|
|
113
|
+
f"Checking {len(requirement_lines)} requirement lines "
|
|
114
|
+
f"for {repo_dir}..."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
results = []
|
|
118
|
+
failures = []
|
|
119
|
+
|
|
120
|
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
|
121
|
+
futures = {
|
|
122
|
+
executor.submit(_check_package, line): line
|
|
123
|
+
for line in requirement_lines
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
total = len(futures)
|
|
127
|
+
|
|
128
|
+
for completed, future in enumerate(as_completed(futures), start=1):
|
|
129
|
+
try:
|
|
130
|
+
result = future.result()
|
|
131
|
+
if result:
|
|
132
|
+
package, declared, latest, error_message = result
|
|
133
|
+
|
|
134
|
+
if error_message:
|
|
135
|
+
failures.append((package, error_message))
|
|
136
|
+
else:
|
|
137
|
+
results.append((package, declared, latest))
|
|
138
|
+
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
failures.append((futures[future], str(exc)))
|
|
141
|
+
|
|
142
|
+
info(f"Progress: {completed}/{total} requirement lines")
|
|
143
|
+
|
|
144
|
+
info("")
|
|
145
|
+
|
|
146
|
+
breaking_changes = []
|
|
147
|
+
unpinned = []
|
|
148
|
+
|
|
149
|
+
for package, declared, latest in results:
|
|
150
|
+
if not declared:
|
|
151
|
+
unpinned.append(package)
|
|
152
|
+
info(
|
|
153
|
+
f"{package}: latest version is {latest}; "
|
|
154
|
+
"no exact declared version is available for comparison."
|
|
155
|
+
)
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
current = Version(declared)
|
|
160
|
+
target = Version(latest)
|
|
161
|
+
except InvalidVersion:
|
|
162
|
+
failures.append((package, "Could not compare versions"))
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
if target <= current:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
if is_major_version_change(str(current), str(target)):
|
|
169
|
+
breaking_changes.append((package, str(current), str(target)))
|
|
170
|
+
warning(
|
|
171
|
+
f"Potential breaking upgrade: {package} "
|
|
172
|
+
f"{current} -> {target}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if failures:
|
|
176
|
+
error(f"{len(failures)} package lookup(s) failed:")
|
|
177
|
+
for package, error_message in failures:
|
|
178
|
+
error(f"- {package}: {error_message}")
|
|
179
|
+
|
|
180
|
+
if breaking_changes:
|
|
181
|
+
warning(
|
|
182
|
+
"Potential major-version changes found. "
|
|
183
|
+
"A major version change does not guarantee a breaking change."
|
|
184
|
+
)
|
|
185
|
+
info(
|
|
186
|
+
"Review the migration before running: "
|
|
187
|
+
f'aston check --run --path "{repo_dir}"'
|
|
188
|
+
)
|
|
189
|
+
elif failures:
|
|
190
|
+
warning(
|
|
191
|
+
"Check incomplete: some packages could not be checked. "
|
|
192
|
+
"Do not treat this result as confirmation of compatibility."
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
rich_success("No newer major-version changes detected.")
|
|
196
|
+
|
|
197
|
+
if unpinned:
|
|
198
|
+
warning(
|
|
199
|
+
f"{len(unpinned)} package(s) lack a usable version "
|
|
200
|
+
"baseline; their compatibility could not be established."
|
|
201
|
+
)
|
aston/relevance.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
def normalize_api_symbol(symbol: str) -> str:
|
|
4
|
+
"""
|
|
5
|
+
Normalize formatting without changing Python identifier casing.
|
|
6
|
+
"""
|
|
7
|
+
if not isinstance(symbol, str):
|
|
8
|
+
return ""
|
|
9
|
+
|
|
10
|
+
symbol = symbol.strip()
|
|
11
|
+
|
|
12
|
+
if symbol.endswith("()"):
|
|
13
|
+
symbol = symbol[:-2]
|
|
14
|
+
|
|
15
|
+
return symbol
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def canonicalize_usage(record: dict) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Convert a locally aliased API usage to its fully qualified form,
|
|
21
|
+
correctly resolving from-imports using the explicit 'imported' field.
|
|
22
|
+
"""
|
|
23
|
+
package = record.get("package")
|
|
24
|
+
alias = record.get("local_alias")
|
|
25
|
+
imported = record.get("imported")
|
|
26
|
+
usage = record.get("api_usage", "")
|
|
27
|
+
|
|
28
|
+
if not isinstance(usage, str):
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
if alias and usage.startswith(alias + "."):
|
|
32
|
+
if imported:
|
|
33
|
+
return imported + usage[len(alias):]
|
|
34
|
+
elif package:
|
|
35
|
+
return package + usage[len(alias):]
|
|
36
|
+
|
|
37
|
+
return usage
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def determine_expected_affected_symbols(
|
|
41
|
+
stage0_symbols: set[str],
|
|
42
|
+
migration_doc_text: str,
|
|
43
|
+
) -> set[str]:
|
|
44
|
+
"""
|
|
45
|
+
Find scanned symbols explicitly referenced in the migration text.
|
|
46
|
+
"""
|
|
47
|
+
expected = set()
|
|
48
|
+
doc_lower = (migration_doc_text or "").lower()
|
|
49
|
+
|
|
50
|
+
for symbol in stage0_symbols:
|
|
51
|
+
normalized = normalize_api_symbol(symbol)
|
|
52
|
+
if not normalized:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
parts = normalized.split(".")
|
|
56
|
+
short_name = ".".join(parts[-2:])
|
|
57
|
+
|
|
58
|
+
if (
|
|
59
|
+
normalized.lower() in doc_lower
|
|
60
|
+
or short_name.lower() in doc_lower
|
|
61
|
+
):
|
|
62
|
+
expected.add(normalized)
|
|
63
|
+
|
|
64
|
+
return expected
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def find_longest_prefix_match(symbol: str, rules_dict: dict) -> list[dict]:
|
|
68
|
+
"""
|
|
69
|
+
Find matching rules using longest-prefix matching.
|
|
70
|
+
E.g., rule for 'openai.ChatCompletion' will match 'openai.ChatCompletion.create'.
|
|
71
|
+
"""
|
|
72
|
+
# Sort old_apis by length descending so longer/more specific prefixes match first
|
|
73
|
+
sorted_old_apis = sorted(rules_dict.keys(), key=len, reverse=True)
|
|
74
|
+
for old_api in sorted_old_apis:
|
|
75
|
+
if symbol == old_api or symbol.startswith(old_api + "."):
|
|
76
|
+
return rules_dict[old_api]
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def evaluate_relevance_and_completeness(
|
|
81
|
+
stage0_symbols: set[str],
|
|
82
|
+
extracted_records: list[dict],
|
|
83
|
+
migration_doc_text: str,
|
|
84
|
+
) -> dict:
|
|
85
|
+
"""
|
|
86
|
+
Evaluate migration-rule coverage using longest-prefix matching and
|
|
87
|
+
strict validated-only target sets.
|
|
88
|
+
"""
|
|
89
|
+
observed_symbols = {
|
|
90
|
+
normalize_api_symbol(symbol)
|
|
91
|
+
for symbol in stage0_symbols
|
|
92
|
+
if normalize_api_symbol(symbol)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Index extracted rules by old API
|
|
96
|
+
old_to_records = {}
|
|
97
|
+
validated_target_apis = set()
|
|
98
|
+
|
|
99
|
+
auto_migration_statuses = {"validated", "valid"}
|
|
100
|
+
|
|
101
|
+
for record in extracted_records or []:
|
|
102
|
+
old_api = normalize_api_symbol(record.get("old_api", ""))
|
|
103
|
+
new_api = normalize_api_symbol(record.get("new_api", ""))
|
|
104
|
+
status = str(record.get("validation_status", "")).strip().lower()
|
|
105
|
+
|
|
106
|
+
if old_api:
|
|
107
|
+
old_to_records.setdefault(old_api, []).append(record)
|
|
108
|
+
|
|
109
|
+
# Item 8: Build known target APIs ONLY from validated rules
|
|
110
|
+
if new_api and status in auto_migration_statuses:
|
|
111
|
+
validated_target_apis.add(new_api)
|
|
112
|
+
|
|
113
|
+
relevant_records = []
|
|
114
|
+
review_records = []
|
|
115
|
+
directly_covered_symbols = set()
|
|
116
|
+
review_covered_symbols = set()
|
|
117
|
+
|
|
118
|
+
for symbol in observed_symbols:
|
|
119
|
+
matching_records = find_longest_prefix_match(symbol, old_to_records)
|
|
120
|
+
if not matching_records:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
for record in matching_records:
|
|
124
|
+
status = str(record.get("validation_status", "")).strip().lower()
|
|
125
|
+
|
|
126
|
+
if status in auto_migration_statuses:
|
|
127
|
+
relevant_records.append({
|
|
128
|
+
**record,
|
|
129
|
+
"old_api": normalize_api_symbol(record.get("old_api", "")),
|
|
130
|
+
})
|
|
131
|
+
directly_covered_symbols.add(symbol)
|
|
132
|
+
else:
|
|
133
|
+
review_records.append({
|
|
134
|
+
**record,
|
|
135
|
+
"old_api": normalize_api_symbol(record.get("old_api", "")),
|
|
136
|
+
})
|
|
137
|
+
review_covered_symbols.add(symbol)
|
|
138
|
+
|
|
139
|
+
# Item 8: Target compatibility established strictly from validated rules
|
|
140
|
+
known_target_symbols = observed_symbols & validated_target_apis
|
|
141
|
+
|
|
142
|
+
expected_affected_symbols = determine_expected_affected_symbols(
|
|
143
|
+
observed_symbols,
|
|
144
|
+
migration_doc_text,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
uncovered_symbols = (
|
|
148
|
+
observed_symbols
|
|
149
|
+
- directly_covered_symbols
|
|
150
|
+
- known_target_symbols
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
missing_documented_symbols = (
|
|
154
|
+
expected_affected_symbols
|
|
155
|
+
- directly_covered_symbols
|
|
156
|
+
- known_target_symbols
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
is_complete = (
|
|
160
|
+
not uncovered_symbols
|
|
161
|
+
and not review_covered_symbols
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
"is_complete": is_complete,
|
|
166
|
+
"observed_symbols": sorted(observed_symbols),
|
|
167
|
+
"expected_affected_symbols": sorted(expected_affected_symbols),
|
|
168
|
+
"directly_covered_symbols": sorted(directly_covered_symbols),
|
|
169
|
+
"already_target_symbols": sorted(known_target_symbols),
|
|
170
|
+
"review_covered_symbols": sorted(review_covered_symbols),
|
|
171
|
+
"uncovered_symbols": sorted(uncovered_symbols),
|
|
172
|
+
"missing_symbols": sorted(missing_documented_symbols),
|
|
173
|
+
"relevant_records": relevant_records,
|
|
174
|
+
"review_records": review_records,
|
|
175
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import shlex
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def verify_full_repo_in_docker(
|
|
9
|
+
repo_root: str,
|
|
10
|
+
migrated_files_path: str | Path,
|
|
11
|
+
req_file: str | Path,
|
|
12
|
+
*,
|
|
13
|
+
python_image: str = "python:3.11-slim",
|
|
14
|
+
timeout: int = 300,
|
|
15
|
+
test_command: str = "python -m pytest -q",
|
|
16
|
+
) -> dict:
|
|
17
|
+
"""Verify the migrated repository inside an isolated Docker container."""
|
|
18
|
+
|
|
19
|
+
repo_path = Path(repo_root).resolve()
|
|
20
|
+
migration_path = Path(migrated_files_path).resolve()
|
|
21
|
+
requirements_path = Path(req_file).resolve()
|
|
22
|
+
|
|
23
|
+
if not repo_path.is_dir():
|
|
24
|
+
return {
|
|
25
|
+
"status": "INVALID_REPO",
|
|
26
|
+
"success": False,
|
|
27
|
+
"exit_code": None,
|
|
28
|
+
"stdout": "",
|
|
29
|
+
"stderr": f"Repository directory not found: {repo_path}",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if not migration_path.is_dir():
|
|
33
|
+
return {
|
|
34
|
+
"status": "INVALID_MIGRATIONS",
|
|
35
|
+
"success": False,
|
|
36
|
+
"exit_code": None,
|
|
37
|
+
"stdout": "",
|
|
38
|
+
"stderr": f"Migrated files directory not found: {migration_path}",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if not requirements_path.is_file():
|
|
42
|
+
return {
|
|
43
|
+
"status": "INVALID_REQUIREMENTS",
|
|
44
|
+
"success": False,
|
|
45
|
+
"exit_code": None,
|
|
46
|
+
"stdout": "",
|
|
47
|
+
"stderr": f"Requirements file not found: {requirements_path}",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test_cmd = test_command.strip()
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
with tempfile.TemporaryDirectory(prefix="aston_verify_") as temp_dir:
|
|
54
|
+
|
|
55
|
+
verification_repo = Path(temp_dir) / "repo"
|
|
56
|
+
|
|
57
|
+
# 1. Copy the original repository.
|
|
58
|
+
shutil.copytree(
|
|
59
|
+
repo_path,
|
|
60
|
+
verification_repo,
|
|
61
|
+
ignore=shutil.ignore_patterns(
|
|
62
|
+
".git",
|
|
63
|
+
".venv",
|
|
64
|
+
"venv",
|
|
65
|
+
"__pycache__",
|
|
66
|
+
".pytest_cache",
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# 2. Overlay migrated files, preserving their relative paths.
|
|
71
|
+
for migrated_file in migration_path.rglob("*"):
|
|
72
|
+
|
|
73
|
+
if not migrated_file.is_file():
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
relative_path = migrated_file.relative_to(migration_path)
|
|
77
|
+
|
|
78
|
+
# Requirements are handled separately below.
|
|
79
|
+
if relative_path.as_posix() == "requirements.txt":
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
destination = verification_repo / relative_path
|
|
83
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
|
|
85
|
+
shutil.copy2(migrated_file, destination)
|
|
86
|
+
|
|
87
|
+
# 3. Replace requirements with the target requirements.
|
|
88
|
+
shutil.copy2(
|
|
89
|
+
requirements_path,
|
|
90
|
+
verification_repo / "requirements.txt",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# 4. Run the assembled repository inside Docker.
|
|
94
|
+
script = f"""
|
|
95
|
+
export PIP_CACHE_DIR=/root/.cache/pip
|
|
96
|
+
set -eu
|
|
97
|
+
|
|
98
|
+
echo "--- [Verification] Installing requirements ---"
|
|
99
|
+
python -m pip install --disable-pip-version-check -r /app/requirements.txt ||
|
|
100
|
+
{{ echo "ASTON_SETUP_FAILED"; exit 20; }}
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
if test_cmd:
|
|
104
|
+
if "pytest" in test_cmd.lower():
|
|
105
|
+
script += """
|
|
106
|
+
echo "--- [Verification] Checking test runner ---"
|
|
107
|
+
if ! python -c "import pytest" 2>/dev/null; then
|
|
108
|
+
python -m pip install --disable-pip-version-check pytest ||
|
|
109
|
+
{ echo "ASTON_PYTEST_SETUP_FAILED"; exit 23; }
|
|
110
|
+
fi
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
script += """
|
|
114
|
+
echo "--- [Verification] Running AST syntax checks ---"
|
|
115
|
+
python -c '
|
|
116
|
+
import ast
|
|
117
|
+
import sys
|
|
118
|
+
from pathlib import Path
|
|
119
|
+
|
|
120
|
+
root = Path("/app")
|
|
121
|
+
|
|
122
|
+
excluded = {
|
|
123
|
+
".git",
|
|
124
|
+
".venv",
|
|
125
|
+
"venv",
|
|
126
|
+
"__pycache__",
|
|
127
|
+
".pytest_cache",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
errors = []
|
|
131
|
+
|
|
132
|
+
for path in root.rglob("*.py"):
|
|
133
|
+
if any(part in excluded for part in path.parts):
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
138
|
+
except (SyntaxError, UnicodeDecodeError, OSError) as exc:
|
|
139
|
+
errors.append((path, exc))
|
|
140
|
+
|
|
141
|
+
for path, exc in errors:
|
|
142
|
+
print("Syntax error in {}: {}".format(path, exc), file=sys.stderr)
|
|
143
|
+
|
|
144
|
+
sys.exit(1 if errors else 0)
|
|
145
|
+
' || { echo "ASTON_SYNTAX_FAILED"; exit 21; }
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
if test_cmd:
|
|
149
|
+
if "pytest" in test_cmd.lower():
|
|
150
|
+
script += f"""
|
|
151
|
+
echo "--- [Verification] Running test suite ---"
|
|
152
|
+
set +e
|
|
153
|
+
export PYTEST_ADDOPTS="${{PYTEST_ADDOPTS:-}} -p no:cacheprovider"
|
|
154
|
+
{test_cmd}
|
|
155
|
+
test_status=$?
|
|
156
|
+
set -e
|
|
157
|
+
|
|
158
|
+
if [ "$test_status" -eq 5 ]; then
|
|
159
|
+
echo "ASTON_TESTS_SKIPPED_NO_TESTS: pytest collected no tests."
|
|
160
|
+
elif [ "$test_status" -ne 0 ]; then
|
|
161
|
+
echo "ASTON_TESTS_FAILED: exit code $test_status"
|
|
162
|
+
exit 22
|
|
163
|
+
else
|
|
164
|
+
echo "ASTON_TESTS_PASSED"
|
|
165
|
+
fi
|
|
166
|
+
"""
|
|
167
|
+
else:
|
|
168
|
+
script += f"""
|
|
169
|
+
echo "--- [Verification] Running test suite ---"
|
|
170
|
+
{test_cmd} ||
|
|
171
|
+
{{ echo "ASTON_TESTS_FAILED"; exit 22; }}
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
script += """
|
|
175
|
+
echo "ASTON_VERIFICATION_PASSED"
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
docker_cmd = [
|
|
179
|
+
"docker", "run", "--rm",
|
|
180
|
+
"-v", f"{verification_repo}:/app:ro,Z",
|
|
181
|
+
"-v", "aston-pip-cache:/root/.cache/pip",
|
|
182
|
+
"--workdir", "/app",
|
|
183
|
+
python_image,
|
|
184
|
+
"sh", "-c", script,
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
result = subprocess.run(
|
|
188
|
+
docker_cmd,
|
|
189
|
+
capture_output=True,
|
|
190
|
+
text=True,
|
|
191
|
+
timeout=timeout,
|
|
192
|
+
check=False,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
output = result.stdout + "\n" + result.stderr
|
|
196
|
+
|
|
197
|
+
if result.returncode == 0 and "ASTON_VERIFICATION_PASSED" in output:
|
|
198
|
+
status = "PASS"
|
|
199
|
+
elif "ASTON_SETUP_FAILED" in output:
|
|
200
|
+
status = "SETUP_ERROR"
|
|
201
|
+
elif "ASTON_PYTEST_SETUP_FAILED" in output:
|
|
202
|
+
status = "SETUP_ERROR"
|
|
203
|
+
elif "ASTON_SYNTAX_FAILED" in output:
|
|
204
|
+
status = "SYNTAX_ERROR"
|
|
205
|
+
elif "ASTON_TESTS_FAILED" in output:
|
|
206
|
+
status = "TEST_FAILED"
|
|
207
|
+
else:
|
|
208
|
+
status = "DOCKER_ERROR"
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
"status": status,
|
|
212
|
+
"success": status == "PASS",
|
|
213
|
+
"exit_code": result.returncode,
|
|
214
|
+
"stdout": result.stdout,
|
|
215
|
+
"stderr": result.stderr,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
except subprocess.TimeoutExpired as exc:
|
|
219
|
+
stdout = exc.stdout or ""
|
|
220
|
+
stderr = exc.stderr or "Repository verification timed out."
|
|
221
|
+
|
|
222
|
+
if isinstance(stdout, bytes):
|
|
223
|
+
stdout = stdout.decode("utf-8", errors="replace")
|
|
224
|
+
|
|
225
|
+
if isinstance(stderr, bytes):
|
|
226
|
+
stderr = stderr.decode("utf-8", errors="replace")
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
"status": "TIMEOUT",
|
|
230
|
+
"success": False,
|
|
231
|
+
"exit_code": None,
|
|
232
|
+
"stdout": stdout,
|
|
233
|
+
"stderr": stderr,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
except OSError as exc:
|
|
237
|
+
return {
|
|
238
|
+
"status": "DOCKER_ERROR",
|
|
239
|
+
"success": False,
|
|
240
|
+
"exit_code": None,
|
|
241
|
+
"stdout": "",
|
|
242
|
+
"stderr": str(exc),
|
|
243
|
+
}
|