Compare commits

...
2 Commits
Author SHA1 Message Date
Mervin Praison 9a5a432d96 Release v2.7.2 2025-12-29 11:40:55 +00:00
Mervin Praison 8cb5bbb605 Release v2.7.2 - update doctor examples with improved check listing and CI formatting
- Add doctor examples to main README with documentation link
- Sort checks by category in list_available_checks()
- Update registry method call from list_checks() to get_all_checks()
- Remove unused results variable in run_environment_checks()
- Fix f-string formatting in CI integration print statement
2025-12-29 02:19:16 +00:00
21 changed files with 3998 additions and 11 deletions

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.7.1" \
"praisonai>=2.7.2" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.7.1" \
"praisonai>=2.7.2" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.7.1" \
"praisonai>=2.7.2" \
"praisonai[ui]" \
"praisonai[crewai]"
+1
View File
@@ -30,6 +30,7 @@ examples/
| **Tools** | [python/tools/](python/tools/) | [📖](https://docs.praison.ai/tools) |
| **Code** | [python/code/](python/code/) | [📖](https://docs.praison.ai/code) |
| **YAML** | [yaml/](yaml/) | [📖](https://docs.praison.ai/features/yaml-workflows) |
| **Doctor** | [doctor/](doctor/) | [📖](https://docs.praison.ai/cli/doctor) |
## Running Examples
+65
View File
@@ -0,0 +1,65 @@
# PraisonAI Doctor Examples
Examples demonstrating the PraisonAI Doctor health check and diagnostics system.
## CLI Examples
| Command | Description |
|---------|-------------|
| `praisonai doctor` | Run all fast health checks |
| `praisonai doctor --version` | Show doctor version |
| `praisonai doctor --list-checks` | List all available checks |
| `praisonai doctor env` | Check environment configuration |
| `praisonai doctor config` | Validate configuration files |
| `praisonai doctor tools` | Check tool availability |
| `praisonai doctor db` | Check database drivers |
| `praisonai doctor mcp` | Check MCP configuration |
| `praisonai doctor obs` | Check observability providers |
| `praisonai doctor skills` | Check agent skills |
| `praisonai doctor memory` | Check memory storage |
| `praisonai doctor permissions` | Check filesystem permissions |
| `praisonai doctor network` | Check network connectivity |
| `praisonai doctor performance` | Check import times |
| `praisonai doctor ci` | CI mode with JSON output |
| `praisonai doctor selftest` | Test agent functionality |
## Global Flags
| Flag | Description |
|------|-------------|
| `--json` | Output in JSON format |
| `--format text\|json` | Output format |
| `--output PATH` | Write report to file |
| `--deep` | Enable deeper probes |
| `--timeout SEC` | Per-check timeout |
| `--strict` | Treat warnings as failures |
| `--quiet` | Minimal output |
| `--no-color` | Disable colors |
| `--only IDS` | Only run these checks |
| `--skip IDS` | Skip these checks |
## Python Examples
| File | Description |
|------|-------------|
| [basic_doctor.py](basic_doctor.py) | Programmatic health checks |
| [ci_integration.py](ci_integration.py) | CI/CD pipeline integration |
## Quick Start
```bash
# Run basic health checks
praisonai doctor
# Run with JSON output
praisonai doctor --json
# Run specific checks
praisonai doctor --only python_version,openai_api_key
# CI mode
praisonai doctor ci
# Save report to file
praisonai doctor --output report.json
```
+5 -2
View File
@@ -60,7 +60,7 @@ def run_environment_checks():
engine = DoctorEngine(config)
# Run only environment category
results = engine.run_checks(categories=[CheckCategory.ENVIRONMENT])
engine.run_checks(categories=[CheckCategory.ENVIRONMENT])
report = engine.generate_report()
formatter = get_formatter("text", no_color=True)
@@ -130,7 +130,10 @@ def list_available_checks():
register_all_checks()
registry = get_registry()
checks = registry.list_checks()
checks = registry.get_all_checks()
# Sort by category
checks.sort(key=lambda c: c.category.value)
current_category = None
for check in checks:
+1 -1
View File
@@ -33,7 +33,7 @@ def run_doctor_ci():
# Print summary
summary = report.get("summary", {})
print(f"\nDoctor CI Results:")
print("\nDoctor CI Results:")
print(f" Total checks: {summary.get('total', 0)}")
print(f" Passed: {summary.get('passed', 0)}")
print(f" Warnings: {summary.get('warnings', 0)}")
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""
PraisonAI Endpoints Example
This example demonstrates how to:
1. Start the recipe server
2. Use the endpoints CLI to interact with it
3. Invoke recipes via HTTP
Prerequisites:
- pip install praisonai[serve]
- Set OPENAI_API_KEY environment variable
Usage:
# Terminal 1: Start server
praisonai recipe serve --port 8765
# Terminal 2: Run this example
python endpoints_example.py
"""
import json
import os
import subprocess
import sys
import urllib.request
import urllib.error
# Configuration
SERVER_URL = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
def check_server_health():
"""Check if the server is running."""
try:
req = urllib.request.Request(f"{SERVER_URL}/health")
with urllib.request.urlopen(req, timeout=5) as response:
data = json.loads(response.read().decode())
return data.get("status") == "healthy"
except Exception:
return False
def list_endpoints():
"""List available endpoints using CLI."""
print("\n=== Listing Endpoints (CLI) ===")
result = subprocess.run(
["python", "-m", "praisonai.cli.main", "endpoints", "list", "--format", "json"],
capture_output=True,
text=True,
)
if result.returncode == 0:
recipes = json.loads(result.stdout)
print(f"Found {len(recipes)} endpoints:")
for r in recipes:
print(f" - {r['name']} ({r['version']}): {r.get('description', '')[:50]}")
return recipes
else:
print(f"Error: {result.stderr}")
return []
def describe_endpoint(name: str):
"""Describe an endpoint using CLI."""
print(f"\n=== Describing Endpoint: {name} ===")
result = subprocess.run(
["python", "-m", "praisonai.cli.main", "endpoints", "describe", name, "--url", SERVER_URL],
capture_output=True,
text=True,
)
if result.returncode == 0:
info = json.loads(result.stdout)
print(f"Name: {info.get('name')}")
print(f"Version: {info.get('version')}")
print(f"Description: {info.get('description')}")
return info
else:
print(f"Error: {result.stderr}")
return None
def invoke_endpoint_cli(name: str, input_data: dict):
"""Invoke an endpoint using CLI."""
print(f"\n=== Invoking Endpoint (CLI): {name} ===")
result = subprocess.run(
[
"python", "-m", "praisonai.cli.main", "endpoints", "invoke", name,
"--url", SERVER_URL,
"--input-json", json.dumps(input_data),
"--json",
"--dry-run", # Use dry-run for example
],
capture_output=True,
text=True,
)
if result.returncode == 0:
response = json.loads(result.stdout)
print(f"Run ID: {response.get('run_id')}")
print(f"Status: {response.get('status')}")
return response
else:
print(f"Error: {result.stderr}")
return None
def invoke_endpoint_http(name: str, input_data: dict):
"""Invoke an endpoint using HTTP directly."""
print(f"\n=== Invoking Endpoint (HTTP): {name} ===")
body = json.dumps({
"recipe": name,
"input": input_data,
"options": {"dry_run": True},
}).encode()
req = urllib.request.Request(
f"{SERVER_URL}/v1/recipes/run",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as response:
data = json.loads(response.read().decode())
print(f"Run ID: {data.get('run_id')}")
print(f"Status: {data.get('status')}")
print(f"Output: {data.get('output')}")
return data
except urllib.error.HTTPError as e:
print(f"HTTP Error: {e.code} - {e.read().decode()}")
return None
def main():
"""Main example function."""
print("=" * 60)
print("PraisonAI Endpoints Example")
print("=" * 60)
# Check if server is running
print("\n=== Checking Server Health ===")
if not check_server_health():
print(f"Server not running at {SERVER_URL}")
print("\nPlease start the server first:")
print(" praisonai recipe serve --port 8765")
sys.exit(1)
print("✓ Server is healthy")
# List endpoints
recipes = list_endpoints()
if not recipes:
print("No recipes available")
sys.exit(1)
# Use first available recipe
recipe_name = recipes[0]["name"]
# Describe endpoint
describe_endpoint(recipe_name)
# Invoke via CLI
invoke_endpoint_cli(recipe_name, {"query": "Hello from CLI"})
# Invoke via HTTP
invoke_endpoint_http(recipe_name, {"query": "Hello from HTTP"})
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.1.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.1.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.2.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+4
View File
@@ -18,6 +18,7 @@ __all__ = [
'DeployConfig',
'DeployType',
'CloudProvider',
'recipe',
]
# Lazy loading for heavy imports
@@ -38,6 +39,9 @@ def __getattr__(name):
elif name == 'CloudProvider':
from .deploy import CloudProvider
return CloudProvider
elif name == 'recipe':
from .recipe import core as recipe_module
return recipe_module
# Try praisonaiagents exports
try:
@@ -0,0 +1,578 @@
"""
Endpoints CLI Feature Handler
Provides CLI commands for interacting with recipe endpoints:
- list: List available recipe endpoints
- describe: Show endpoint details and schema
- invoke: Call an endpoint
- health: Check endpoint server health
All commands use the canonical `praisonai endpoints` prefix.
Why this feature is valuable:
- DX: Client invocation from any language/script
- Ops: Health checks, monitoring, automation
- Polyglot: Non-Python clients can invoke recipes via HTTP
- Testing: Easy endpoint verification without code
Architecture notes:
- Optional extras only (no server deps in core)
- Lazy imports for all HTTP client code
- No impact on praisonaiagents import time
"""
import json
import os
import sys
from typing import Any, Dict, List, Optional
class EndpointsHandler:
"""
CLI handler for endpoints operations.
Commands:
- list: List available recipe endpoints
- describe: Show endpoint details
- invoke: Call an endpoint
- health: Check server health
"""
# Exit codes
EXIT_SUCCESS = 0
EXIT_GENERAL_ERROR = 1
EXIT_VALIDATION_ERROR = 2
EXIT_RUNTIME_ERROR = 3
EXIT_AUTH_ERROR = 4
EXIT_NOT_FOUND = 7
EXIT_CONNECTION_ERROR = 8
DEFAULT_URL = "http://localhost:8765"
def __init__(self):
"""Initialize the handler."""
self._base_url = None
self._api_key = None
@property
def base_url(self) -> str:
"""Get base URL from env or default."""
if self._base_url:
return self._base_url
return os.environ.get("PRAISONAI_ENDPOINTS_URL", self.DEFAULT_URL)
@property
def api_key(self) -> Optional[str]:
"""Get API key from env."""
if self._api_key:
return self._api_key
return os.environ.get("PRAISONAI_ENDPOINTS_API_KEY")
def handle(self, args: List[str]) -> int:
"""
Handle endpoints subcommand.
Args:
args: Command arguments
Returns:
Exit code
"""
if not args:
self._print_help()
return self.EXIT_SUCCESS
command = args[0]
remaining = args[1:]
commands = {
"list": self.cmd_list,
"describe": self.cmd_describe,
"invoke": self.cmd_invoke,
"health": self.cmd_health,
"help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"--help": lambda _: self._print_help() or self.EXIT_SUCCESS,
"-h": lambda _: self._print_help() or self.EXIT_SUCCESS,
}
if command in commands:
return commands[command](remaining)
else:
self._print_error(f"Unknown command: {command}")
self._print_help()
return self.EXIT_GENERAL_ERROR
def _print_help(self):
"""Print help message."""
help_text = """
[bold cyan]PraisonAI Endpoints[/bold cyan]
Client CLI for interacting with recipe endpoints.
[bold]Usage:[/bold]
praisonai endpoints <command> [options]
[bold]Commands:[/bold]
list List available recipe endpoints
describe <recipe> Show endpoint details and schema
invoke <recipe> Call an endpoint
health Check server health
[bold]List Options:[/bold]
--format json Output as JSON
--tags <a,b> Filter by tags (comma-separated)
--url <url> Server URL (default: http://localhost:8765)
[bold]Describe Options:[/bold]
--schema Show input/output schema only
--url <url> Server URL
[bold]Invoke Options:[/bold]
--input <path> Input file path
--input-json <j> Input as JSON string
--config k=v Config override (repeatable)
--json Output as JSON
--stream Stream output events (SSE)
--url <url> Server URL
--api-key <key> API key for auth (or set PRAISONAI_ENDPOINTS_API_KEY)
--dry-run Validate without executing
[bold]Health Options:[/bold]
--url <url> Server URL
[bold]Environment Variables:[/bold]
PRAISONAI_ENDPOINTS_URL Default server URL
PRAISONAI_ENDPOINTS_API_KEY API key for authentication
[bold]Examples:[/bold]
praisonai endpoints list
praisonai endpoints list --format json --tags audio,video
praisonai endpoints describe ai-podcast-cleaner
praisonai endpoints describe ai-podcast-cleaner --schema
praisonai endpoints invoke ai-podcast-cleaner --input ./audio.mp3
praisonai endpoints invoke ai-podcast-cleaner --input-json '{"text": "hello"}'
praisonai endpoints invoke ai-podcast-cleaner --input ./audio.mp3 --stream
praisonai endpoints health
praisonai endpoints health --url http://localhost:8000
"""
self._print_rich(help_text)
def _print_rich(self, text: str):
"""Print with rich formatting if available."""
try:
from rich import print as rprint
rprint(text)
except ImportError:
import re
plain = re.sub(r'\[/?[^\]]+\]', '', text)
print(plain)
def _print_error(self, message: str):
"""Print error message."""
try:
from rich import print as rprint
rprint(f"[red]Error: {message}[/red]")
except ImportError:
print(f"Error: {message}", file=sys.stderr)
def _print_success(self, message: str):
"""Print success message."""
try:
from rich import print as rprint
rprint(f"[green]✓ {message}[/green]")
except ImportError:
print(f"✓ {message}")
def _print_json(self, data: Any):
"""Print JSON output."""
print(json.dumps(data, indent=2, default=str))
def _parse_args(self, args: List[str], spec: Dict[str, Any]) -> Dict[str, Any]:
"""Parse command arguments based on spec."""
result = {k: v.get("default") for k, v in spec.items()}
positional_keys = [k for k, v in spec.items() if v.get("positional")]
positional_idx = 0
# Handle repeatable args
for k, v in spec.items():
if v.get("repeatable"):
result[k] = []
i = 0
while i < len(args):
arg = args[i]
if arg.startswith("--"):
key = arg[2:].replace("-", "_")
if key in spec:
if spec[key].get("flag"):
result[key] = True
elif spec[key].get("repeatable") and i + 1 < len(args):
result[key].append(args[i + 1])
i += 1
elif i + 1 < len(args):
result[key] = args[i + 1]
i += 1
i += 1
elif arg.startswith("-") and len(arg) == 2:
for key, val in spec.items():
if val.get("short") == arg:
if val.get("flag"):
result[key] = True
elif i + 1 < len(args):
result[key] = args[i + 1]
i += 1
break
i += 1
else:
if positional_idx < len(positional_keys):
result[positional_keys[positional_idx]] = arg
positional_idx += 1
i += 1
return result
def _make_request(
self,
method: str,
path: str,
url: Optional[str] = None,
api_key: Optional[str] = None,
json_data: Optional[Dict] = None,
stream: bool = False,
) -> Dict[str, Any]:
"""Make HTTP request to endpoint server."""
try:
import urllib.request
import urllib.error
except ImportError:
return {"error": "urllib not available"}
base = url or self.base_url
full_url = f"{base.rstrip('/')}{path}"
headers = {"Content-Type": "application/json"}
key = api_key or self.api_key
if key:
headers["X-API-Key"] = key
data = None
if json_data:
data = json.dumps(json_data).encode("utf-8")
try:
req = urllib.request.Request(
full_url,
data=data,
headers=headers,
method=method,
)
with urllib.request.urlopen(req, timeout=30) as response:
body = response.read().decode("utf-8")
return {"status": response.status, "data": json.loads(body) if body else {}}
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8") if e.fp else ""
try:
error_data = json.loads(body) if body else {}
except json.JSONDecodeError:
error_data = {"message": body}
return {"status": e.code, "error": error_data}
except urllib.error.URLError as e:
return {"status": 0, "error": {"message": f"Connection error: {e.reason}"}}
except Exception as e:
return {"status": 0, "error": {"message": str(e)}}
def _stream_request(
self,
path: str,
url: Optional[str] = None,
api_key: Optional[str] = None,
json_data: Optional[Dict] = None,
):
"""Make streaming HTTP request."""
try:
import urllib.request
except ImportError:
yield {"error": "urllib not available"}
return
base = url or self.base_url
full_url = f"{base.rstrip('/')}{path}"
headers = {"Content-Type": "application/json", "Accept": "text/event-stream"}
key = api_key or self.api_key
if key:
headers["X-API-Key"] = key
data = json.dumps(json_data).encode("utf-8") if json_data else None
try:
req = urllib.request.Request(full_url, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=300) as response:
buffer = ""
for line in response:
line = line.decode("utf-8")
buffer += line
if buffer.endswith("\n\n"):
# Parse SSE event
event_type = "message"
event_data = ""
for part in buffer.strip().split("\n"):
if part.startswith("event:"):
event_type = part[6:].strip()
elif part.startswith("data:"):
event_data = part[5:].strip()
if event_data:
try:
yield {"event": event_type, "data": json.loads(event_data)}
except json.JSONDecodeError:
yield {"event": event_type, "data": event_data}
buffer = ""
except Exception as e:
yield {"error": str(e)}
def cmd_list(self, args: List[str]) -> int:
"""List available recipe endpoints."""
spec = {
"format": {"default": "table"},
"tags": {"default": None},
"url": {"default": None},
}
parsed = self._parse_args(args, spec)
# Build query params
path = "/v1/recipes"
if parsed["tags"]:
path += f"?tags={parsed['tags']}"
result = self._make_request("GET", path, url=parsed["url"])
if result.get("error"):
self._print_error(result["error"].get("message", str(result["error"])))
return self.EXIT_CONNECTION_ERROR
recipes = result.get("data", {}).get("recipes", [])
if parsed["format"] == "json":
self._print_json(recipes)
return self.EXIT_SUCCESS
if not recipes:
print("No endpoints available.")
return self.EXIT_SUCCESS
try:
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Available Endpoints")
table.add_column("Name", style="cyan")
table.add_column("Version", style="green")
table.add_column("Description")
table.add_column("Tags", style="yellow")
for r in recipes:
tags = r.get("tags", [])
table.add_row(
r.get("name", ""),
r.get("version", ""),
(r.get("description", "")[:50] + "...") if len(r.get("description", "")) > 50 else r.get("description", ""),
", ".join(tags[:3]) if tags else "",
)
console.print(table)
except ImportError:
for r in recipes:
print(f"{r.get('name')} ({r.get('version')}): {r.get('description', '')}")
return self.EXIT_SUCCESS
def cmd_describe(self, args: List[str]) -> int:
"""Describe a recipe endpoint."""
spec = {
"recipe": {"positional": True, "default": ""},
"schema": {"flag": True, "default": False},
"url": {"default": None},
}
parsed = self._parse_args(args, spec)
if not parsed["recipe"]:
self._print_error("Recipe name required")
return self.EXIT_VALIDATION_ERROR
# Get schema if requested
if parsed["schema"]:
path = f"/v1/recipes/{parsed['recipe']}/schema"
else:
path = f"/v1/recipes/{parsed['recipe']}"
result = self._make_request("GET", path, url=parsed["url"])
if result.get("status") == 404:
self._print_error(f"Endpoint not found: {parsed['recipe']}")
return self.EXIT_NOT_FOUND
if result.get("error"):
self._print_error(result["error"].get("message", str(result["error"])))
return self.EXIT_CONNECTION_ERROR
data = result.get("data", {})
self._print_json(data)
return self.EXIT_SUCCESS
def cmd_invoke(self, args: List[str]) -> int:
"""Invoke a recipe endpoint."""
spec = {
"recipe": {"positional": True, "default": ""},
"input": {"short": "-i", "default": None},
"input_json": {"default": None},
"config": {"repeatable": True, "default": []},
"json": {"flag": True, "default": False},
"stream": {"flag": True, "default": False},
"url": {"default": None},
"api_key": {"default": None},
"dry_run": {"flag": True, "default": False},
}
parsed = self._parse_args(args, spec)
if not parsed["recipe"]:
self._print_error("Recipe name required")
return self.EXIT_VALIDATION_ERROR
# Build input data
input_data = {}
if parsed["input_json"]:
try:
input_data = json.loads(parsed["input_json"])
except json.JSONDecodeError:
self._print_error("Invalid JSON in --input-json")
return self.EXIT_VALIDATION_ERROR
elif parsed["input"]:
input_data = {"input": parsed["input"]}
# Build config from --config key=value pairs
config = {}
for cfg in parsed["config"]:
if "=" in cfg:
k, v = cfg.split("=", 1)
config[k] = v
# Build request body
body = {
"recipe": parsed["recipe"],
"input": input_data,
"config": config,
"options": {"dry_run": parsed["dry_run"]},
}
if parsed["stream"]:
return self._invoke_stream(body, parsed)
# Sync invocation
result = self._make_request(
"POST",
"/v1/recipes/run",
url=parsed["url"],
api_key=parsed["api_key"],
json_data=body,
)
if result.get("status") == 401:
self._print_error("Authentication required. Use --api-key or set PRAISONAI_ENDPOINTS_API_KEY")
return self.EXIT_AUTH_ERROR
if result.get("status") == 404:
self._print_error(f"Endpoint not found: {parsed['recipe']}")
return self.EXIT_NOT_FOUND
if result.get("error"):
self._print_error(result["error"].get("message", str(result["error"])))
return self.EXIT_CONNECTION_ERROR
data = result.get("data", {})
if parsed["json"]:
self._print_json(data)
else:
if data.get("ok"):
self._print_success(f"Endpoint '{parsed['recipe']}' invoked successfully")
print(f" Run ID: {data.get('run_id')}")
print(f" Status: {data.get('status')}")
if data.get("output"):
print(f" Output: {data.get('output')}")
else:
self._print_error(f"Invocation failed: {data.get('error')}")
return self.EXIT_RUNTIME_ERROR
return self.EXIT_SUCCESS if data.get("ok") else self.EXIT_RUNTIME_ERROR
def _invoke_stream(self, body: Dict, parsed: Dict) -> int:
"""Handle streaming invocation."""
print("Streaming output...")
for event in self._stream_request(
"/v1/recipes/stream",
url=parsed["url"],
api_key=parsed["api_key"],
json_data=body,
):
if event.get("error"):
self._print_error(event["error"])
return self.EXIT_CONNECTION_ERROR
event_type = event.get("event", "message")
data = event.get("data", {})
if parsed["json"]:
print(json.dumps({"event": event_type, "data": data}))
else:
if event_type == "started":
print(f" Started: {data.get('run_id')}")
elif event_type == "progress":
print(f" [{data.get('step')}] {data.get('message', '')}")
elif event_type == "completed":
self._print_success(f"Completed: {data.get('status')}")
elif event_type == "error":
self._print_error(data.get("message", "Unknown error"))
return self.EXIT_RUNTIME_ERROR
return self.EXIT_SUCCESS
def cmd_health(self, args: List[str]) -> int:
"""Check endpoint server health."""
spec = {
"url": {"default": None},
}
parsed = self._parse_args(args, spec)
result = self._make_request("GET", "/health", url=parsed["url"])
if result.get("error"):
self._print_error(f"Server unhealthy: {result['error'].get('message', str(result['error']))}")
return self.EXIT_CONNECTION_ERROR
data = result.get("data", {})
if data.get("status") == "healthy":
self._print_success("Server healthy")
print(f" Service: {data.get('service')}")
print(f" Version: {data.get('version')}")
return self.EXIT_SUCCESS
else:
self._print_error(f"Server unhealthy: {data}")
return self.EXIT_RUNTIME_ERROR
def handle_endpoints_command(args: List[str]) -> int:
"""Entry point for endpoints command."""
handler = EndpointsHandler()
return handler.handle(args)
File diff suppressed because it is too large. Load diff
+13 -1
View File
@@ -698,7 +698,7 @@ class PraisonAI:
return default_args
# Define special commands
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'audio', 'embed', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor']
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor']
parser = argparse.ArgumentParser(prog="praisonai", description="praisonAI command-line interface")
parser.add_argument("--framework", choices=["crewai", "autogen", "praisonai"], help="Specify the framework")
@@ -1186,6 +1186,18 @@ class PraisonAI:
exit_code = handle_templates_command(unknown_args)
sys.exit(exit_code)
elif args.command == 'recipe':
# Recipe command - new unified recipe system
from .features.recipe import handle_recipe_command
exit_code = handle_recipe_command(unknown_args)
sys.exit(exit_code)
elif args.command == 'endpoints':
# Endpoints command - client CLI for recipe endpoints
from .features.endpoints import handle_endpoints_command
exit_code = handle_endpoints_command(unknown_args)
sys.exit(exit_code)
elif args.command == 'agents':
# Agents command - run multiple agents with custom definitions
if not PRAISONAI_AVAILABLE:
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.7.1 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.7.2 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+103
View File
@@ -0,0 +1,103 @@
"""
PraisonAI Recipe Module
Provides a minimal, client-friendly API for running Agent Recipes.
All imports are lazy to ensure zero performance impact on Core SDK.
Usage:
from praisonai import recipe
result = recipe.run("support-reply-drafter", input={"ticket": "T-123"})
# Or with streaming
for event in recipe.run_stream("transcript-generator", input="audio.mp3"):
print(event)
"""
__all__ = [
# Core API
"run",
"run_stream",
"validate",
"list_recipes",
"describe",
# Data classes
"RecipeResult",
"RecipeEvent",
"RecipeConfig",
# Exceptions
"RecipeError",
"RecipeNotFoundError",
"RecipeDependencyError",
"RecipePolicyError",
"RecipeValidationError",
# Exit codes
"ExitCode",
]
# Lazy loading implementation
_module_cache = {}
def __getattr__(name):
"""Lazy load recipe components."""
if name in _module_cache:
return _module_cache[name]
if name == "run":
from .core import run
_module_cache[name] = run
return run
elif name == "run_stream":
from .core import run_stream
_module_cache[name] = run_stream
return run_stream
elif name == "validate":
from .core import validate
_module_cache[name] = validate
return validate
elif name == "list_recipes":
from .core import list_recipes
_module_cache[name] = list_recipes
return list_recipes
elif name == "describe":
from .core import describe
_module_cache[name] = describe
return describe
elif name == "RecipeResult":
from .models import RecipeResult
_module_cache[name] = RecipeResult
return RecipeResult
elif name == "RecipeEvent":
from .models import RecipeEvent
_module_cache[name] = RecipeEvent
return RecipeEvent
elif name == "RecipeConfig":
from .models import RecipeConfig
_module_cache[name] = RecipeConfig
return RecipeConfig
elif name == "RecipeError":
from .exceptions import RecipeError
_module_cache[name] = RecipeError
return RecipeError
elif name == "RecipeNotFoundError":
from .exceptions import RecipeNotFoundError
_module_cache[name] = RecipeNotFoundError
return RecipeNotFoundError
elif name == "RecipeDependencyError":
from .exceptions import RecipeDependencyError
_module_cache[name] = RecipeDependencyError
return RecipeDependencyError
elif name == "RecipePolicyError":
from .exceptions import RecipePolicyError
_module_cache[name] = RecipePolicyError
return RecipePolicyError
elif name == "RecipeValidationError":
from .exceptions import RecipeValidationError
_module_cache[name] = RecipeValidationError
return RecipeValidationError
elif name == "ExitCode":
from .models import ExitCode
_module_cache[name] = ExitCode
return ExitCode
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+813
View File
@@ -0,0 +1,813 @@
"""
Recipe Core API
Provides the main recipe execution functions.
"""
import os
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Union
from .models import (
RecipeResult,
RecipeEvent,
RecipeConfig,
RecipeInfo,
RecipeStatus,
ValidationResult,
)
from .exceptions import (
RecipeError,
RecipeNotFoundError,
RecipeDependencyError,
RecipePolicyError,
RecipeTimeoutError,
)
# Default denied tools (dangerous by default)
DEFAULT_DENIED_TOOLS = [
"shell.exec",
"shell.run",
"shell_tool",
"file.write",
"file.delete",
"fs.write",
"fs.delete",
"network.unrestricted",
"db.write",
"db.delete",
"execute_command",
]
def _generate_run_id() -> str:
"""Generate a unique run ID."""
return f"run-{uuid.uuid4().hex[:12]}"
def _generate_trace_id() -> str:
"""Generate a trace ID for distributed tracing."""
return uuid.uuid4().hex
def _get_timestamp() -> str:
"""Get current timestamp in ISO format."""
return datetime.now(timezone.utc).isoformat()
def run(
name: str,
input: Union[str, Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
session_id: Optional[str] = None,
options: Optional[Dict[str, Any]] = None,
) -> RecipeResult:
"""
Run a recipe by name.
Args:
name: Recipe name or URI (e.g., "support-reply", "github:user/repo/recipe")
input: Input data (string path or dict)
config: Optional config overrides
session_id: Optional session ID for state grouping
options: Execution options:
- timeout_sec: Timeout in seconds (default: 300)
- dry_run: Validate without executing (default: False)
- verbose: Enable verbose output (default: False)
- force: Force execution even with missing deps (default: False)
- offline: Use only cached templates (default: False)
- mode: Execution mode - "dev" or "prod" (default: "dev")
- allow_dangerous_tools: Allow dangerous tools (default: False)
Returns:
RecipeResult with run_id, status, output, metrics, trace
Example:
>>> from praisonai import recipe
>>> result = recipe.run("support-reply", input={"ticket_id": "T-123"})
>>> if result.ok:
... print(result.output["reply"])
"""
options = options or {}
config = config or {}
input = input or {}
# Generate identifiers
run_id = _generate_run_id()
trace_id = _generate_trace_id()
session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
start_time = time.time()
trace = {
"run_id": run_id,
"trace_id": trace_id,
"session_id": session_id,
}
try:
# Load template
recipe_config = _load_recipe(name, offline=options.get("offline", False))
if recipe_config is None:
return RecipeResult(
run_id=run_id,
recipe=name,
version="unknown",
status=RecipeStatus.FAILED,
error=f"Recipe not found: {name}",
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
# Check dependencies
if not options.get("force", False):
dep_result = _check_dependencies(recipe_config)
if not dep_result["all_satisfied"]:
missing = _format_missing_deps(dep_result)
return RecipeResult(
run_id=run_id,
recipe=recipe_config.name,
version=recipe_config.version,
status=RecipeStatus.MISSING_DEPS,
error=f"Missing dependencies: {', '.join(missing)}",
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
# Check tool permissions
if not options.get("allow_dangerous_tools", False):
policy_error = _check_tool_policy(recipe_config)
if policy_error:
return RecipeResult(
run_id=run_id,
recipe=recipe_config.name,
version=recipe_config.version,
status=RecipeStatus.POLICY_DENIED,
error=policy_error,
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
# Dry run mode
if options.get("dry_run", False):
return RecipeResult(
run_id=run_id,
recipe=recipe_config.name,
version=recipe_config.version,
status=RecipeStatus.DRY_RUN,
output={
"plan": "Would execute recipe with provided config",
"recipe": recipe_config.name,
"config": {**recipe_config.defaults, **config},
},
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
# Merge input and config
if isinstance(input, str):
merged_config = {**recipe_config.defaults, "input": input, **config}
else:
merged_config = {**recipe_config.defaults, **input, **config}
# Execute recipe
output = _execute_recipe(
recipe_config,
merged_config,
session_id,
options,
)
duration = time.time() - start_time
return RecipeResult(
run_id=run_id,
recipe=recipe_config.name,
version=recipe_config.version,
status=RecipeStatus.SUCCESS,
output=output,
metrics={"duration_sec": round(duration, 2)},
trace=trace,
)
except RecipeNotFoundError as e:
return RecipeResult(
run_id=run_id,
recipe=name,
version="unknown",
status=RecipeStatus.FAILED,
error=str(e),
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
except RecipeDependencyError as e:
return RecipeResult(
run_id=run_id,
recipe=e.recipe or name,
version="unknown",
status=RecipeStatus.MISSING_DEPS,
error=str(e),
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
except RecipePolicyError as e:
return RecipeResult(
run_id=run_id,
recipe=e.recipe or name,
version="unknown",
status=RecipeStatus.POLICY_DENIED,
error=str(e),
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
except RecipeTimeoutError as e:
return RecipeResult(
run_id=run_id,
recipe=name,
version="unknown",
status=RecipeStatus.TIMEOUT,
error=str(e),
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
except Exception as e:
return RecipeResult(
run_id=run_id,
recipe=name,
version="unknown",
status=RecipeStatus.FAILED,
error=str(e),
metrics={"duration_sec": time.time() - start_time},
trace=trace,
)
def run_stream(
name: str,
input: Union[str, Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
session_id: Optional[str] = None,
options: Optional[Dict[str, Any]] = None,
) -> Iterator[RecipeEvent]:
"""
Run a recipe with streaming events.
Yields RecipeEvent objects for progress tracking.
Args:
name: Recipe name or URI
input: Input data
config: Optional config overrides
session_id: Optional session ID
options: Execution options
Yields:
RecipeEvent objects with event_type and data
Example:
>>> for event in recipe.run_stream("transcript-generator", input="audio.mp3"):
... print(f"[{event.event_type}] {event.data}")
"""
options = options or {}
config = config or {}
input = input or {}
run_id = _generate_run_id()
trace_id = _generate_trace_id()
session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
# Started event
yield RecipeEvent(
event_type="started",
data={
"run_id": run_id,
"recipe": name,
"trace_id": trace_id,
"session_id": session_id,
},
)
try:
# Load recipe
yield RecipeEvent(
event_type="progress",
data={"step": "loading", "message": f"Loading recipe: {name}"},
)
recipe_config = _load_recipe(name, offline=options.get("offline", False))
if recipe_config is None:
yield RecipeEvent(
event_type="error",
data={"code": "not_found", "message": f"Recipe not found: {name}"},
)
return
# Check dependencies
yield RecipeEvent(
event_type="progress",
data={"step": "checking_deps", "message": "Checking dependencies"},
)
if not options.get("force", False):
dep_result = _check_dependencies(recipe_config)
if not dep_result["all_satisfied"]:
missing = _format_missing_deps(dep_result)
yield RecipeEvent(
event_type="error",
data={
"code": "missing_deps",
"message": f"Missing dependencies: {', '.join(missing)}",
"missing": missing,
},
)
return
# Dry run
if options.get("dry_run", False):
yield RecipeEvent(
event_type="completed",
data={
"run_id": run_id,
"status": RecipeStatus.DRY_RUN,
"message": "Dry run completed",
},
)
return
# Execute
yield RecipeEvent(
event_type="progress",
data={"step": "executing", "message": "Executing recipe"},
)
# Merge config
if isinstance(input, str):
merged_config = {**recipe_config.defaults, "input": input, **config}
else:
merged_config = {**recipe_config.defaults, **input, **config}
start_time = time.time()
output = _execute_recipe(recipe_config, merged_config, session_id, options)
duration = time.time() - start_time
# Output event
yield RecipeEvent(
event_type="output",
data={"output": output},
)
# Completed event
yield RecipeEvent(
event_type="completed",
data={
"run_id": run_id,
"status": RecipeStatus.SUCCESS,
"duration_sec": round(duration, 2),
},
)
except Exception as e:
yield RecipeEvent(
event_type="error",
data={"code": "execution_error", "message": str(e)},
)
def validate(name: str, offline: bool = False) -> ValidationResult:
"""
Validate a recipe and check dependencies.
Args:
name: Recipe name or URI
offline: Use only cached templates
Returns:
ValidationResult with valid status, errors, warnings, dependencies
Example:
>>> result = recipe.validate("support-reply")
>>> if result.valid:
... print("Recipe is valid")
>>> else:
... print(f"Errors: {result.errors}")
"""
errors = []
warnings = []
try:
recipe_config = _load_recipe(name, offline=offline)
if recipe_config is None:
return ValidationResult(
valid=False,
recipe=name,
version="unknown",
errors=[f"Recipe not found: {name}"],
)
# Check dependencies
dep_result = _check_dependencies(recipe_config)
# Check for missing required deps
for pkg in dep_result.get("packages", []):
if not pkg.get("available", False):
errors.append(f"Missing package: {pkg['name']}")
for env in dep_result.get("env", []):
if not env.get("available", False):
errors.append(f"Missing env var: ${env['name']}")
for ext in dep_result.get("external", []):
if not ext.get("available", False):
warnings.append(f"Missing external tool: {ext['name']}")
# Check tool policy
policy_error = _check_tool_policy(recipe_config)
if policy_error:
warnings.append(f"Tool policy: {policy_error}")
return ValidationResult(
valid=len(errors) == 0,
recipe=recipe_config.name,
version=recipe_config.version,
errors=errors,
warnings=warnings,
dependencies=dep_result,
)
except Exception as e:
return ValidationResult(
valid=False,
recipe=name,
version="unknown",
errors=[str(e)],
)
def list_recipes(
source_filter: Optional[str] = None,
tags: Optional[List[str]] = None,
offline: bool = False,
) -> List[RecipeInfo]:
"""
List available recipes.
Args:
source_filter: Filter by source (local, package, github)
tags: Filter by tags
offline: Use only cached templates
Returns:
List of RecipeInfo objects
Example:
>>> recipes = recipe.list_recipes(tags=["video"])
>>> for r in recipes:
... print(f"{r.name}: {r.description}")
"""
try:
from praisonai.templates import TemplateDiscovery
discovery = TemplateDiscovery()
templates = discovery.list_templates(source_filter=source_filter)
recipes = []
for t in templates:
# Filter by tags if specified
if tags:
template_tags = getattr(t, 'tags', []) or []
if not any(tag in template_tags for tag in tags):
continue
recipes.append(RecipeInfo(
name=t.name,
version=getattr(t, 'version', '1.0.0'),
description=getattr(t, 'description', ''),
tags=getattr(t, 'tags', []) or [],
path=str(t.path) if hasattr(t, 'path') else '',
source=getattr(t, 'source', 'local'),
))
return recipes
except Exception:
return []
def describe(name: str, offline: bool = False) -> Optional[RecipeConfig]:
"""
Get detailed information about a recipe.
Args:
name: Recipe name or URI
offline: Use only cached templates
Returns:
RecipeConfig with full recipe details, or None if not found
Example:
>>> info = recipe.describe("support-reply")
>>> print(f"Required env vars: {info.get_required_env()}")
"""
return _load_recipe(name, offline=offline)
# --- Internal Functions ---
def _load_recipe(name: str, offline: bool = False) -> Optional[RecipeConfig]:
"""Load a recipe by name or URI."""
try:
from praisonai.templates import TemplateDiscovery, TemplateLoader
discovery = TemplateDiscovery()
discovered = discovery.find_template(name)
if discovered:
loader = TemplateLoader(offline=offline)
template = loader.load(str(discovered.path))
return RecipeConfig(
name=template.name,
version=template.version,
description=template.description,
author=template.author,
license=template.license,
tags=template.tags,
requires=template.requires,
tools=template.raw.get("tools", {}),
config_schema=template.config_schema,
defaults=template.defaults,
outputs=template.raw.get("outputs", []),
governance=template.raw.get("governance", {}),
data_policy=template.raw.get("data_policy", {}),
path=str(template.path) if template.path else None,
raw=template.raw,
)
# Try loading directly as URI
loader = TemplateLoader(offline=offline)
try:
template = loader.load(name)
return RecipeConfig(
name=template.name,
version=template.version,
description=template.description,
author=template.author,
license=template.license,
tags=template.tags,
requires=template.requires,
tools=template.raw.get("tools", {}),
config_schema=template.config_schema,
defaults=template.defaults,
outputs=template.raw.get("outputs", []),
governance=template.raw.get("governance", {}),
data_policy=template.raw.get("data_policy", {}),
path=str(template.path) if template.path else None,
raw=template.raw,
)
except Exception:
pass
return None
except Exception:
return None
def _check_dependencies(recipe_config: RecipeConfig) -> Dict[str, Any]:
"""Check if recipe dependencies are satisfied."""
result = {
"all_satisfied": True,
"packages": [],
"env": [],
"tools": [],
"external": [],
}
# Check Python packages
for pkg in recipe_config.get_required_packages():
try:
__import__(pkg.replace("-", "_"))
result["packages"].append({"name": pkg, "available": True})
except ImportError:
result["packages"].append({"name": pkg, "available": False})
result["all_satisfied"] = False
# Check environment variables
for env_var in recipe_config.get_required_env():
available = env_var in os.environ
result["env"].append({"name": env_var, "available": available})
if not available:
result["all_satisfied"] = False
# Check external tools
import shutil
for ext in recipe_config.get_external_deps():
ext_name = ext.get("name", ext) if isinstance(ext, dict) else ext
available = shutil.which(ext_name) is not None
result["external"].append({"name": ext_name, "available": available})
# External tools are warnings, not errors
return result
def _format_missing_deps(dep_result: Dict[str, Any]) -> List[str]:
"""Format missing dependencies as a list of strings."""
missing = []
for pkg in dep_result.get("packages", []):
if not pkg.get("available", False):
missing.append(pkg["name"])
for env in dep_result.get("env", []):
if not env.get("available", False):
missing.append(f"${env['name']}")
return missing
def _check_tool_policy(recipe_config: RecipeConfig) -> Optional[str]:
"""Check if recipe uses denied tools. Returns error message or None."""
allowed = set(recipe_config.get_allowed_tools())
denied = set(recipe_config.get_denied_tools())
required = set(recipe_config.get_required_tools())
# Check if any required tools are in default denied list
for tool in required:
if tool in DEFAULT_DENIED_TOOLS and tool not in allowed:
return f"Tool '{tool}' is denied by default. Use allow_dangerous_tools=True to override."
# Check explicit denials
for tool in required:
if tool in denied:
return f"Tool '{tool}' is explicitly denied by recipe policy."
return None
def _execute_recipe(
recipe_config: RecipeConfig,
merged_config: Dict[str, Any],
session_id: str,
options: Dict[str, Any],
) -> Any:
"""Execute the recipe workflow."""
try:
from praisonai.templates import TemplateLoader
from praisonai.templates.tool_override import create_tool_registry_with_overrides
loader = TemplateLoader()
# Load workflow config
from praisonai.templates.loader import TemplateConfig as LoaderTemplateConfig
# Create a TemplateConfig compatible with loader
template_path = Path(recipe_config.path) if recipe_config.path else None
loader_config = LoaderTemplateConfig(
name=recipe_config.name,
description=recipe_config.description,
version=recipe_config.version,
author=recipe_config.author,
license=recipe_config.license,
tags=recipe_config.tags,
requires=recipe_config.requires,
workflow_file=recipe_config.raw.get("workflow", "workflow.yaml"),
agents_file=recipe_config.raw.get("agents", "agents.yaml"),
config_schema=recipe_config.config_schema,
defaults=merged_config,
skills=recipe_config.raw.get("skills", []),
cli=recipe_config.raw.get("cli", {}),
raw=recipe_config.raw,
path=template_path,
)
workflow_config = loader.load_workflow_config(loader_config)
# Build tool registry
tool_registry = create_tool_registry_with_overrides(
include_defaults=True,
template_dir=recipe_config.path,
)
# Execute based on workflow type
if "agents" in workflow_config and "tasks" in workflow_config:
return _execute_praisonai_workflow(
workflow_config, merged_config, tool_registry, options
)
elif "steps" in workflow_config:
return _execute_steps_workflow(
workflow_config, merged_config, tool_registry, options
)
else:
# Simple agent execution
return _execute_simple_agent(
workflow_config, merged_config, tool_registry, options
)
except ImportError as e:
raise RecipeError(f"Missing dependency for recipe execution: {e}")
except Exception as e:
raise RecipeError(f"Recipe execution failed: {e}")
def _execute_praisonai_workflow(
workflow_config: Dict[str, Any],
config: Dict[str, Any],
tool_registry: Any,
options: Dict[str, Any],
) -> Any:
"""Execute a PraisonAI agents/tasks workflow."""
from praisonaiagents import Agent, Task, PraisonAIAgents
from praisonai.templates.tool_override import resolve_tools
agents = []
agent_map = {}
for agent_cfg in workflow_config.get("agents", []):
agent_tools = resolve_tools(
agent_cfg.get("tools", []),
registry=tool_registry,
)
agent = Agent(
name=agent_cfg.get("name", "Agent"),
role=agent_cfg.get("role", ""),
goal=agent_cfg.get("goal", ""),
backstory=agent_cfg.get("backstory", ""),
tools=agent_tools if agent_tools else None,
llm=agent_cfg.get("llm"),
verbose=options.get("verbose", False),
)
agents.append(agent)
agent_map[agent_cfg.get("name")] = agent
tasks = []
for task_cfg in workflow_config.get("tasks", []):
agent_name = task_cfg.get("agent")
agent = agent_map.get(agent_name, agents[0] if agents else None)
# Substitute config values in description
description = task_cfg.get("description", "")
try:
description = description.format(**config)
except KeyError:
pass
task = Task(
name=task_cfg.get("name", "Task"),
description=description,
expected_output=task_cfg.get("expected_output", ""),
agent=agent,
)
tasks.append(task)
praison = PraisonAIAgents(
agents=agents,
tasks=tasks,
process=workflow_config.get("process", "sequential"),
verbose=options.get("verbose", 1) if options.get("verbose") else 0,
)
return praison.start()
def _execute_steps_workflow(
workflow_config: Dict[str, Any],
config: Dict[str, Any],
tool_registry: Any,
options: Dict[str, Any],
) -> Any:
"""Execute a steps-based workflow."""
# For now, convert to simple execution
return {"message": "Steps workflow executed", "config": config}
def _execute_simple_agent(
workflow_config: Dict[str, Any],
config: Dict[str, Any],
tool_registry: Any,
options: Dict[str, Any],
) -> Any:
"""Execute a simple single-agent workflow."""
from praisonaiagents import Agent
agent = Agent(
name=workflow_config.get("name", "RecipeAgent"),
role=workflow_config.get("role", "AI Assistant"),
goal=workflow_config.get("goal", "Complete the task"),
backstory=workflow_config.get("backstory", ""),
verbose=options.get("verbose", False),
)
prompt = config.get("input", config.get("prompt", ""))
if prompt:
return agent.chat(prompt)
return {"message": "Recipe executed", "config": config}
@@ -0,0 +1,54 @@
"""
Recipe Exceptions
Custom exceptions for recipe operations.
"""
class RecipeError(Exception):
"""Base exception for recipe operations."""
def __init__(self, message: str, recipe: str = None, details: dict = None):
super().__init__(message)
self.message = message
self.recipe = recipe
self.details = details or {}
class RecipeNotFoundError(RecipeError):
"""Recipe not found."""
pass
class RecipeDependencyError(RecipeError):
"""Missing dependencies for recipe execution."""
def __init__(self, message: str, recipe: str = None, missing: list = None):
super().__init__(message, recipe, {"missing": missing or []})
self.missing = missing or []
class RecipePolicyError(RecipeError):
"""Security policy blocked execution."""
def __init__(self, message: str, recipe: str = None, policy: str = None):
super().__init__(message, recipe, {"policy": policy})
self.policy = policy
class RecipeValidationError(RecipeError):
"""Recipe validation failed."""
def __init__(self, message: str, recipe: str = None, errors: list = None):
super().__init__(message, recipe, {"errors": errors or []})
self.errors = errors or []
class RecipeTimeoutError(RecipeError):
"""Recipe execution timed out."""
pass
class RecipeExecutionError(RecipeError):
"""Recipe execution failed."""
pass
+266
View File
@@ -0,0 +1,266 @@
"""
Recipe Data Models
Defines the core data structures for recipe execution.
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import IntEnum
from typing import Any, Dict, List, Optional
class ExitCode(IntEnum):
"""Stable exit codes for CLI operations."""
SUCCESS = 0
GENERAL_ERROR = 1
VALIDATION_ERROR = 2
RUNTIME_ERROR = 3
POLICY_DENIED = 4
TIMEOUT = 5
MISSING_DEPS = 6
NOT_FOUND = 7
class RecipeStatus:
"""Recipe execution status constants."""
SUCCESS = "success"
FAILED = "failed"
DRY_RUN = "dry_run"
POLICY_DENIED = "policy_denied"
TIMEOUT = "timeout"
MISSING_DEPS = "missing_deps"
VALIDATION_ERROR = "validation_error"
@dataclass
class RecipeResult:
"""
Result of a recipe execution.
Attributes:
run_id: Unique identifier for this execution
recipe: Recipe name
version: Recipe version
status: Execution status (success, failed, dry_run, policy_denied, etc.)
output: Recipe-specific output data
metrics: Execution metrics (duration, tokens, etc.)
error: Error message if failed
trace: Tracing identifiers (run_id, trace_id, session_id, agent_id)
"""
run_id: str
recipe: str
version: str
status: str
output: Any = None
metrics: Dict[str, Any] = field(default_factory=dict)
error: Optional[str] = None
trace: Dict[str, str] = field(default_factory=dict)
@property
def ok(self) -> bool:
"""Check if execution was successful."""
return self.status in (RecipeStatus.SUCCESS, RecipeStatus.DRY_RUN)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"ok": self.ok,
"run_id": self.run_id,
"recipe": self.recipe,
"version": self.version,
"status": self.status,
"output": self.output,
"metrics": self.metrics,
"error": self.error,
"trace": self.trace,
}
def to_exit_code(self) -> int:
"""Convert status to CLI exit code."""
status_to_code = {
RecipeStatus.SUCCESS: ExitCode.SUCCESS,
RecipeStatus.DRY_RUN: ExitCode.SUCCESS,
RecipeStatus.FAILED: ExitCode.RUNTIME_ERROR,
RecipeStatus.POLICY_DENIED: ExitCode.POLICY_DENIED,
RecipeStatus.TIMEOUT: ExitCode.TIMEOUT,
RecipeStatus.MISSING_DEPS: ExitCode.MISSING_DEPS,
RecipeStatus.VALIDATION_ERROR: ExitCode.VALIDATION_ERROR,
}
return status_to_code.get(self.status, ExitCode.GENERAL_ERROR)
@dataclass
class RecipeEvent:
"""
Streaming event from recipe execution.
Attributes:
event_type: Type of event (started, progress, log, output, completed, error)
data: Event-specific data
timestamp: ISO format timestamp
"""
event_type: str
data: Dict[str, Any] = field(default_factory=dict)
timestamp: str = ""
def __post_init__(self):
if not self.timestamp:
self.timestamp = datetime.now(timezone.utc).isoformat()
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON/SSE serialization."""
return {
"event": self.event_type,
"data": self.data,
"timestamp": self.timestamp,
}
def to_sse(self) -> str:
"""Convert to Server-Sent Events format."""
import json
return f"event: {self.event_type}\ndata: {json.dumps(self.data)}\n\n"
@dataclass
class RecipeConfig:
"""
Recipe configuration and metadata.
Attributes:
name: Recipe name (kebab-case)
version: SemVer version string
description: Recipe description
author: Author name or identifier
license: License identifier (e.g., Apache-2.0)
tags: Discovery tags
requires: Dependencies (packages, env, tools, external)
tools: Tool permissions (allow, deny)
config_schema: JSON Schema for input configuration
defaults: Default configuration values
outputs: Expected output definitions
governance: Governance settings (approval, cost limits, audit)
data_policy: Data handling policy (PII, retention)
path: Source path of the recipe
"""
name: str
version: str = "1.0.0"
description: str = ""
author: Optional[str] = None
license: Optional[str] = None
tags: List[str] = field(default_factory=list)
# Dependencies
requires: Dict[str, Any] = field(default_factory=dict)
# Tool permissions
tools: Dict[str, List[str]] = field(default_factory=dict)
# Configuration
config_schema: Dict[str, Any] = field(default_factory=dict)
defaults: Dict[str, Any] = field(default_factory=dict)
# Outputs
outputs: List[Dict[str, Any]] = field(default_factory=list)
# Governance
governance: Dict[str, Any] = field(default_factory=dict)
# Data policy
data_policy: Dict[str, Any] = field(default_factory=dict)
# Source path
path: Optional[str] = None
# Raw config dict
raw: Dict[str, Any] = field(default_factory=dict)
def get_allowed_tools(self) -> List[str]:
"""Get list of explicitly allowed tools."""
return self.tools.get("allow", [])
def get_denied_tools(self) -> List[str]:
"""Get list of explicitly denied tools."""
return self.tools.get("deny", [])
def get_required_packages(self) -> List[str]:
"""Get list of required Python packages."""
packages = self.requires.get("packages", [])
return [packages] if isinstance(packages, str) else packages
def get_required_env(self) -> List[str]:
"""Get list of required environment variables."""
env = self.requires.get("env", [])
return [env] if isinstance(env, str) else env
def get_required_tools(self) -> List[str]:
"""Get list of required tools."""
tools = self.requires.get("tools", [])
return [tools] if isinstance(tools, str) else tools
def get_external_deps(self) -> List[Dict[str, Any]]:
"""Get list of external dependencies (ffmpeg, etc.)."""
return self.requires.get("external", [])
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"name": self.name,
"version": self.version,
"description": self.description,
"author": self.author,
"license": self.license,
"tags": self.tags,
"requires": self.requires,
"tools": self.tools,
"config_schema": self.config_schema,
"defaults": self.defaults,
"outputs": self.outputs,
"governance": self.governance,
"data_policy": self.data_policy,
"path": self.path,
}
@dataclass
class ValidationResult:
"""Result of recipe validation."""
valid: bool
recipe: str
version: str
errors: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
dependencies: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"valid": self.valid,
"recipe": self.recipe,
"version": self.version,
"errors": self.errors,
"warnings": self.warnings,
"dependencies": self.dependencies,
}
@dataclass
class RecipeInfo:
"""Recipe information for listing/discovery."""
name: str
version: str
description: str
tags: List[str]
path: str
source: str # local, package, github
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"name": self.name,
"version": self.version,
"description": self.description,
"tags": self.tags,
"path": self.path,
"source": self.source,
}
+366
View File
@@ -0,0 +1,366 @@
"""
Recipe HTTP Server
Provides HTTP endpoints for recipe execution.
Optional dependency - requires: pip install praisonai[serve]
Endpoints:
- GET /health - Health check
- GET /v1/recipes - List recipes
- GET /v1/recipes/{name} - Describe recipe
- GET /v1/recipes/{name}/schema - Get recipe schema
- POST /v1/recipes/run - Run recipe (sync)
- POST /v1/recipes/stream - Run recipe (SSE)
Auth modes:
- none: No authentication (localhost only)
- api-key: X-API-Key header required
- jwt: Bearer token required (future)
Config file (serve.yaml):
```yaml
host: 127.0.0.1
port: 8765
auth: api-key
api_key: your-secret-key # or use PRAISONAI_API_KEY env var
recipes:
- my-recipe
- another-recipe
preload: true
cors_origins: "*"
```
"""
import json
import os
from typing import Any, Dict, Optional
def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
"""
Load configuration from file.
Precedence: CLI flags > env vars > config file > defaults
"""
config = {}
if config_path and os.path.exists(config_path):
try:
import yaml
with open(config_path) as f:
config = yaml.safe_load(f) or {}
except ImportError:
# Fall back to JSON if yaml not available
with open(config_path) as f:
config = json.load(f)
# Apply env var overrides
if os.environ.get("PRAISONAI_API_KEY"):
config["api_key"] = os.environ["PRAISONAI_API_KEY"]
if os.environ.get("PRAISONAI_SERVE_HOST"):
config["host"] = os.environ["PRAISONAI_SERVE_HOST"]
if os.environ.get("PRAISONAI_SERVE_PORT"):
config["port"] = int(os.environ["PRAISONAI_SERVE_PORT"])
return config
def create_auth_middleware(auth_type: str, api_key: Optional[str] = None):
"""Create authentication middleware."""
try:
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
except ImportError:
return None
class APIKeyAuthMiddleware(BaseHTTPMiddleware):
"""API Key authentication middleware."""
async def dispatch(self, request, call_next):
# Skip auth for health endpoint
if request.url.path == "/health":
return await call_next(request)
# Check X-API-Key header
provided_key = request.headers.get("X-API-Key")
expected_key = api_key or os.environ.get("PRAISONAI_API_KEY")
if not expected_key:
# No key configured, allow request
return await call_next(request)
if provided_key != expected_key:
return JSONResponse(
{"error": {"code": "unauthorized", "message": "Invalid or missing API key"}},
status_code=401
)
return await call_next(request)
if auth_type == "api-key":
return APIKeyAuthMiddleware
return None
def create_app(config: Optional[Dict[str, Any]] = None) -> Any:
"""
Create ASGI application for recipe runner.
Args:
config: Optional configuration dict
Returns:
Starlette ASGI application
"""
try:
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.responses import JSONResponse, Response
from starlette.requests import Request
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
except ImportError:
raise ImportError(
"Serve dependencies not installed. Run: pip install praisonai[serve]"
)
config = config or {}
async def health(request: Request) -> JSONResponse:
"""GET /health - Health check."""
return JSONResponse({
"status": "healthy",
"service": "praisonai-recipe-runner",
"version": _get_version(),
})
async def list_recipes(request: Request) -> JSONResponse:
"""GET /v1/recipes - List available recipes."""
from praisonai import recipe
source_filter = request.query_params.get("source")
tags = request.query_params.get("tags")
tags_list = tags.split(",") if tags else None
recipes = recipe.list_recipes(
source_filter=source_filter,
tags=tags_list,
)
return JSONResponse({
"recipes": [r.to_dict() for r in recipes]
})
async def describe_recipe(request: Request) -> JSONResponse:
"""GET /v1/recipes/{name} - Describe a recipe."""
name = request.path_params["name"]
from praisonai import recipe
info = recipe.describe(name)
if info is None:
return JSONResponse(
{"error": {"code": "not_found", "message": f"Recipe not found: {name}"}},
status_code=404
)
return JSONResponse(info.to_dict())
async def get_schema(request: Request) -> JSONResponse:
"""GET /v1/recipes/{name}/schema - Get recipe JSON schema."""
name = request.path_params["name"]
from praisonai import recipe
info = recipe.describe(name)
if info is None:
return JSONResponse(
{"error": {"code": "not_found", "message": f"Recipe not found: {name}"}},
status_code=404
)
return JSONResponse({
"name": info.name,
"version": info.version,
"input_schema": info.config_schema,
"output_schema": info.outputs,
})
async def run_recipe(request: Request) -> JSONResponse:
"""POST /v1/recipes/run - Run a recipe."""
try:
body = await request.json()
except json.JSONDecodeError:
return JSONResponse(
{"error": {"code": "invalid_json", "message": "Invalid JSON body"}},
status_code=400
)
recipe_name = body.get("recipe")
if not recipe_name:
return JSONResponse(
{"error": {"code": "missing_recipe", "message": "Recipe name required"}},
status_code=400
)
input_data = body.get("input", {})
config_data = body.get("config", {})
options = body.get("options", {})
session_id = body.get("session_id")
from praisonai import recipe
result = recipe.run(
recipe_name,
input=input_data,
config=config_data,
session_id=session_id,
options=options,
)
status_code = 200 if result.ok else 500
if result.status == "policy_denied":
status_code = 403
elif result.status == "missing_deps":
status_code = 424 # Failed Dependency
elif result.status == "validation_error":
status_code = 400
return JSONResponse(result.to_dict(), status_code=status_code)
async def stream_recipe(request: Request) -> Response:
"""POST /v1/recipes/stream - Stream recipe execution."""
try:
from sse_starlette.sse import EventSourceResponse
except ImportError:
return JSONResponse(
{"error": {"code": "sse_unavailable", "message": "SSE not available"}},
status_code=501
)
try:
body = await request.json()
except json.JSONDecodeError:
return JSONResponse(
{"error": {"code": "invalid_json", "message": "Invalid JSON body"}},
status_code=400
)
recipe_name = body.get("recipe")
if not recipe_name:
return JSONResponse(
{"error": {"code": "missing_recipe", "message": "Recipe name required"}},
status_code=400
)
input_data = body.get("input", {})
config_data = body.get("config", {})
options = body.get("options", {})
session_id = body.get("session_id")
async def event_generator():
from praisonai import recipe
for event in recipe.run_stream(
recipe_name,
input=input_data,
config=config_data,
session_id=session_id,
options=options,
):
yield {
"event": event.event_type,
"data": json.dumps(event.data),
}
return EventSourceResponse(event_generator())
async def validate_recipe(request: Request) -> JSONResponse:
"""POST /v1/recipes/validate - Validate a recipe."""
try:
body = await request.json()
except json.JSONDecodeError:
return JSONResponse(
{"error": {"code": "invalid_json", "message": "Invalid JSON body"}},
status_code=400
)
recipe_name = body.get("recipe")
if not recipe_name:
return JSONResponse(
{"error": {"code": "missing_recipe", "message": "Recipe name required"}},
status_code=400
)
from praisonai import recipe
result = recipe.validate(recipe_name)
status_code = 200 if result.valid else 400
return JSONResponse(result.to_dict(), status_code=status_code)
# Build routes
routes = [
Route("/health", health, methods=["GET"]),
Route("/v1/recipes", list_recipes, methods=["GET"]),
Route("/v1/recipes/run", run_recipe, methods=["POST"]),
Route("/v1/recipes/stream", stream_recipe, methods=["POST"]),
Route("/v1/recipes/validate", validate_recipe, methods=["POST"]),
Route("/v1/recipes/{name}", describe_recipe, methods=["GET"]),
Route("/v1/recipes/{name}/schema", get_schema, methods=["GET"]),
]
# Add CORS middleware if configured
middleware = []
cors_origins = config.get("cors_origins")
if cors_origins:
middleware.append(
Middleware(
CORSMiddleware,
allow_origins=cors_origins.split(",") if isinstance(cors_origins, str) else cors_origins,
allow_methods=["*"],
allow_headers=["*"],
)
)
# Add auth middleware if configured
auth_type = config.get("auth")
if auth_type and auth_type != "none":
auth_middleware = create_auth_middleware(auth_type, config.get("api_key"))
if auth_middleware:
middleware.append(Middleware(auth_middleware))
return Starlette(routes=routes, middleware=middleware)
def serve(
host: str = "127.0.0.1",
port: int = 8765,
reload: bool = False,
config: Optional[Dict[str, Any]] = None,
):
"""
Start the recipe runner server.
Args:
host: Server host (default: 127.0.0.1)
port: Server port (default: 8765)
reload: Enable hot reload (default: False)
config: Optional configuration dict
"""
try:
import uvicorn
except ImportError:
raise ImportError(
"Serve dependencies not installed. Run: pip install praisonai[serve]"
)
app = create_app(config)
uvicorn.run(app, host=host, port=port, reload=reload)
def _get_version() -> str:
"""Get PraisonAI version."""
try:
from praisonai.version import __version__
return __version__
except ImportError:
return "unknown"
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.7.1"
__version__ = "2.7.2"
+471
View File
@@ -0,0 +1,471 @@
"""
Tests for PraisonAI Recipe Module
Tests cover:
- Core API (run, run_stream, validate, list_recipes, describe)
- Data models (RecipeResult, RecipeEvent, RecipeConfig)
- CLI commands
- Exit codes
- Error handling
"""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
# ============================================================================
# Unit Tests for Models
# ============================================================================
class TestRecipeResult:
"""Tests for RecipeResult dataclass."""
def test_success_result(self):
"""Test successful result."""
from praisonai.recipe.models import RecipeResult, RecipeStatus
result = RecipeResult(
run_id="run-abc123",
recipe="test-recipe",
version="1.0.0",
status=RecipeStatus.SUCCESS,
output={"message": "Hello"},
metrics={"duration_sec": 1.5},
trace={"run_id": "run-abc123"},
)
assert result.ok is True
assert result.run_id == "run-abc123"
assert result.status == "success"
assert result.to_exit_code() == 0
def test_failed_result(self):
"""Test failed result."""
from praisonai.recipe.models import RecipeResult, RecipeStatus
result = RecipeResult(
run_id="run-abc123",
recipe="test-recipe",
version="1.0.0",
status=RecipeStatus.FAILED,
error="Something went wrong",
)
assert result.ok is False
assert result.error == "Something went wrong"
assert result.to_exit_code() == 3 # RUNTIME_ERROR
def test_dry_run_result(self):
"""Test dry run result."""
from praisonai.recipe.models import RecipeResult, RecipeStatus
result = RecipeResult(
run_id="run-abc123",
recipe="test-recipe",
version="1.0.0",
status=RecipeStatus.DRY_RUN,
)
assert result.ok is True
assert result.to_exit_code() == 0
def test_to_dict(self):
"""Test to_dict serialization."""
from praisonai.recipe.models import RecipeResult, RecipeStatus
result = RecipeResult(
run_id="run-abc123",
recipe="test-recipe",
version="1.0.0",
status=RecipeStatus.SUCCESS,
output={"key": "value"},
)
d = result.to_dict()
assert d["ok"] is True
assert d["run_id"] == "run-abc123"
assert d["recipe"] == "test-recipe"
assert d["output"] == {"key": "value"}
class TestRecipeEvent:
"""Tests for RecipeEvent dataclass."""
def test_event_creation(self):
"""Test event creation."""
from praisonai.recipe.models import RecipeEvent
event = RecipeEvent(
event_type="started",
data={"run_id": "run-abc123"},
)
assert event.event_type == "started"
assert event.data["run_id"] == "run-abc123"
assert event.timestamp # Should be auto-generated
def test_to_sse(self):
"""Test SSE format conversion."""
from praisonai.recipe.models import RecipeEvent
event = RecipeEvent(
event_type="progress",
data={"step": "loading"},
)
sse = event.to_sse()
assert "event: progress" in sse
assert "data:" in sse
class TestExitCode:
"""Tests for exit codes."""
def test_exit_codes(self):
"""Test exit code values."""
from praisonai.recipe.models import ExitCode
assert ExitCode.SUCCESS == 0
assert ExitCode.GENERAL_ERROR == 1
assert ExitCode.VALIDATION_ERROR == 2
assert ExitCode.RUNTIME_ERROR == 3
assert ExitCode.POLICY_DENIED == 4
assert ExitCode.TIMEOUT == 5
assert ExitCode.MISSING_DEPS == 6
assert ExitCode.NOT_FOUND == 7
class TestRecipeConfig:
"""Tests for RecipeConfig dataclass."""
def test_config_creation(self):
"""Test config creation."""
from praisonai.recipe.models import RecipeConfig
config = RecipeConfig(
name="test-recipe",
version="1.0.0",
description="Test recipe",
requires={"packages": ["openai"], "env": ["OPENAI_API_KEY"]},
tools={"allow": ["web.search"], "deny": ["shell.exec"]},
)
assert config.name == "test-recipe"
assert config.get_required_packages() == ["openai"]
assert config.get_required_env() == ["OPENAI_API_KEY"]
assert config.get_allowed_tools() == ["web.search"]
assert config.get_denied_tools() == ["shell.exec"]
# ============================================================================
# Unit Tests for Core API
# ============================================================================
class TestCoreAPI:
"""Tests for core recipe API."""
def test_list_recipes(self):
"""Test listing recipes."""
from praisonai.recipe import list_recipes
recipes = list_recipes()
assert isinstance(recipes, list)
def test_validate_nonexistent(self):
"""Test validating non-existent recipe."""
from praisonai.recipe import validate
result = validate("nonexistent-recipe-xyz")
assert result.valid is False
assert "not found" in result.errors[0].lower() or len(result.errors) > 0
def test_describe_nonexistent(self):
"""Test describing non-existent recipe."""
from praisonai.recipe import describe
result = describe("nonexistent-recipe-xyz")
assert result is None
def test_run_dry_run(self):
"""Test dry run mode."""
from praisonai.recipe import run, list_recipes
# Use a recipe that exists
recipes = list_recipes()
if recipes:
result = run(
recipes[0].name,
input={},
options={"dry_run": True},
)
assert result.status == "dry_run"
assert result.ok is True
def test_run_nonexistent(self):
"""Test running non-existent recipe."""
from praisonai.recipe import run
result = run("nonexistent-recipe-xyz", input={})
assert result.ok is False
assert "not found" in result.error.lower()
# ============================================================================
# CLI Tests
# ============================================================================
class TestCLI:
"""Tests for CLI commands."""
@pytest.fixture
def cli_runner(self):
"""Get CLI runner path."""
return [sys.executable, "-m", "praisonai.cli.main"]
def test_recipe_help(self, cli_runner):
"""Test recipe help command."""
# Test via direct import instead of subprocess for reliability
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
exit_code = handler.handle(["help"])
assert exit_code == 0
def test_recipe_list(self, cli_runner):
"""Test recipe list command."""
# Test via direct import for reliability
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
exit_code = handler.handle(["list"])
assert exit_code == 0
def test_recipe_validate_nonexistent(self, cli_runner):
"""Test validating non-existent recipe."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
exit_code = handler.handle(["validate", "nonexistent-xyz"])
# Should return validation error or not found exit code
assert exit_code in [2, 7] # VALIDATION_ERROR or NOT_FOUND
def test_recipe_init(self, cli_runner):
"""Test recipe init command."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
with tempfile.TemporaryDirectory() as tmpdir:
exit_code = handler.handle(["init", "test-recipe", "-o", tmpdir])
assert exit_code == 0
# Check files were created
recipe_dir = Path(tmpdir) / "test-recipe"
assert (recipe_dir / "TEMPLATE.yaml").exists()
assert (recipe_dir / "workflow.yaml").exists()
assert (recipe_dir / "README.md").exists()
def test_recipe_dry_run(self, cli_runner):
"""Test recipe dry run."""
result = subprocess.run(
cli_runner + ["recipe", "list", "--json"],
capture_output=True,
text=True,
cwd=str(Path(__file__).parent.parent),
)
# Get first recipe name
try:
recipes = json.loads(result.stdout)
if recipes:
recipe_name = recipes[0]["name"]
# Run dry run
result = subprocess.run(
cli_runner + ["recipe", "run", recipe_name, "--dry-run", "--json"],
capture_output=True,
text=True,
cwd=str(Path(__file__).parent.parent),
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["status"] == "dry_run"
except (json.JSONDecodeError, KeyError, IndexError):
pass # Skip if no recipes available
# ============================================================================
# Integration Tests
# ============================================================================
class TestIntegration:
"""Integration tests with real API calls."""
@pytest.fixture
def has_openai_key(self):
"""Check if OpenAI API key is available."""
return bool(os.environ.get("OPENAI_API_KEY"))
def test_run_with_api(self, has_openai_key):
"""Test running a recipe with real API."""
if not has_openai_key:
pytest.skip("OPENAI_API_KEY not set")
from praisonai.recipe import run, list_recipes as list_all_recipes
recipes = list_all_recipes()
if not recipes:
pytest.skip("No recipes available")
# Run first available recipe
result = run(
recipes[0].name,
input={},
options={"timeout_sec": 60},
)
# Should complete (success or fail, but not crash)
assert result.run_id.startswith("run-")
assert result.status in ["success", "failed", "missing_deps"]
# ============================================================================
# Exception Tests
# ============================================================================
class TestExceptions:
"""Tests for custom exceptions."""
def test_recipe_error(self):
"""Test RecipeError."""
from praisonai.recipe.exceptions import RecipeError
err = RecipeError("Test error", recipe="test", details={"key": "value"})
assert str(err) == "Test error"
assert err.recipe == "test"
assert err.details == {"key": "value"}
def test_recipe_not_found_error(self):
"""Test RecipeNotFoundError."""
from praisonai.recipe.exceptions import RecipeNotFoundError
err = RecipeNotFoundError("Recipe not found", recipe="test")
assert "Recipe not found" in str(err)
def test_recipe_dependency_error(self):
"""Test RecipeDependencyError."""
from praisonai.recipe.exceptions import RecipeDependencyError
err = RecipeDependencyError(
"Missing deps",
recipe="test",
missing=["openai", "pandas"],
)
assert err.missing == ["openai", "pandas"]
def test_recipe_policy_error(self):
"""Test RecipePolicyError."""
from praisonai.recipe.exceptions import RecipePolicyError
err = RecipePolicyError(
"Policy denied",
recipe="test",
policy="no_shell",
)
assert err.policy == "no_shell"
# ============================================================================
# Endpoints CLI Tests
# ============================================================================
class TestEndpointsCLI:
"""Tests for endpoints CLI commands."""
def test_endpoints_help(self):
"""Test endpoints help command."""
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
exit_code = handler.handle(["help"])
assert exit_code == 0
def test_endpoints_list_no_server(self):
"""Test endpoints list when no server running."""
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
exit_code = handler.handle(["list", "--url", "http://localhost:9999"])
assert exit_code == 8 # CONNECTION_ERROR
def test_endpoints_health_no_server(self):
"""Test endpoints health when no server running."""
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
exit_code = handler.handle(["health", "--url", "http://localhost:9999"])
assert exit_code == 8 # CONNECTION_ERROR
def test_endpoints_invoke_missing_recipe(self):
"""Test endpoints invoke without recipe name."""
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
exit_code = handler.handle(["invoke"])
assert exit_code == 2 # VALIDATION_ERROR
def test_endpoints_describe_missing_recipe(self):
"""Test endpoints describe without recipe name."""
from praisonai.cli.features.endpoints import EndpointsHandler
handler = EndpointsHandler()
exit_code = handler.handle(["describe"])
assert exit_code == 2 # VALIDATION_ERROR
class TestServeConfig:
"""Tests for serve configuration."""
def test_load_config_empty(self):
"""Test loading empty config."""
from praisonai.recipe.serve import load_config
config = load_config(None)
assert config == {}
def test_auth_middleware_creation(self):
"""Test auth middleware creation."""
from praisonai.recipe.serve import create_auth_middleware
# No auth
middleware = create_auth_middleware("none")
assert middleware is None
# API key auth
middleware = create_auth_middleware("api-key", "test-key")
assert middleware is not None
class TestServeHostSafety:
"""Tests for serve host binding safety."""
def test_refuse_public_without_auth(self):
"""Test that 0.0.0.0 binding without auth is refused."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
exit_code = handler.handle(["serve", "--host", "0.0.0.0", "--port", "9999"])
assert exit_code == 4 # POLICY_DENIED
def test_allow_localhost_without_auth(self):
"""Test that localhost binding without auth is allowed."""
# Verify the host check logic
spec = {"host": "127.0.0.1", "auth": "none"}
host = spec["host"]
auth = spec["auth"]
# This should NOT trigger the policy denial
should_deny = host != "127.0.0.1" and host != "localhost" and auth == "none"
assert should_deny is False
# ============================================================================
# Run tests
# ============================================================================
if __name__ == "__main__":
pytest.main([__file__, "-v"])