CmdForge/src/smarttools/manifest.py

277 lines
7.3 KiB
Python

"""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}