| from __future__ import annotations |
|
|
| import ast |
| import json |
| import re |
| from dataclasses import asdict, dataclass, field |
| from pathlib import Path |
| from typing import Any |
|
|
|
|
| ENTRY_NAMES = ( |
| "train.py", "trainer.py", "finetune.py", "fine_tune.py", |
| "main.py", "run.py", "app.py", |
| ) |
| CONFIG_SUFFIXES = {".json", ".yaml", ".yml", ".toml", ".ini"} |
| DATASET_EXTENSIONS = { |
| ".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", |
| ".txt", ".csv", ".json", ".jsonl", ".parquet", ".wav", ".mp3", |
| } |
|
|
|
|
| @dataclass(slots=True) |
| class ToolAnalysis: |
| folder: str |
| python_files: list[str] = field(default_factory=list) |
| entry_candidates: list[str] = field(default_factory=list) |
| config_files: list[str] = field(default_factory=list) |
| selected_entry: str = "" |
| selected_configs: list[str] = field(default_factory=list) |
| arguments: list[str] = field(default_factory=list) |
| required_arguments: list[str] = field(default_factory=list) |
| dataset_format: str = "Not detected" |
| output_behavior: str = "Not detected" |
| checkpoint_behavior: str = "Not detected" |
| progress_behavior: str = "Not detected" |
| resume_behavior: str = "Not detected" |
| score: int = 1 |
| reasons: list[str] = field(default_factory=list) |
| warnings: list[str] = field(default_factory=list) |
|
|
|
|
| def _relative(path: Path, folder: Path) -> str: |
| return path.relative_to(folder).as_posix() |
|
|
|
|
| def scan_folder(folder_value: str) -> ToolAnalysis: |
| folder = Path(folder_value).expanduser().resolve() |
| if not folder.is_dir(): |
| return ToolAnalysis(str(folder), reasons=["The selected folder does not exist."]) |
| python_paths = sorted(folder.rglob("*.py")) |
| python_paths = [ |
| path for path in python_paths |
| if not any(part.casefold() in {".git", ".venv", "venv", "__pycache__", "site-packages"} |
| for part in path.parts) |
| ][:500] |
| python_files = [_relative(path, folder) for path in python_paths] |
| ranked = sorted( |
| python_files, |
| key=lambda value: ( |
| 0 if Path(value).name.casefold() in ENTRY_NAMES else 1, |
| value.count("/"), |
| len(value), |
| ), |
| ) |
| configs = sorted( |
| _relative(path, folder) |
| for path in folder.rglob("*") |
| if path.is_file() |
| and path.suffix.casefold() in CONFIG_SUFFIXES |
| and not any(part.casefold() in {".git", ".venv", "venv", "__pycache__", "site-packages"} |
| for part in path.parts) |
| )[:100] |
| result = ToolAnalysis( |
| folder=str(folder), |
| python_files=python_files, |
| entry_candidates=ranked[:30], |
| config_files=configs, |
| selected_entry=ranked[0] if ranked else "", |
| ) |
| return analyze_selection(result, result.selected_entry, []) |
|
|
|
|
| def _string_literals(tree: ast.AST) -> list[str]: |
| return [ |
| node.value |
| for node in ast.walk(tree) |
| if isinstance(node, ast.Constant) and isinstance(node.value, str) |
| ] |
|
|
|
|
| def _call_name(call: ast.Call) -> str: |
| parts: list[str] = [] |
| value: ast.AST = call.func |
| while isinstance(value, ast.Attribute): |
| parts.append(value.attr) |
| value = value.value |
| if isinstance(value, ast.Name): |
| parts.append(value.id) |
| return ".".join(reversed(parts)) |
|
|
|
|
| def analyze_selection( |
| base: ToolAnalysis, |
| entry_relative: str, |
| selected_configs: list[str], |
| ) -> ToolAnalysis: |
| result = ToolAnalysis( |
| folder=base.folder, |
| python_files=list(base.python_files), |
| entry_candidates=list(base.entry_candidates), |
| config_files=list(base.config_files), |
| selected_entry=entry_relative, |
| selected_configs=list(selected_configs), |
| ) |
| if not entry_relative: |
| result.reasons = ["No Python entry script was selected."] |
| return result |
| folder = Path(result.folder) |
| entry = (folder / entry_relative).resolve() |
| try: |
| entry.relative_to(folder) |
| except ValueError: |
| result.reasons = ["The entry script is outside the selected tool folder."] |
| return result |
| try: |
| source = entry.read_text(encoding="utf-8", errors="replace") |
| tree = ast.parse(source, filename=str(entry)) |
| except (OSError, SyntaxError) as exc: |
| result.reasons = [f"The entry script could not be parsed: {exc}"] |
| return result |
|
|
| score = 2 |
| reasons: list[str] = [] |
| warnings: list[str] = [] |
| literals = _string_literals(tree) |
| lowered_source = source.casefold() |
| calls = [node for node in ast.walk(tree) if isinstance(node, ast.Call)] |
| call_names = [_call_name(call).casefold() for call in calls] |
|
|
| arguments: dict[str, bool] = {} |
| for call in calls: |
| name = _call_name(call).casefold() |
| if not name.endswith("add_argument"): |
| continue |
| flags = [ |
| value.value |
| for value in call.args |
| if isinstance(value, ast.Constant) |
| and isinstance(value.value, str) |
| and value.value.startswith("-") |
| ] |
| if not flags: |
| continue |
| flag = max(flags, key=len).lstrip("-").replace("-", "_") |
| required = any( |
| keyword.arg == "required" |
| and isinstance(keyword.value, ast.Constant) |
| and keyword.value.value is True |
| for keyword in call.keywords |
| ) |
| arguments[flag] = required |
| result.arguments = sorted(arguments) |
| result.required_arguments = sorted(key for key, required in arguments.items() if required) |
| if arguments: |
| score += 2 |
| reasons.append(f"Detected {len(arguments)} command-line option(s).") |
| else: |
| reasons.append("No argparse-style command-line options were detected.") |
|
|
| has_main_guard = "__name__" in lowered_source and "__main__" in lowered_source |
| if has_main_guard: |
| score += 1 |
| reasons.append("Has a standard Python main entry point.") |
| else: |
| warnings.append("No standard __main__ entry point was detected.") |
|
|
| training_terms = ("train", "epoch", "optimizer", "loss", "backward", "fit(") |
| training_hits = sum(term in lowered_source for term in training_terms) |
| if training_hits >= 2: |
| score += 1 |
| reasons.append("The script contains recognizable training logic.") |
| else: |
| warnings.append("Little recognizable training logic was found in the selected file.") |
|
|
| data_exts = sorted( |
| extension for extension in DATASET_EXTENSIONS |
| if extension in lowered_source |
| ) |
| dataset_args = [key for key in arguments if any(term in key for term in ("data", "dataset", "input"))] |
| if data_exts or dataset_args: |
| score += 1 |
| parts = [] |
| if data_exts: |
| parts.append(", ".join(data_exts)) |
| if dataset_args: |
| parts.append("arguments: " + ", ".join(dataset_args)) |
| result.dataset_format = "; ".join(parts) |
| reasons.append("Dataset input is discoverable.") |
|
|
| output_args = [key for key in arguments if any(term in key for term in ("output", "save", "model_dir"))] |
| output_literals = [ |
| value for value in literals |
| if any(term in value.casefold() for term in ("output", "checkpoint", ".pt", ".pth", ".ckpt", ".safetensors")) |
| ][:5] |
| if output_args or output_literals: |
| score += 1 |
| result.output_behavior = ( |
| ("arguments: " + ", ".join(output_args)) if output_args |
| else "Paths referenced by the script: " + ", ".join(output_literals) |
| ) |
| reasons.append("Output or model-save behavior is visible.") |
|
|
| checkpoint_terms = [ |
| value for value in ("checkpoint", "state_dict", ".ckpt", ".pt", ".pth", ".safetensors") |
| if value in lowered_source |
| ] |
| result.checkpoint_behavior = ( |
| "Detected: " + ", ".join(checkpoint_terms) |
| if checkpoint_terms else "No checkpoint-writing pattern detected" |
| ) |
| if checkpoint_terms: |
| score += 1 |
|
|
| resume_args = [key for key in arguments if any(term in key for term in ("resume", "checkpoint", "load"))] |
| if resume_args: |
| result.resume_behavior = "Supported through: " + ", ".join(resume_args) |
| score += 1 |
| elif "load_state_dict" in lowered_source or "resume" in lowered_source: |
| result.resume_behavior = "Possible in code, but no clear command-line option was detected" |
| else: |
| result.resume_behavior = "No safe resume interface detected" |
|
|
| progress_patterns = [] |
| if "tqdm" in lowered_source: |
| progress_patterns.append("tqdm") |
| if "print(" in lowered_source: |
| progress_patterns.append("console output") |
| if any(term in lowered_source for term in ("tensorboard", "wandb", "mlflow")): |
| progress_patterns.append("experiment tracker") |
| result.progress_behavior = ( |
| ", ".join(progress_patterns) if progress_patterns else "No progress reporting detected" |
| ) |
| if progress_patterns: |
| score += 1 |
|
|
| risky = { |
| "os.system": "uses os.system", |
| "eval": "uses eval", |
| "exec": "uses exec", |
| "pickle.loads": "loads unrestricted pickle data", |
| "shutil.rmtree": "can recursively delete folders", |
| "os.remove": "can delete files", |
| "pathlib.path.unlink": "can delete files", |
| } |
| for call_name in call_names: |
| for pattern, message in risky.items(): |
| if call_name == pattern or call_name.endswith("." + pattern): |
| warnings.append(message) |
| score -= 2 |
| for call in calls: |
| if _call_name(call).casefold().endswith(("subprocess.run", "subprocess.popen", "subprocess.call")): |
| shell_true = any( |
| keyword.arg == "shell" |
| and isinstance(keyword.value, ast.Constant) |
| and keyword.value.value is True |
| for keyword in call.keywords |
| ) |
| warnings.append( |
| "launches another process with a shell" if shell_true |
| else "launches another process" |
| ) |
| score -= 2 if shell_true else 1 |
|
|
| if selected_configs: |
| valid_configs = [ |
| name for name in selected_configs |
| if name in result.config_files and (folder / name).is_file() |
| ] |
| result.selected_configs = valid_configs |
| if valid_configs: |
| reasons.append("Selected configuration files are present.") |
|
|
| result.score = max(1, min(10, score)) |
| result.reasons = reasons or ["Only basic Python compatibility was detected."] |
| result.warnings = sorted(set(warnings)) |
| return result |
|
|
|
|
| class ExternalToolStore: |
| def __init__(self, root: Path) -> None: |
| self.path = root.resolve() / "config" / "external_tools.json" |
|
|
| def load(self) -> list[dict[str, Any]]: |
| try: |
| payload = json.loads(self.path.read_text(encoding="utf-8")) |
| tools = payload.get("tools", []) |
| return [dict(item) for item in tools if isinstance(item, dict)] |
| except (OSError, ValueError, TypeError, json.JSONDecodeError): |
| return [] |
|
|
| def save_connector( |
| self, |
| *, |
| name: str, |
| description: str, |
| analysis: ToolAnalysis, |
| arguments: list[str], |
| required_arguments: list[str], |
| ) -> dict[str, Any]: |
| safe_id = re.sub(r"[^a-z0-9]+", "_", name.casefold()).strip("_") or "tool" |
| tool_id = "external_" + safe_id |
| entry = (Path(analysis.folder) / analysis.selected_entry).resolve() |
| entry.relative_to(Path(analysis.folder).resolve()) |
| record = { |
| "id": tool_id, |
| "name": name.strip(), |
| "description": description.strip() or "User-connected external Python tool.", |
| "category": "External", |
| "entry_function": entry.stem, |
| "arguments": arguments, |
| "required_arguments": required_arguments, |
| "capabilities": ["external_script", "progress", "cancel"], |
| "requires_confirmation": True, |
| "enabled": True, |
| "demo": False, |
| "backend": { |
| "type": "script", |
| "path": str(entry), |
| "root": analysis.folder, |
| }, |
| "analysis": asdict(analysis), |
| } |
| tools = self.load() |
| replaced = False |
| for index, existing in enumerate(tools): |
| if existing.get("id") == tool_id: |
| tools[index] = record |
| replaced = True |
| break |
| if not replaced: |
| tools.append(record) |
| self.path.parent.mkdir(parents=True, exist_ok=True) |
| temporary = self.path.with_suffix(".tmp") |
| temporary.write_text(json.dumps({"tools": tools}, indent=2), encoding="utf-8") |
| temporary.replace(self.path) |
| return record |
|
|