"""Project manifest (smarttools.yaml) handling. Manages project-level tool dependencies and overrides. """ import re from dataclasses import dataclass, field from pathlib import Path from typing import Optional, List, Dict import yaml MANIFEST_FILENAME = "smarttools.yaml" @dataclass class Dependency: """A tool dependency declaration.""" name: str # owner/name format (e.g., "rob/summarize") version: str = "*" # Version constraint (e.g., ">=1.0.0", "^1.2.0") @property def owner(self) -> Optional[str]: """Extract owner from name if present.""" if "/" in self.name: return self.name.split("/")[0] return None @property def tool_name(self) -> str: """Extract tool name without owner.""" if "/" in self.name: return self.name.split("/")[1] return self.name def to_dict(self) -> dict: return { "name": self.name, "version": self.version } @classmethod def from_dict(cls, data: dict) -> "Dependency": if isinstance(data, str): # Simple string format: "rob/summarize" or "rob/summarize@^1.0.0" if "@" in data: name, version = data.rsplit("@", 1) return cls(name=name, version=version) return cls(name=data) return cls( name=data["name"], version=data.get("version", "*") ) @dataclass class ToolOverride: """Runtime overrides for a tool.""" provider: Optional[str] = None # Future: other overrides like timeout, retries, etc. def to_dict(self) -> dict: d = {} if self.provider: d["provider"] = self.provider return d @classmethod def from_dict(cls, data: dict) -> "ToolOverride": return cls( provider=data.get("provider") ) @dataclass class Manifest: """Project manifest (smarttools.yaml).""" name: str = "my-project" version: str = "1.0.0" dependencies: List[Dependency] = field(default_factory=list) overrides: Dict[str, ToolOverride] = field(default_factory=dict) def to_dict(self) -> dict: d = { "name": self.name, "version": self.version, } if self.dependencies: d["dependencies"] = [dep.to_dict() for dep in self.dependencies] if self.overrides: d["overrides"] = { name: override.to_dict() for name, override in self.overrides.items() } return d @classmethod def from_dict(cls, data: dict) -> "Manifest": dependencies = [] for dep in data.get("dependencies", []): dependencies.append(Dependency.from_dict(dep)) overrides = {} for name, override_data in data.get("overrides", {}).items(): overrides[name] = ToolOverride.from_dict(override_data) return cls( name=data.get("name", "my-project"), version=data.get("version", "1.0.0"), dependencies=dependencies, overrides=overrides ) def get_override(self, tool_name: str) -> Optional[ToolOverride]: """Get override for a tool by name (checks both full and short names).""" # Try exact match first if tool_name in self.overrides: return self.overrides[tool_name] # Try matching just the tool name part (without owner) short_name = tool_name.split("/")[-1] if "/" in tool_name else tool_name for override_name, override in self.overrides.items(): override_short = override_name.split("/")[-1] if "/" in override_name else override_name if override_short == short_name: return override return None def add_dependency(self, name: str, version: str = "*") -> None: """Add or update a dependency.""" # Check if already exists for dep in self.dependencies: if dep.name == name: dep.version = version return self.dependencies.append(Dependency(name=name, version=version)) def find_manifest(start_dir: Optional[Path] = None) -> Optional[Path]: """ Find smarttools.yaml by searching up from start_dir. Args: start_dir: Directory to start searching from (default: cwd) Returns: Path to manifest file, or None if not found """ if start_dir is None: start_dir = Path.cwd() current = start_dir.resolve() while current != current.parent: manifest_path = current / MANIFEST_FILENAME if manifest_path.exists(): return manifest_path current = current.parent # Check root manifest_path = current / MANIFEST_FILENAME if manifest_path.exists(): return manifest_path return None def load_manifest(path: Optional[Path] = None) -> Optional[Manifest]: """ Load a project manifest. Args: path: Path to manifest file, or None to search Returns: Manifest object, or None if not found """ if path is None: path = find_manifest() if path is None or not path.exists(): return None try: data = yaml.safe_load(path.read_text()) or {} return Manifest.from_dict(data) except Exception as e: print(f"Warning: Error loading manifest: {e}") return None def save_manifest(manifest: Manifest, path: Optional[Path] = None) -> Path: """ Save a manifest to disk. Args: manifest: Manifest to save path: Path to save to (default: ./smarttools.yaml) Returns: Path where manifest was saved """ if path is None: path = Path.cwd() / MANIFEST_FILENAME path.write_text(yaml.dump(manifest.to_dict(), default_flow_style=False, sort_keys=False)) return path def create_manifest( name: str = "my-project", version: str = "1.0.0", path: Optional[Path] = None ) -> Manifest: """ Create a new manifest. Args: name: Project name version: Project version path: Path to save to (optional) Returns: Created Manifest object """ manifest = Manifest(name=name, version=version) if path is not None: save_manifest(manifest, path) return manifest def parse_version_constraint(constraint: str) -> dict: """ Parse a version constraint string. Args: constraint: Version constraint (e.g., ">=1.0.0", "^1.2.3", "~1.2.0") Returns: Dict with operator and version parts """ constraint = constraint.strip() # Exact version if re.match(r'^\d+\.\d+\.\d+', constraint): return {"operator": "=", "version": constraint} # Any version if constraint == "*" or constraint == "latest": return {"operator": "*", "version": None} # Range operators patterns = [ (r'^>=(.+)$', ">="), (r'^<=(.+)$', "<="), (r'^>(.+)$', ">"), (r'^<(.+)$', "<"), (r'^\^(.+)$', "^"), # Compatible (same major) (r'^~(.+)$', "~"), # Approximately (same minor) ] for pattern, operator in patterns: match = re.match(pattern, constraint) if match: return {"operator": operator, "version": match.group(1)} # Default to exact match return {"operator": "=", "version": constraint}