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 ADDED
File without changes
aston/axios.py ADDED
@@ -0,0 +1,525 @@
1
+ import ast
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ from dotenv import load_dotenv
6
+ from mistralai.client import Mistral
7
+ from aston.rule_validation import validate_rules_against_target
8
+ from aston.scanner import parser
9
+ from aston.ingestion import run_stage1_pipeline, get_latest_pypi_version
10
+ from aston.relevance import evaluate_relevance_and_completeness, canonicalize_usage
11
+ from aston.docker import verify_single_file_in_docker, VerificationResult
12
+ from aston.relevance import normalize_api_symbol
13
+ from aston.cli.console import rich_success, error, warning, info
14
+ from aston.exceptions import ConfigurationError, LLMError
15
+ import time
16
+ from aston.llm import call_llm
17
+ from aston.config import load_config
18
+
19
+ load_dotenv()
20
+ config = load_config()
21
+
22
+ llm_cfg = config.get("llm", {})
23
+ provider_name = llm_cfg.get("provider", "mistral")
24
+ model_name = llm_cfg.get("model", "codestral-latest")
25
+ temperature = llm_cfg.get("temperature", 0.0)
26
+ max_retries = llm_cfg.get("max_retries", 3)
27
+
28
+ API_KEY = os.environ.get("MISTRAL_API_KEY")
29
+ client = Mistral(api_key=API_KEY) if API_KEY else None
30
+
31
+ with open("patcher_prompt.txt", "r", encoding="utf-8") as f:
32
+ prompt = f.read()
33
+
34
+
35
+ def contains_items(items) -> bool:
36
+ """Check whether a list or nested dictionary contains rule/match records."""
37
+ if isinstance(items, dict):
38
+ return any(contains_items(value) for value in items.values())
39
+
40
+ return (
41
+ isinstance(items, list)
42
+ and len(items) > 0
43
+ and any(isinstance(item, dict) for item in items)
44
+ )
45
+
46
+
47
+ def clean_mistral_code(raw_response: str) -> str:
48
+ """Remove markdown code fences from the LLM response."""
49
+ if not isinstance(raw_response, str):
50
+ return ""
51
+
52
+ lines = raw_response.strip().splitlines()
53
+
54
+ if lines and lines[0].startswith("```python"):
55
+ lines.pop(0)
56
+ elif lines and lines[0].startswith("```"):
57
+ lines.pop(0)
58
+
59
+ if lines and lines[-1].strip() == "```":
60
+ lines.pop()
61
+
62
+ return "\n".join(lines).strip()
63
+
64
+
65
+ def stage0(package_name: str, repo_index: dict) -> set[str]:
66
+ """
67
+ Extract canonical usages for a package across the entire repository.
68
+
69
+ This remains global because Stage 0 discovers the package APIs used
70
+ anywhere in the repository.
71
+ """
72
+ flattened_usages = []
73
+
74
+ files_dict = repo_index.get("files", {})
75
+
76
+ for file_path, file_data in files_dict.items():
77
+ if isinstance(file_data, dict):
78
+ usages = file_data.get("usages", [])
79
+
80
+ if isinstance(usages, list):
81
+ flattened_usages.extend(
82
+ usage
83
+ for usage in usages
84
+ if isinstance(usage, dict)
85
+ )
86
+
87
+ stage0_records = [
88
+ record
89
+ for record in flattened_usages
90
+ if record.get("package") == package_name
91
+ ]
92
+
93
+ dynamic_stage0_symbols = {
94
+ canonicalize_usage(record)
95
+ for record in stage0_records
96
+ }
97
+
98
+ info(
99
+ f"Dynamic AST Scanned Usages for {package_name}: "
100
+ f"{dynamic_stage0_symbols}"
101
+ )
102
+
103
+ return dynamic_stage0_symbols
104
+
105
+
106
+ def man_extract(
107
+ rules: list,
108
+ parses: list[dict],
109
+ source_file: str,
110
+ ) -> list[dict]:
111
+ """Match AST usages to migration candidates using longest-prefix matching."""
112
+ from collections import defaultdict
113
+
114
+ extracted = defaultdict(list)
115
+
116
+ for rule in rules:
117
+ package = rule.get("package")
118
+ old_api = normalize_api_symbol(rule.get("old_api", ""))
119
+
120
+ if not package or not old_api:
121
+ continue
122
+
123
+ extracted[(package, old_api)].append(dict(rule))
124
+
125
+ report = []
126
+ filename = Path(source_file).as_posix()
127
+
128
+ rule_keys_by_pkg = defaultdict(list)
129
+
130
+ for package, old_api in extracted.keys():
131
+ rule_keys_by_pkg[package].append(old_api)
132
+
133
+ for package in rule_keys_by_pkg:
134
+ rule_keys_by_pkg[package].sort(
135
+ key=len,
136
+ reverse=True,
137
+ )
138
+
139
+ for parse in parses:
140
+ if not isinstance(parse, dict):
141
+ continue
142
+
143
+ package = parse.get("package")
144
+ canonical_usage = canonicalize_usage(parse)
145
+ normalized_usage = normalize_api_symbol(canonical_usage)
146
+
147
+ matched_old_api = None
148
+
149
+ for old_api in rule_keys_by_pkg.get(package, []):
150
+ if (
151
+ normalized_usage == old_api
152
+ or normalized_usage.startswith(old_api + ".")
153
+ ):
154
+ matched_old_api = old_api
155
+ break
156
+
157
+ if not matched_old_api:
158
+ continue
159
+
160
+ candidates = extracted.get((package, matched_old_api))
161
+
162
+ if not candidates:
163
+ continue
164
+
165
+ report.append(
166
+ {
167
+ "file_name": filename,
168
+ "Line": parse.get("line"),
169
+ "alias": parse.get("local_alias"),
170
+ "package": package,
171
+ "detected_usage": parse.get("api_usage"),
172
+ "canonical_usage": canonical_usage,
173
+ "instance": parse.get("instance"),
174
+ "kwargs": parse.get("kwargs"),
175
+ "change": {
176
+ "candidates": candidates,
177
+ },
178
+ }
179
+ )
180
+
181
+ info(
182
+ f"\nman_extract: {filename} | "
183
+ f"input usages={len(parses)} | matched usages={len(report)}"
184
+ )
185
+
186
+ return report
187
+
188
+
189
+ def code_patcher(
190
+ source_code: str,
191
+ matches: list,
192
+ rules: list,
193
+ target_version: list,
194
+ current_version: list,
195
+ repo_rules,
196
+ symbols,
197
+ relationships,
198
+ dependencies,
199
+ repair_context: dict | None = None,
200
+ raw_code_examples: list[str] | None = None,
201
+ ):
202
+ """Stage 3 migration and Stage 5 verification-guided repair."""
203
+
204
+ if not client:
205
+ raise ConfigurationError(
206
+ message="MISTRAL_API_KEY environment variable is missing or invalid.",
207
+ details="Set your MISTRAL_API_KEY in your environment or .env file before running code patching."
208
+ )
209
+
210
+ # CHANGE: Require rules and matches for a normal migration, but don't
211
+ # block repair mode. A verifier error can still need repair even when
212
+ # the original migration evidence is incomplete.
213
+ if not repair_context and (
214
+ not contains_items(rules)
215
+ or not contains_items(matches)
216
+ ):
217
+ warning(
218
+ "No valid migration rules or matching usages. "
219
+ "Retaining original code unmodified."
220
+ )
221
+ return source_code
222
+
223
+ system_prompt = prompt
224
+
225
+ if repair_context:
226
+ repair_instructions = repair_context.get(
227
+ "repair_instructions",
228
+ "",
229
+ )
230
+
231
+ system_prompt += """
232
+
233
+ REPAIR MODE — OVERRIDES CONFLICTING MIGRATION INSTRUCTIONS
234
+
235
+ The current source has already failed target-environment verification.
236
+
237
+ Treat the current error and all previous attempts as evidence.
238
+ Trace the failing expression to the migration decision that produced it.
239
+
240
+ Never repeat an expression that a previous verification attempt
241
+ demonstrated to be invalid unless new, specific evidence explains
242
+ why it should now work.
243
+
244
+ Do not alternate between previously failed replacements.
245
+ Do not assume a migration rule is a valid literal substitution.
246
+ Inspect surrounding configuration, initialization, imports, and
247
+ object ownership before changing the failing code.
248
+
249
+ Preserve successful changes and unrelated code.
250
+ Do not invent APIs, attributes, constructor arguments, or configuration.
251
+ If the supplied evidence is insufficient for a safe replacement,
252
+ do not fabricate one.
253
+
254
+ Return the complete source file only.
255
+ """
256
+
257
+ if repair_instructions:
258
+ system_prompt += (
259
+ "\n\nPROJECT-SPECIFIC REPAIR INSTRUCTIONS:\n"
260
+ + repair_instructions
261
+ )
262
+
263
+ cont = f"""
264
+ MIGRATION_RULES_FOR_EACH_DEPENDENCY:
265
+ {json.dumps(rules, indent=2, default=str)}
266
+
267
+ REPOSITORY_FILE_USAGES:
268
+ {json.dumps(repo_rules, indent=2, default=str)}
269
+
270
+ AST_SYMBOLS_INDEX:
271
+ {json.dumps(symbols, indent=2, default=str)}
272
+
273
+ CROSS_FILE_RELATIONSHIPS:
274
+ {json.dumps(relationships, indent=2, default=str)}
275
+
276
+ DEPENDENCIES_MAP:
277
+ {json.dumps(dependencies, indent=2, default=str)}
278
+
279
+ MATCHES_AND_AFFECTED_USAGES:
280
+ {json.dumps(matches, indent=2, default=str)}
281
+
282
+ CURRENT_DEPENDENCY_VERSIONS:
283
+ {json.dumps(current_version, indent=2, default=str)}
284
+
285
+ TARGET_DEPENDENCY_VERSIONS:
286
+ {json.dumps(target_version, indent=2, default=str)}
287
+
288
+ RAW_CODE_EXAMPLES_FOR_CONTEXT_NOT_AUTHORITATIVE_RULES:
289
+ {json.dumps(raw_code_examples, indent=2, default=str)}
290
+
291
+ REPAIR_CONTEXT:
292
+ {json.dumps(repair_context, indent=2, default=str) if repair_context else "None"}
293
+
294
+ CURRENT SOURCE CODE TO MIGRATE OR REPAIR:
295
+ {source_code}
296
+ """
297
+
298
+
299
+ delay = 2.0
300
+ response = None
301
+
302
+ for attempt in range(1, max_retries + 1):
303
+ try:
304
+ response = call_llm(prompt=cont, provider=provider_name, model=model_name, temperature=temperature, system_prompt=system_prompt)
305
+ break
306
+ except Exception as exc:
307
+ if attempt == max_retries:
308
+ raise LLMError(
309
+ message=f"Mistral LLM call failed after {max_retries} attempts.",
310
+ details=str(exc)
311
+ ) from exc
312
+ warning(f"Mistral API call failed ({exc}). Retrying in {delay}s ({attempt}/{max_retries})...")
313
+ time.sleep(delay)
314
+ delay *= 2.0
315
+
316
+ patched = response.choices[0].message.content
317
+ clean = clean_mistral_code(patched)
318
+
319
+ try:
320
+ ast.parse(clean)
321
+ except SyntaxError as se:
322
+ error(
323
+ "Patcher returned syntactically invalid Python code "
324
+ f"({se}); rejecting candidate."
325
+ )
326
+ return source_code
327
+
328
+ if repair_context:
329
+ failed_sources = {
330
+ attempt.get("source_code")
331
+ for attempt in repair_context.get("previous_attempts", [])
332
+ if attempt.get("source_code")
333
+ }
334
+
335
+ if clean == source_code:
336
+ warning(
337
+ "Repair returned unchanged source; "
338
+ "rejecting candidate."
339
+ )
340
+ return source_code
341
+
342
+ if clean in failed_sources:
343
+ warning(
344
+ "Repair repeated a previously failed source; "
345
+ "rejecting candidate."
346
+ )
347
+ return source_code
348
+
349
+ return clean
350
+
351
+
352
+ def scraper(
353
+ pkg_name: str,
354
+ current_version: str,
355
+ target_version: str,
356
+ stage0_symbols: set[str],
357
+ parses: list[dict],
358
+ composed_rules: list[dict],
359
+ doc_text: str,
360
+ source_file: str,
361
+ ):
362
+ """Run migration-rule coverage auditing, targeted extraction, and matching."""
363
+ print("\n==================================================")
364
+ print("--- [Stage 1] Multi-Boundary Chain Ingestion ---")
365
+ print("==================================================")
366
+
367
+ def normalize_set(values) -> set[str]:
368
+ if not values:
369
+ return set()
370
+
371
+ return {
372
+ normalize_api_symbol(value)
373
+ for value in values
374
+ if (
375
+ isinstance(value, str)
376
+ and normalize_api_symbol(value)
377
+ )
378
+ }
379
+
380
+ def rule_key(rule: dict) -> tuple:
381
+ return (
382
+ rule.get("package"),
383
+ normalize_api_symbol(rule.get("old_api") or ""),
384
+ normalize_api_symbol(rule.get("new_api") or ""),
385
+ rule.get("from_version"),
386
+ rule.get("to_version"),
387
+ rule.get("migration_type"),
388
+ )
389
+
390
+ working_rules = [
391
+ dict(rule)
392
+ for rule in (composed_rules or [])
393
+ if isinstance(rule, dict)
394
+ ]
395
+
396
+ working_doc_text = doc_text or ""
397
+
398
+ print("\n========== STAGE 2: INITIAL AUDIT ==========")
399
+ info(f"\nSTAGE 0 SYMBOLS: {sorted(stage0_symbols)}")
400
+ info(f"INITIAL RULE COUNT: {len(working_rules)}")
401
+
402
+ audit = evaluate_relevance_and_completeness(
403
+ stage0_symbols,
404
+ working_rules,
405
+ working_doc_text,
406
+ )
407
+
408
+ observed_symbols = normalize_set(stage0_symbols)
409
+ directly_covered = normalize_set(
410
+ audit.get("directly_covered_symbols", [])
411
+ )
412
+ already_target = normalize_set(
413
+ audit.get("already_target_symbols", [])
414
+ )
415
+
416
+ targeted_symbols = set()
417
+
418
+ for field in ("missing_symbols", "uncovered_symbols"):
419
+ targeted_symbols.update(
420
+ normalize_set(audit.get(field, []))
421
+ )
422
+
423
+ targeted_symbols.update(
424
+ observed_symbols - directly_covered - already_target
425
+ )
426
+
427
+ if targeted_symbols:
428
+ print("\n==================================================")
429
+ print("--- [Stage 2.5] Targeted Rule Extraction ---")
430
+ print("==================================================")
431
+ info(f"FOCUS SYMBOLS: {sorted(targeted_symbols)}")
432
+
433
+ try:
434
+ extra_rules, extra_doc_text, extra_examples = (
435
+ run_stage1_pipeline(
436
+ pkg_name,
437
+ current_version=current_version,
438
+ target_version=target_version,
439
+ stage0_symbols=targeted_symbols,
440
+ )
441
+ )
442
+
443
+ extra_rules = extra_rules or []
444
+ existing_keys = {
445
+ rule_key(rule)
446
+ for rule in working_rules
447
+ }
448
+ added_count = 0
449
+
450
+ for rule in extra_rules:
451
+ if not isinstance(rule, dict):
452
+ continue
453
+
454
+ candidate = dict(rule)
455
+ key = rule_key(candidate)
456
+
457
+ if key not in existing_keys:
458
+ working_rules.append(candidate)
459
+ existing_keys.add(key)
460
+ added_count += 1
461
+
462
+ if extra_doc_text:
463
+ working_doc_text = (
464
+ working_doc_text + "\n\n" + extra_doc_text
465
+ ).strip()
466
+
467
+ info(f"TARGETED CANDIDATES RETURNED: {len(extra_rules)}")
468
+ info(f"NEW DISTINCT RULES ADDED: {added_count}")
469
+
470
+ except Exception as exc:
471
+ error(
472
+ "⚠️ Targeted extraction failed:",
473
+ repr(exc),
474
+ )
475
+
476
+ audit = evaluate_relevance_and_completeness(
477
+ stage0_symbols,
478
+ working_rules,
479
+ working_doc_text,
480
+ )
481
+
482
+ print("\n==================================================")
483
+ print("--- [Stage 2] Final Relevance & Completeness Audit ---")
484
+ print("==================================================")
485
+ print()
486
+ info(f"OBSERVED: {sorted(observed_symbols)}")
487
+ info(f"TOTAL RULES: {len(working_rules)}")
488
+ info("FINAL AUDIT RESULT:")
489
+ info(json.dumps(audit, indent=2, default=str))
490
+
491
+ relevant_rules = audit.get("relevant_records", [])
492
+ missing = normalize_set(audit.get("missing_symbols", []))
493
+ uncovered = normalize_set(audit.get("uncovered_symbols", []))
494
+
495
+ is_genuinely_complete = (
496
+ bool(audit.get("is_complete", False))
497
+ and not missing
498
+ and not uncovered
499
+ )
500
+
501
+ print("\n========== FINAL COVERAGE STATUS ==========")
502
+ info(f"\nComplete: {is_genuinely_complete}")
503
+
504
+ with open("changes.json", "w", encoding="utf-8") as f:
505
+ json.dump(relevant_rules, f, indent=2, default=str)
506
+
507
+ info("Canonical contract saved in changes.json")
508
+
509
+ print("\n==================================================")
510
+ print("--- [Stage 3] Matching Usages ---")
511
+ print("==================================================")
512
+
513
+ # The pipeline must pass usages from the current file here.
514
+ # Stage 0 remains global; this matching operation is file-specific.
515
+ report = man_extract(
516
+ relevant_rules,
517
+ parses,
518
+ source_file,
519
+ )
520
+
521
+ info(f"MATCHED USAGES: {len(report)}")
522
+ info(f"RELEVANT RULES: {len(relevant_rules)}")
523
+
524
+ return report, relevant_rules, is_genuinely_complete
525
+
aston/config.py ADDED
@@ -0,0 +1,88 @@
1
+ import os
2
+ from pathlib import Path
3
+ import yaml
4
+ from aston.cli.console import error, info, rich_success, warning
5
+ from aston.exceptions import ConfigurationError
6
+
7
+ CONFIG_DIR = Path.home() / ".config" / "aston"
8
+ CONFIG_FILE = CONFIG_DIR / "config.yaml"
9
+ LOGS_DIR = CONFIG_DIR / "logs"
10
+
11
+ DEFAULT_CONFIG = {
12
+ "llm": {
13
+ "provider": "mistral", # Default LLM provider
14
+ "model": "codestral-latest",
15
+ "temperature": 0.0,
16
+ "max_retries": 3,
17
+ },
18
+ "migration": {
19
+ "per_file_repair_retries": 3, # LLM repair loops per individual file
20
+ "failed_files_sweep_passes": 2, # Outer pipeline runs to retry remaining failed files
21
+ "docker_file_timeout_seconds": 300, # Timeout for single-file sandbox checks
22
+ "docker_repo_timeout_seconds": 300,# Timeout for repo-wide test suites
23
+ },
24
+ "github": {
25
+ "default_base_branch": "main",
26
+ "draft_prs": False,
27
+ },
28
+ }
29
+
30
+
31
+ def get_config_dir() -> Path:
32
+ """Ensure and return the user config directory (~/.config/aston/)."""
33
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
34
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
35
+ return CONFIG_DIR
36
+
37
+
38
+ def load_config() -> dict:
39
+ """
40
+ Load configuration from ~/.config/aston/config.yaml,
41
+ falling back to defaults and applying environment variable overrides.
42
+ """
43
+ config = DEFAULT_CONFIG.copy()
44
+
45
+ if CONFIG_FILE.is_file():
46
+ try:
47
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
48
+ user_config = yaml.safe_load(f)
49
+ if isinstance(user_config, dict):
50
+ # Deep merge user config into defaults
51
+ for section, values in user_config.items():
52
+ if section in config and isinstance(values, dict):
53
+ config[section].update(values)
54
+ else:
55
+ config[section] = values
56
+ except Exception as exc:
57
+ warning(f"Failed to parse {CONFIG_FILE}: {exc}. Using default configuration.")
58
+
59
+ # Environment variable overrides
60
+ if os.getenv("ASTON_MODEL"):
61
+ config["llm"]["model"] = os.getenv("ASTON_MODEL")
62
+
63
+ return config
64
+
65
+
66
+ def save_default_config() -> Path:
67
+ """Initialize ~/.config/aston/config.yaml with default settings."""
68
+ get_config_dir()
69
+ if not CONFIG_FILE.exists():
70
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
71
+ yaml.safe_dump(DEFAULT_CONFIG, f, sort_keys=False)
72
+ rich_success(f"Initialized default configuration at: {CONFIG_FILE}")
73
+ else:
74
+ info(f"Configuration file already exists at: {CONFIG_FILE}")
75
+ return CONFIG_FILE
76
+
77
+
78
+ def redact_secrets(data: any) -> any:
79
+ """Recursively redact sensitive keys for safe display."""
80
+ sensitive_keys = {"api_key", "token", "secret", "password", "github_token"}
81
+ if isinstance(data, dict):
82
+ return {
83
+ k: ("********" if k.lower() in sensitive_keys else redact_secrets(v))
84
+ for k, v in data.items()
85
+ }
86
+ elif isinstance(data, list):
87
+ return [redact_secrets(item) for item in data]
88
+ return data