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