Add S-expression recipe support
- Add format detection that correctly handles ; comments - Import artdag.sexp parser/compiler with YAML fallback - Add execute_step_sexp and run_plan_sexp Celery tasks - Update recipe upload to handle both S-expr and YAML formats Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,45 @@
|
||||
"""
|
||||
Recipe Service - business logic for recipe management.
|
||||
|
||||
Recipes are content-addressed S-expression files stored in the cache (and IPFS).
|
||||
The recipe ID is the content hash of the S-expression file.
|
||||
Recipes are content-addressed files stored in the cache (and IPFS).
|
||||
Supports both S-expression (.sexp) and YAML (.yaml) formats.
|
||||
The recipe ID is the content hash of the file.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
|
||||
from artdag.sexp import compile_string, parse, serialize, CompileError, ParseError
|
||||
# Try to import S-expression support, fall back to YAML
|
||||
try:
|
||||
from artdag.sexp import compile_string, parse, serialize, CompileError, ParseError
|
||||
SEXP_AVAILABLE = True
|
||||
except ImportError:
|
||||
SEXP_AVAILABLE = False
|
||||
compile_string = None
|
||||
parse = None
|
||||
serialize = None
|
||||
CompileError = Exception
|
||||
ParseError = Exception
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def _is_sexp_format(content: str) -> bool:
|
||||
"""
|
||||
Detect if content is S-expression format.
|
||||
|
||||
Skips leading comments (lines starting with ;) and whitespace.
|
||||
Returns True if the first non-comment content starts with (.
|
||||
"""
|
||||
for line in content.split('\n'):
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue # Skip empty lines
|
||||
if stripped.startswith(';'):
|
||||
continue # Skip comments
|
||||
return stripped.startswith('(')
|
||||
return False
|
||||
|
||||
|
||||
class RecipeService:
|
||||
@@ -32,19 +62,31 @@ class RecipeService:
|
||||
return None
|
||||
|
||||
with open(path) as f:
|
||||
sexp_content = f.read()
|
||||
content = f.read()
|
||||
|
||||
# Compile S-expression recipe
|
||||
try:
|
||||
compiled = compile_string(sexp_content)
|
||||
recipe_data = compiled.to_dict()
|
||||
except (ParseError, CompileError) as e:
|
||||
# Return basic error info
|
||||
return {"error": str(e), "recipe_id": recipe_id}
|
||||
# Try to detect format and parse
|
||||
recipe_data = None
|
||||
is_sexp = _is_sexp_format(content)
|
||||
|
||||
if is_sexp and SEXP_AVAILABLE:
|
||||
# Parse as S-expression
|
||||
try:
|
||||
compiled = compile_string(content)
|
||||
recipe_data = compiled.to_dict()
|
||||
recipe_data["sexp"] = content
|
||||
except (ParseError, CompileError) as e:
|
||||
return {"error": str(e), "recipe_id": recipe_id}
|
||||
else:
|
||||
# Parse as YAML
|
||||
try:
|
||||
recipe_data = yaml.safe_load(content)
|
||||
if not isinstance(recipe_data, dict):
|
||||
return {"error": "Invalid recipe format", "recipe_id": recipe_id}
|
||||
except yaml.YAMLError as e:
|
||||
return {"error": str(e), "recipe_id": recipe_id}
|
||||
|
||||
# Add the recipe_id to the data for convenience
|
||||
recipe_data["recipe_id"] = recipe_id
|
||||
recipe_data["sexp"] = sexp_content # Keep original S-expression
|
||||
|
||||
# Get IPFS CID if available
|
||||
ipfs_cid = self.cache.get_ipfs_cid(recipe_id)
|
||||
@@ -53,7 +95,11 @@ class RecipeService:
|
||||
|
||||
# Compute step_count from nodes
|
||||
nodes = recipe_data.get("dag", {}).get("nodes", [])
|
||||
recipe_data["step_count"] = len(nodes) if isinstance(nodes, list) else 0
|
||||
if not nodes:
|
||||
nodes = recipe_data.get("nodes", [])
|
||||
if not nodes:
|
||||
nodes = recipe_data.get("pipeline", [])
|
||||
recipe_data["step_count"] = len(nodes) if isinstance(nodes, (list, dict)) else 0
|
||||
|
||||
return recipe_data
|
||||
|
||||
@@ -93,33 +139,53 @@ class RecipeService:
|
||||
|
||||
async def upload_recipe(
|
||||
self,
|
||||
sexp_content: str,
|
||||
content: str,
|
||||
uploader: str,
|
||||
name: str = None,
|
||||
description: str = None,
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Upload a recipe from S-expression content.
|
||||
Upload a recipe from S-expression or YAML content.
|
||||
|
||||
The recipe is stored in the cache and optionally pinned to IPFS.
|
||||
Returns (recipe_id, error_message).
|
||||
"""
|
||||
# Validate and compile S-expression
|
||||
try:
|
||||
compiled = compile_string(sexp_content)
|
||||
except ParseError as e:
|
||||
return None, f"Parse error: {e}"
|
||||
except CompileError as e:
|
||||
return None, f"Compile error: {e}"
|
||||
# Detect format
|
||||
is_sexp = _is_sexp_format(content)
|
||||
|
||||
# For now, store the original S-expression content
|
||||
# The uploader info is not embedded in the S-expression (kept in metadata)
|
||||
# In a full implementation, we might add a :uploader keyword
|
||||
if is_sexp and SEXP_AVAILABLE:
|
||||
# Validate S-expression
|
||||
try:
|
||||
compiled = compile_string(content)
|
||||
except ParseError as e:
|
||||
return None, f"Parse error: {e}"
|
||||
except CompileError as e:
|
||||
return None, f"Compile error: {e}"
|
||||
suffix = ".sexp"
|
||||
else:
|
||||
# Validate YAML
|
||||
try:
|
||||
recipe_data = yaml.safe_load(content)
|
||||
if not isinstance(recipe_data, dict):
|
||||
return None, "Recipe must be a YAML dictionary"
|
||||
|
||||
# Add uploader info
|
||||
recipe_data["uploader"] = uploader
|
||||
if name:
|
||||
recipe_data["name"] = name
|
||||
if description:
|
||||
recipe_data["description"] = description
|
||||
|
||||
# Serialize back
|
||||
content = yaml.dump(recipe_data, default_flow_style=False)
|
||||
except yaml.YAMLError as e:
|
||||
return None, f"Invalid YAML: {e}"
|
||||
suffix = ".yaml"
|
||||
|
||||
# Write to temp file for caching
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".sexp", mode="w") as tmp:
|
||||
tmp.write(sexp_content)
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, mode="w") as tmp:
|
||||
tmp.write(content)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
# Store in cache (content-addressed, auto-pins to IPFS)
|
||||
@@ -165,10 +231,15 @@ class RecipeService:
|
||||
except Exception as e:
|
||||
return False, f"Failed to delete: {e}"
|
||||
|
||||
def parse_recipe(self, sexp_content: str) -> Dict[str, Any]:
|
||||
"""Parse and compile recipe S-expression content."""
|
||||
compiled = compile_string(sexp_content)
|
||||
return compiled.to_dict()
|
||||
def parse_recipe(self, content: str) -> Dict[str, Any]:
|
||||
"""Parse recipe content (S-expression or YAML)."""
|
||||
is_sexp = _is_sexp_format(content)
|
||||
|
||||
if is_sexp and SEXP_AVAILABLE:
|
||||
compiled = compile_string(content)
|
||||
return compiled.to_dict()
|
||||
else:
|
||||
return yaml.safe_load(content)
|
||||
|
||||
def build_dag(self, recipe: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user