mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 03:31:28 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a4c2c2c43 | ||
|
|
0b2e6ff69b |
No files matched your search
@@ -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.5" \
|
||||
"praisonai>=2.7.6" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -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.5" \
|
||||
"praisonai>=2.7.6" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -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.5" \
|
||||
"praisonai>=2.7.6" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Package Manager Example
|
||||
|
||||
This example demonstrates how to use the PraisonAI package manager for installing and managing Python packages with security defaults.
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
- Installing packages with version constraints
|
||||
- Listing installed packages
|
||||
- Searching for packages on PyPI
|
||||
- Managing package index configuration
|
||||
- Security features (dependency confusion prevention)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run the example
|
||||
python package_manager_example.py
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### Install Packages
|
||||
|
||||
```bash
|
||||
# Install a package
|
||||
praisonai install requests
|
||||
|
||||
# Install with version constraint
|
||||
praisonai install "requests>=2.28"
|
||||
|
||||
# Install multiple packages
|
||||
praisonai install requests httpx aiohttp
|
||||
|
||||
# Upgrade existing package
|
||||
praisonai install requests --upgrade
|
||||
|
||||
# JSON output
|
||||
praisonai install requests --json
|
||||
```
|
||||
|
||||
### Uninstall Packages
|
||||
|
||||
```bash
|
||||
# Uninstall a package
|
||||
praisonai uninstall requests
|
||||
|
||||
# Uninstall without confirmation
|
||||
praisonai uninstall requests --yes
|
||||
```
|
||||
|
||||
### List Packages
|
||||
|
||||
```bash
|
||||
# List all installed packages
|
||||
praisonai package list
|
||||
|
||||
# JSON output
|
||||
praisonai package list --json
|
||||
```
|
||||
|
||||
### Search Packages
|
||||
|
||||
```bash
|
||||
# Search PyPI
|
||||
praisonai package search langchain
|
||||
|
||||
# JSON output
|
||||
praisonai package search langchain --json
|
||||
```
|
||||
|
||||
### Index Configuration
|
||||
|
||||
```bash
|
||||
# Show current index settings
|
||||
praisonai package index show --json
|
||||
|
||||
# Set custom index
|
||||
praisonai package index set https://pypi.mycompany.com/simple
|
||||
|
||||
# Reset to PyPI default
|
||||
praisonai package index set https://pypi.org/simple
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
By default, only the primary index (PyPI) is used. Extra indexes are blocked to prevent dependency confusion attacks.
|
||||
|
||||
```bash
|
||||
# This will FAIL (extra index not allowed by default)
|
||||
praisonai install mypackage --extra-index-url https://other.index.com/simple
|
||||
|
||||
# Explicitly allow extra index (shows security warning)
|
||||
praisonai install mypackage \
|
||||
--extra-index-url https://other.index.com/simple \
|
||||
--allow-extra-index
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `PRAISONAI_PACKAGE_INDEX_URL` - Override primary index URL
|
||||
- `PIP_INDEX_URL` - Fallback to pip's index URL
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Package Manager Example
|
||||
|
||||
This example demonstrates:
|
||||
1. Checking package index configuration
|
||||
2. Searching for packages on PyPI
|
||||
3. Listing installed packages
|
||||
4. Security features (safe defaults)
|
||||
|
||||
Requirements:
|
||||
- praisonai installed
|
||||
- No external API keys required
|
||||
|
||||
Note: This example does NOT actually install/uninstall packages
|
||||
to avoid modifying your environment. It demonstrates read-only
|
||||
operations and shows the commands for install/uninstall.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
|
||||
def run_command(args, description):
|
||||
"""Run a praisonai command and display results."""
|
||||
print(f"\n[{description}]")
|
||||
print(f"Command: praisonai {' '.join(args)}")
|
||||
print("-" * 40)
|
||||
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "praisonai"] + args,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
print(json.dumps(data, indent=2))
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
print(result.stdout)
|
||||
return None
|
||||
else:
|
||||
print(f"Error (exit code {result.returncode}):")
|
||||
print(result.stderr or result.stdout)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("PraisonAI Package Manager Example")
|
||||
print("=" * 60)
|
||||
print("\nThis example demonstrates package manager features.")
|
||||
print("No packages will be installed or uninstalled.")
|
||||
|
||||
# 1. Show index configuration
|
||||
print("\n" + "=" * 60)
|
||||
print("1. PACKAGE INDEX CONFIGURATION")
|
||||
print("=" * 60)
|
||||
|
||||
data = run_command(
|
||||
["package", "index", "show", "--json"],
|
||||
"Show current index settings"
|
||||
)
|
||||
|
||||
if data:
|
||||
print(f"\n✓ Primary index: {data.get('index_url', 'N/A')}")
|
||||
print(f"✓ Extra indexes allowed: {data.get('allow_extra_index', False)}")
|
||||
|
||||
# 2. Search for packages
|
||||
print("\n" + "=" * 60)
|
||||
print("2. SEARCH PACKAGES")
|
||||
print("=" * 60)
|
||||
|
||||
data = run_command(
|
||||
["package", "search", "requests", "--json"],
|
||||
"Search for 'requests' package"
|
||||
)
|
||||
|
||||
if data and data.get("results"):
|
||||
pkg = data["results"][0]
|
||||
print(f"\n✓ Found: {pkg.get('name')} v{pkg.get('version')}")
|
||||
print(f" Summary: {pkg.get('summary', 'N/A')}")
|
||||
|
||||
# 3. List installed packages (sample)
|
||||
print("\n" + "=" * 60)
|
||||
print("3. LIST INSTALLED PACKAGES")
|
||||
print("=" * 60)
|
||||
|
||||
data = run_command(
|
||||
["package", "list", "--json"],
|
||||
"List installed packages"
|
||||
)
|
||||
|
||||
if data and data.get("packages"):
|
||||
print(f"\n✓ Total packages: {len(data['packages'])}")
|
||||
print(" Sample (first 5):")
|
||||
for pkg in data["packages"][:5]:
|
||||
print(f" - {pkg['name']}=={pkg['version']}")
|
||||
|
||||
# 4. Show install/uninstall commands (without executing)
|
||||
print("\n" + "=" * 60)
|
||||
print("4. INSTALL/UNINSTALL COMMANDS (NOT EXECUTED)")
|
||||
print("=" * 60)
|
||||
|
||||
print("\nTo install packages, use:")
|
||||
print(" praisonai install requests")
|
||||
print(" praisonai install 'requests>=2.28'")
|
||||
print(" praisonai install requests httpx --upgrade")
|
||||
|
||||
print("\nTo uninstall packages, use:")
|
||||
print(" praisonai uninstall requests")
|
||||
print(" praisonai uninstall requests --yes")
|
||||
|
||||
# 5. Security features
|
||||
print("\n" + "=" * 60)
|
||||
print("5. SECURITY FEATURES")
|
||||
print("=" * 60)
|
||||
|
||||
print("\nDependency Confusion Prevention:")
|
||||
print(" - By default, only PyPI is used as package source")
|
||||
print(" - Extra index URLs are blocked unless explicitly allowed")
|
||||
print(" - Use --allow-extra-index to enable (shows warning)")
|
||||
|
||||
print("\nExample (blocked by default):")
|
||||
print(" praisonai install pkg --extra-index-url https://other.index.com/simple")
|
||||
print(" → Error: Extra index URLs not allowed")
|
||||
|
||||
print("\nExample (explicitly allowed):")
|
||||
print(" praisonai install pkg --extra-index-url https://other.index.com/simple --allow-extra-index")
|
||||
print(" → Warning shown, then proceeds")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed!")
|
||||
print("=" * 60)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,11 @@
|
||||
# Creator Suite Examples - Environment Variables
|
||||
|
||||
# Required for most features
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
|
||||
# Optional - for web search in news crawler
|
||||
TAVILY_API_KEY=your_tavily_api_key_here
|
||||
|
||||
# Optional - for additional LLM providers
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
GOOGLE_API_KEY=your_google_api_key_here
|
||||
@@ -0,0 +1,77 @@
|
||||
# Creator Suite Examples
|
||||
|
||||
Examples for the PraisonAI Creator Automation Suite - a complete toolkit for AI content creators.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Set your API key
|
||||
export OPENAI_API_KEY=your_key_here
|
||||
|
||||
# Run an example
|
||||
python news_crawler_example.py
|
||||
python script_writer_example.py
|
||||
python hook_generator_example.py
|
||||
python brief_generator_example.py
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
| Example | Description | API Key Required |
|
||||
|---------|-------------|------------------|
|
||||
| `news_crawler_example.py` | Crawl AI news from multiple sources | No (HN, arXiv) |
|
||||
| `script_writer_example.py` | Generate scripts for YouTube, X, LinkedIn | Yes (OpenAI) |
|
||||
| `hook_generator_example.py` | Generate attention-grabbing hooks | Yes (OpenAI) |
|
||||
| `brief_generator_example.py` | Generate news briefs | Yes (OpenAI) |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env` and fill in your API keys:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
## Features Covered
|
||||
|
||||
### News Intelligence Layer
|
||||
- AI News Crawler
|
||||
- AI News Deduper
|
||||
- AI Signal Ranker
|
||||
- AI Context Enricher
|
||||
- AI Brief Generator
|
||||
|
||||
### Script & Story Layer
|
||||
- AI Script Writer
|
||||
- AI Angle Generator
|
||||
- AI Hook Generator
|
||||
- AI CTA Generator
|
||||
- AI Fact Checker
|
||||
|
||||
### Capture & Screen Automation
|
||||
- AI Screenshot Capture
|
||||
- AI Screen Recorder
|
||||
- AI News Capture Pack
|
||||
|
||||
### Video Generation
|
||||
- AI B-roll Builder
|
||||
- AI Voiceover Generator
|
||||
- AI Video Merger
|
||||
|
||||
### Publishing & Distribution
|
||||
- AI Publisher Pack
|
||||
- AI Content Calendar
|
||||
- AI Post Copy Generator
|
||||
- AI Hashtag Optimizer
|
||||
|
||||
### Feedback & Learning
|
||||
- AI Performance Analyzer
|
||||
- AI Comment Miner
|
||||
- AI A/B Hook Tester
|
||||
|
||||
### Orchestrator
|
||||
- AI Daily News Show (end-to-end pipeline)
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation: https://docs.praison.ai/docs/ai-tools/creator-suite/
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Brief Generator Example
|
||||
|
||||
Demonstrates generating news briefs from articles.
|
||||
Requires: OPENAI_API_KEY
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Check for API key
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("ERROR: OPENAI_API_KEY environment variable required")
|
||||
print("Set it with: export OPENAI_API_KEY=your_key")
|
||||
sys.exit(1)
|
||||
|
||||
# Add the Agent-Recipes templates to path
|
||||
sys.path.insert(0, '/Users/praison/Agent-Recipes/agent_recipes/templates/ai-brief-generator')
|
||||
|
||||
from tools import generate_brief, extract_highlights
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("AI Brief Generator Example")
|
||||
print("=" * 60)
|
||||
|
||||
# Sample articles
|
||||
articles = [
|
||||
{
|
||||
"title": "OpenAI Releases GPT-5 with Multimodal Capabilities",
|
||||
"content": "OpenAI announced GPT-5 today with major improvements in reasoning, multimodal understanding, and reduced hallucinations.",
|
||||
"source": "TechCrunch",
|
||||
"url": "https://example.com/gpt5"
|
||||
},
|
||||
{
|
||||
"title": "Google Gemini 2.0 Launches with Real-time Video Understanding",
|
||||
"content": "Google unveiled Gemini 2.0 with breakthrough capabilities in real-time video analysis and native tool use.",
|
||||
"source": "The Verge",
|
||||
"url": "https://example.com/gemini2"
|
||||
},
|
||||
{
|
||||
"title": "Anthropic Claude 4 Sets New Benchmarks",
|
||||
"content": "Anthropic's Claude 4 achieves state-of-the-art results on coding, math, and reasoning benchmarks.",
|
||||
"source": "Wired",
|
||||
"url": "https://example.com/claude4"
|
||||
}
|
||||
]
|
||||
|
||||
# 1. Generate daily brief
|
||||
print("\n1. Generating daily brief...")
|
||||
brief = generate_brief(articles, format="daily", max_articles=3)
|
||||
print(f" Format: daily")
|
||||
print(f" Articles included: {brief.get('article_count', 0)}")
|
||||
print(f"\n Brief preview:\n {brief.get('brief', '')[:500]}...")
|
||||
|
||||
# 2. Extract highlights
|
||||
print("\n2. Extracting highlights...")
|
||||
highlights = extract_highlights(articles, num_highlights=3)
|
||||
print(f" Highlights:")
|
||||
for h in highlights.get('highlights', []):
|
||||
print(f" - {h}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Hook Generator Example
|
||||
|
||||
Demonstrates generating attention-grabbing hooks.
|
||||
Requires: OPENAI_API_KEY
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Check for API key
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("ERROR: OPENAI_API_KEY environment variable required")
|
||||
print("Set it with: export OPENAI_API_KEY=your_key")
|
||||
sys.exit(1)
|
||||
|
||||
# Add the Agent-Recipes templates to path
|
||||
sys.path.insert(0, '/Users/praison/Agent-Recipes/agent_recipes/templates/ai-hook-generator')
|
||||
|
||||
from tools import generate_hooks, rank_hooks
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("AI Hook Generator Example")
|
||||
print("=" * 60)
|
||||
|
||||
topic = "GPT-5 just released with groundbreaking capabilities"
|
||||
|
||||
# 1. Generate hooks
|
||||
print(f"\n1. Generating hooks for: '{topic}'")
|
||||
hooks = generate_hooks(topic, num_variants=5)
|
||||
print(f" Generated {len(hooks.get('hooks', []))} hooks:")
|
||||
|
||||
for hook in hooks.get('hooks', []):
|
||||
print(f"\n [{hook.get('style', 'unknown')}]")
|
||||
print(f" {hook.get('text', '')}")
|
||||
|
||||
# 2. Rank hooks
|
||||
print("\n2. Ranking hooks by engagement potential...")
|
||||
ranked = rank_hooks(hooks.get('hooks', []))
|
||||
best = ranked.get('best_hook', {})
|
||||
print(f"\n Best hook (score: {best.get('score', 0)}):")
|
||||
print(f" {best.get('text', '')}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI News Crawler Example
|
||||
|
||||
Demonstrates crawling AI news from multiple sources.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the Agent-Recipes templates to path
|
||||
sys.path.insert(0, '/Users/praison/Agent-Recipes/agent_recipes/templates/ai-news-crawler')
|
||||
|
||||
from tools import crawl_hackernews, crawl_reddit_ai, crawl_arxiv, crawl_ai_news
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("AI News Crawler Example")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. Crawl HackerNews (no API key needed)
|
||||
print("\n1. Crawling HackerNews...")
|
||||
hn_articles = crawl_hackernews(max_articles=5, time_window_hours=48)
|
||||
print(f" Found {len(hn_articles)} articles from HackerNews")
|
||||
for article in hn_articles[:3]:
|
||||
print(f" - {article.get('title', 'No title')[:60]}...")
|
||||
|
||||
# 2. Crawl arXiv (no API key needed)
|
||||
print("\n2. Crawling arXiv...")
|
||||
arxiv_articles = crawl_arxiv(categories=["cs.AI", "cs.LG"], max_results=5)
|
||||
print(f" Found {len(arxiv_articles)} articles from arXiv")
|
||||
for article in arxiv_articles[:3]:
|
||||
print(f" - {article.get('title', 'No title')[:60]}...")
|
||||
|
||||
# 3. Full crawl from multiple sources
|
||||
print("\n3. Full multi-source crawl...")
|
||||
all_articles = crawl_ai_news(
|
||||
sources=["hackernews", "arxiv"],
|
||||
max_articles=10,
|
||||
time_window_hours=48
|
||||
)
|
||||
print(f" Total articles: {all_articles.get('total', 0)}")
|
||||
print(f" Sources crawled: {all_articles.get('sources_crawled', [])}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Script Writer Example
|
||||
|
||||
Demonstrates generating scripts for different platforms.
|
||||
Requires: OPENAI_API_KEY
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Check for API key
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("ERROR: OPENAI_API_KEY environment variable required")
|
||||
print("Set it with: export OPENAI_API_KEY=your_key")
|
||||
sys.exit(1)
|
||||
|
||||
# Add the Agent-Recipes templates to path
|
||||
sys.path.insert(0, '/Users/praison/Agent-Recipes/agent_recipes/templates/ai-script-writer')
|
||||
|
||||
from tools import write_youtube_script, write_short_script, write_thread
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("AI Script Writer Example")
|
||||
print("=" * 60)
|
||||
|
||||
topic = "AI agents are revolutionizing software development"
|
||||
|
||||
# 1. YouTube Long-form Script
|
||||
print("\n1. Generating YouTube long-form script...")
|
||||
yt_script = write_youtube_script(
|
||||
topic=topic,
|
||||
target_length=180,
|
||||
key_points=["automation", "productivity", "future of coding"]
|
||||
)
|
||||
print(f" Format: {yt_script.get('format')}")
|
||||
print(f" Estimated duration: {yt_script.get('estimated_duration')}s")
|
||||
print(f" Script preview:\n {yt_script.get('script', '')[:200]}...")
|
||||
|
||||
# 2. YouTube Short Script
|
||||
print("\n2. Generating YouTube Short script...")
|
||||
short_script = write_short_script(topic, duration=30)
|
||||
print(f" Format: {short_script.get('format')}")
|
||||
print(f" Script:\n {short_script.get('script', '')[:300]}...")
|
||||
|
||||
# 3. X Thread
|
||||
print("\n3. Generating X thread...")
|
||||
thread = write_thread(topic, num_tweets=3)
|
||||
print(f" Thread ({len(thread.get('tweets', []))} tweets):")
|
||||
for i, tweet in enumerate(thread.get('tweets', [])[:3], 1):
|
||||
print(f" Tweet {i}: {tweet[:80]}...")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+47
-24
@@ -1,48 +1,71 @@
|
||||
# Recipe Registry Example
|
||||
|
||||
This example demonstrates how to use the PraisonAI recipe registry for publishing and pulling recipes.
|
||||
This example demonstrates how to use the PraisonAI recipe registry for publishing and pulling recipes via both local filesystem and HTTP server.
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
- Creating a local registry
|
||||
- Packing recipes into bundles
|
||||
- Publishing recipes to the registry
|
||||
- Starting an HTTP registry server
|
||||
- Publishing recipes with token authentication
|
||||
- Listing and searching recipes
|
||||
- Pulling recipes from the registry
|
||||
- Pulling recipes from HTTP registry
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run the example
|
||||
python registry_example.py
|
||||
# Run the HTTP registry example
|
||||
python http_registry_example.py
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
## HTTP Registry Server
|
||||
|
||||
```bash
|
||||
# Publish a recipe
|
||||
# Start HTTP registry server
|
||||
praisonai registry serve --port 7777
|
||||
|
||||
# Start with authentication
|
||||
praisonai registry serve --port 7777 --token mysecret
|
||||
|
||||
# Check server status
|
||||
praisonai registry status --registry http://localhost:7777
|
||||
```
|
||||
|
||||
## CLI Commands with HTTP Registry
|
||||
|
||||
```bash
|
||||
# Publish to HTTP registry
|
||||
praisonai recipe publish ./my-recipe --registry http://localhost:7777 --json
|
||||
|
||||
# Publish with token
|
||||
praisonai recipe publish ./my-recipe --registry http://localhost:7777 --token mysecret
|
||||
|
||||
# Pull from HTTP registry
|
||||
praisonai recipe pull [email protected] --registry http://localhost:7777 -o ./recipes
|
||||
|
||||
# List recipes from HTTP registry
|
||||
praisonai recipe list --registry http://localhost:7777 --json
|
||||
|
||||
# Search HTTP registry
|
||||
praisonai recipe search "hello" --registry http://localhost:7777
|
||||
```
|
||||
|
||||
## Local Registry Commands
|
||||
|
||||
```bash
|
||||
# Publish to local registry (default)
|
||||
praisonai recipe publish ./my-recipe --json
|
||||
|
||||
# Pull a recipe
|
||||
# Pull from local registry
|
||||
praisonai recipe pull [email protected] -o ./recipes
|
||||
|
||||
# List recipes in registry
|
||||
praisonai recipe list --json
|
||||
|
||||
# Search recipes
|
||||
praisonai recipe search "hello"
|
||||
```
|
||||
|
||||
## Using a Custom Registry Path
|
||||
|
||||
```bash
|
||||
# Publish to custom registry
|
||||
# Use custom local path
|
||||
praisonai recipe publish ./my-recipe --registry /path/to/registry
|
||||
|
||||
# Pull from custom registry
|
||||
praisonai recipe pull [email protected] --registry /path/to/registry
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `PRAISONAI_REGISTRY_TOKEN` - Default token for HTTP registry authentication
|
||||
|
||||
## Default Registry Location
|
||||
|
||||
By default, the registry is stored at `~/.praison/registry`.
|
||||
By default, the local registry is stored at `~/.praison/registry`.
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
HTTP Recipe Registry Example
|
||||
|
||||
This example demonstrates:
|
||||
1. Starting an HTTP registry server programmatically
|
||||
2. Publishing recipes with token authentication
|
||||
3. Listing and searching recipes via HTTP
|
||||
4. Pulling recipes from HTTP registry
|
||||
|
||||
Requirements:
|
||||
- praisonai installed
|
||||
- No external API keys required (local registry only)
|
||||
|
||||
Environment Variables (optional):
|
||||
- PRAISONAI_REGISTRY_TOKEN: Token for authentication
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
def create_sample_recipe(output_dir: Path) -> Path:
|
||||
"""Create a sample recipe for testing."""
|
||||
recipe_dir = output_dir / "sample-agent"
|
||||
recipe_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create manifest.json
|
||||
manifest = {
|
||||
"name": "sample-agent",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample agent recipe for registry testing",
|
||||
"tags": ["sample", "agent", "test"],
|
||||
"author": "praison",
|
||||
"files": ["recipe.yaml"]
|
||||
}
|
||||
(recipe_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
|
||||
|
||||
# Create recipe.yaml
|
||||
recipe_yaml = """
|
||||
name: sample-agent
|
||||
version: "1.0.0"
|
||||
description: A sample agent recipe
|
||||
|
||||
agents:
|
||||
- name: assistant
|
||||
role: General Assistant
|
||||
goal: Help users with their questions
|
||||
instructions: You are a helpful assistant.
|
||||
|
||||
tasks:
|
||||
- name: respond
|
||||
description: Respond to user query
|
||||
agent: assistant
|
||||
"""
|
||||
(recipe_dir / "recipe.yaml").write_text(recipe_yaml.strip())
|
||||
|
||||
# Pack into .praison bundle
|
||||
bundle_path = output_dir / "sample-agent-1.0.0.praison"
|
||||
with tarfile.open(bundle_path, "w:gz") as tar:
|
||||
for file in recipe_dir.iterdir():
|
||||
tar.add(file, arcname=file.name)
|
||||
|
||||
return bundle_path
|
||||
|
||||
|
||||
def start_registry_server(port: int, registry_path: Path, token: str = None):
|
||||
"""Start registry server in background process."""
|
||||
cmd = [
|
||||
sys.executable, "-c",
|
||||
f"""
|
||||
import sys
|
||||
sys.path.insert(0, '/Users/praison/praisonai-package/src/praisonai')
|
||||
from praisonai.recipe.server import create_wsgi_app
|
||||
from wsgiref.simple_server import make_server, WSGIRequestHandler
|
||||
from pathlib import Path
|
||||
|
||||
class QuietHandler(WSGIRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
app = create_wsgi_app(registry_path=Path("{registry_path}"), token={repr(token)})
|
||||
server = make_server("127.0.0.1", {port}, app, handler_class=QuietHandler)
|
||||
server.serve_forever()
|
||||
"""
|
||||
]
|
||||
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return proc
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("HTTP Recipe Registry Example")
|
||||
print("=" * 60)
|
||||
|
||||
# Setup
|
||||
work_dir = Path(tempfile.mkdtemp(prefix="registry_example_"))
|
||||
registry_path = work_dir / "registry"
|
||||
pulled_dir = work_dir / "pulled"
|
||||
port = 7799
|
||||
token = "example-secret-token"
|
||||
|
||||
print(f"\nWork directory: {work_dir}")
|
||||
print(f"Registry path: {registry_path}")
|
||||
print(f"Server port: {port}")
|
||||
print(f"Token: {'*' * len(token)} (redacted)")
|
||||
|
||||
try:
|
||||
# 1. Create sample recipe
|
||||
print("\n[1] Creating sample recipe...")
|
||||
bundle_path = create_sample_recipe(work_dir)
|
||||
print(f" ✓ Created bundle: {bundle_path.name}")
|
||||
|
||||
# 2. Start HTTP registry server
|
||||
print("\n[2] Starting HTTP registry server...")
|
||||
server = start_registry_server(port, registry_path, token)
|
||||
time.sleep(1) # Wait for server to start
|
||||
print(f" ✓ Server running on http://127.0.0.1:{port}")
|
||||
|
||||
# 3. Check health
|
||||
print("\n[3] Checking server health...")
|
||||
result = subprocess.run(
|
||||
["curl", "-s", f"http://127.0.0.1:{port}/healthz"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
health = json.loads(result.stdout)
|
||||
print(f" Status: {health['status']}")
|
||||
print(f" Auth required: {health['auth_required']}")
|
||||
|
||||
# 4. Publish WITHOUT token (should fail)
|
||||
print("\n[4] Publishing WITHOUT token (expect failure)...")
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "praisonai", "recipe", "publish", str(bundle_path),
|
||||
"--registry", f"http://127.0.0.1:{port}", "--json"],
|
||||
capture_output=True, text=True,
|
||||
cwd="/Users/praison/praisonai-package/src/praisonai"
|
||||
)
|
||||
if result.returncode != 0 or "error" in result.stdout.lower() or "401" in result.stdout:
|
||||
print(" ✓ Correctly rejected (authentication required)")
|
||||
else:
|
||||
print(f" ✗ Unexpected success: {result.stdout}")
|
||||
|
||||
# 5. Publish WITH token (should succeed)
|
||||
print("\n[5] Publishing WITH token...")
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "praisonai", "recipe", "publish", str(bundle_path),
|
||||
"--registry", f"http://127.0.0.1:{port}", "--token", token, "--json"],
|
||||
capture_output=True, text=True,
|
||||
cwd="/Users/praison/praisonai-package/src/praisonai"
|
||||
)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
if data.get("ok"):
|
||||
print(f" ✓ Published: {data['name']}@{data['version']}")
|
||||
else:
|
||||
print(f" ✗ Failed: {result.stdout}")
|
||||
return 1
|
||||
except json.JSONDecodeError:
|
||||
print(f" Output: {result.stdout}")
|
||||
print(f" Stderr: {result.stderr}")
|
||||
return 1
|
||||
|
||||
# 6. List recipes
|
||||
print("\n[6] Listing recipes from registry...")
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "praisonai", "recipe", "list",
|
||||
"--registry", f"http://127.0.0.1:{port}", "--json"],
|
||||
capture_output=True, text=True,
|
||||
cwd="/Users/praison/praisonai-package/src/praisonai"
|
||||
)
|
||||
recipes = json.loads(result.stdout)
|
||||
print(f" Found {len(recipes)} recipe(s):")
|
||||
for r in recipes:
|
||||
print(f" - {r['name']} v{r['version']}: {r.get('description', '')[:40]}")
|
||||
|
||||
# 7. Search recipes
|
||||
print("\n[7] Searching for 'agent'...")
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "praisonai", "recipe", "search", "agent",
|
||||
"--registry", f"http://127.0.0.1:{port}", "--json"],
|
||||
capture_output=True, text=True,
|
||||
cwd="/Users/praison/praisonai-package/src/praisonai"
|
||||
)
|
||||
matches = json.loads(result.stdout)
|
||||
print(f" Found {len(matches)} match(es)")
|
||||
|
||||
# 8. Pull recipe
|
||||
print("\n[8] Pulling recipe...")
|
||||
pulled_dir.mkdir(parents=True, exist_ok=True)
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "praisonai", "recipe", "pull", "sample-agent",
|
||||
"--registry", f"http://127.0.0.1:{port}", "-o", str(pulled_dir), "--json"],
|
||||
capture_output=True, text=True,
|
||||
cwd="/Users/praison/praisonai-package/src/praisonai"
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
if data.get("ok"):
|
||||
print(f" ✓ Pulled to: {data['path']}")
|
||||
|
||||
# Verify content
|
||||
manifest_path = Path(data['path']) / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
print(f" ✓ Verified: {manifest['name']}@{manifest['version']}")
|
||||
else:
|
||||
print(f" ✗ Failed: {result.stdout}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
# Cleanup
|
||||
server.terminate()
|
||||
server.wait()
|
||||
|
||||
finally:
|
||||
# Clean up temp directory
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,66 @@
|
||||
# Recipe Background Tasks Example
|
||||
|
||||
This example demonstrates how to run recipes and agents as background tasks.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
pip install praisonai praisonaiagents
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
## Python Example
|
||||
|
||||
```bash
|
||||
python example_background.py
|
||||
```
|
||||
|
||||
## CLI Examples
|
||||
|
||||
### Submit a recipe as background task
|
||||
|
||||
```bash
|
||||
# Submit recipe to background
|
||||
praisonai background submit --recipe my-recipe --input '{"query": "test"}'
|
||||
|
||||
# Check status
|
||||
praisonai background status <task_id>
|
||||
|
||||
# List all tasks
|
||||
praisonai background list
|
||||
|
||||
# Cancel a task
|
||||
praisonai background cancel <task_id>
|
||||
|
||||
# Clear completed tasks
|
||||
praisonai background clear
|
||||
```
|
||||
|
||||
### Run recipe with --background flag
|
||||
|
||||
```bash
|
||||
# Run recipe in background
|
||||
praisonai recipe run my-recipe --background
|
||||
|
||||
# With input
|
||||
praisonai recipe run my-recipe --background --input '{"topic": "AI"}'
|
||||
|
||||
# With session ID
|
||||
praisonai recipe run my-recipe --background --session-id session_123
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Async Execution**: Tasks run in the background without blocking
|
||||
- **Progress Tracking**: Monitor task progress and status
|
||||
- **Cancellation**: Cancel running tasks when needed
|
||||
- **Result Retrieval**: Get results when tasks complete
|
||||
- **Concurrency Control**: Limit concurrent tasks
|
||||
|
||||
## Safe Defaults
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `timeout_sec` | 300 | Maximum execution time |
|
||||
| `max_concurrent` | 5 | Maximum concurrent tasks |
|
||||
| `cleanup_delay_sec` | 3600 | Auto-cleanup delay |
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Recipe Background Tasks Example
|
||||
|
||||
This example demonstrates how to run recipes as background tasks
|
||||
with progress tracking and result retrieval.
|
||||
|
||||
Requirements:
|
||||
pip install praisonai
|
||||
|
||||
Usage:
|
||||
python example_background.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run a recipe as a background task."""
|
||||
|
||||
print("=" * 60)
|
||||
print("Recipe Background Tasks Example")
|
||||
print("=" * 60)
|
||||
|
||||
# For this example, we'll use a simple inline agent instead of a recipe
|
||||
# In production, you would use an actual recipe name
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.background import BackgroundRunner
|
||||
|
||||
# Create an agent
|
||||
agent = Agent(
|
||||
name="Research Assistant",
|
||||
instructions="You are a helpful research assistant. Provide concise answers.",
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Create background runner
|
||||
runner = BackgroundRunner(max_concurrent_tasks=3)
|
||||
|
||||
print("\n1. Submitting task to background...")
|
||||
|
||||
# Submit task
|
||||
task = await runner.submit(
|
||||
lambda: agent.start("What are the top 3 benefits of AI in healthcare?"),
|
||||
name="healthcare-research",
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
print(f" Task ID: {task.id}")
|
||||
print(f" Status: {task.status.value}")
|
||||
|
||||
print("\n2. Waiting for completion...")
|
||||
|
||||
# Wait for result
|
||||
result = await runner.wait_for_task(task.id, timeout=120)
|
||||
|
||||
print("\n3. Task completed!")
|
||||
print(f" Status: {task.status.value}")
|
||||
print(f" Result preview: {str(result)[:200]}...")
|
||||
|
||||
# List all tasks
|
||||
print("\n4. Listing all tasks:")
|
||||
tasks = runner.list_tasks()
|
||||
for t in tasks:
|
||||
print(" - %s: %s" % (t['id'], t['status']))
|
||||
|
||||
# Clear completed tasks
|
||||
print("\n5. Clearing completed tasks...")
|
||||
runner.clear_completed()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,97 @@
|
||||
# Recipe Async Jobs Example
|
||||
|
||||
This example demonstrates how to submit recipes as async jobs to a jobs server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
pip install praisonai praisonaiagents httpx uvicorn
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
## Start the Jobs Server
|
||||
|
||||
```bash
|
||||
python -m uvicorn praisonai.jobs.server:create_app --port 8005 --factory
|
||||
```
|
||||
|
||||
## Python Example
|
||||
|
||||
```bash
|
||||
python example_jobs.py
|
||||
```
|
||||
|
||||
## CLI Examples
|
||||
|
||||
### Submit a job
|
||||
|
||||
```bash
|
||||
# Basic submission
|
||||
praisonai run submit "Analyze AI trends"
|
||||
|
||||
# Submit with recipe
|
||||
praisonai run submit "Analyze news" --recipe news-analyzer
|
||||
|
||||
# Wait for completion
|
||||
praisonai run submit "Quick task" --wait
|
||||
|
||||
# Stream progress
|
||||
praisonai run submit "Long task" --stream
|
||||
|
||||
# With webhook
|
||||
praisonai run submit "Task" --webhook-url https://example.com/callback
|
||||
|
||||
# With idempotency
|
||||
praisonai run submit "Task" --idempotency-key order-123
|
||||
|
||||
# With metadata
|
||||
praisonai run submit "Task" --metadata user=john --metadata priority=high
|
||||
```
|
||||
|
||||
### Check status
|
||||
|
||||
```bash
|
||||
praisonai run status <job_id>
|
||||
praisonai run status <job_id> --json
|
||||
```
|
||||
|
||||
### Get result
|
||||
|
||||
```bash
|
||||
praisonai run result <job_id>
|
||||
praisonai run result <job_id> --json
|
||||
```
|
||||
|
||||
### Stream progress
|
||||
|
||||
```bash
|
||||
praisonai run stream <job_id>
|
||||
```
|
||||
|
||||
### List jobs
|
||||
|
||||
```bash
|
||||
praisonai run list
|
||||
praisonai run list --status running
|
||||
```
|
||||
|
||||
### Cancel job
|
||||
|
||||
```bash
|
||||
praisonai run cancel <job_id>
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Server-based**: Jobs persist across restarts
|
||||
- **Webhooks**: Get notified when jobs complete
|
||||
- **Idempotency**: Prevent duplicate submissions
|
||||
- **Streaming**: Real-time progress updates
|
||||
- **Metadata**: Attach custom data to jobs
|
||||
|
||||
## Safe Defaults
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `timeout` | 3600 | Maximum execution time (1 hour) |
|
||||
| `api_url` | http://127.0.0.1:8005 | Jobs server URL |
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Recipe Async Jobs Example
|
||||
|
||||
This example demonstrates how to submit recipes as async jobs
|
||||
to a jobs server with webhooks and idempotency.
|
||||
|
||||
Requirements:
|
||||
pip install praisonai httpx
|
||||
|
||||
Usage:
|
||||
1. Start the jobs server:
|
||||
python -m uvicorn praisonai.jobs.server:create_app --port 8005 --factory
|
||||
|
||||
2. Run this example:
|
||||
python example_jobs.py
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
|
||||
def main():
|
||||
"""Submit and monitor an async job."""
|
||||
|
||||
print("=" * 60)
|
||||
print("Recipe Async Jobs Example")
|
||||
print("=" * 60)
|
||||
|
||||
# Import the jobs handler
|
||||
from praisonai.cli.features.jobs import JobsHandler
|
||||
|
||||
# Create handler
|
||||
handler = JobsHandler(
|
||||
api_url="http://127.0.0.1:8005",
|
||||
verbose=True
|
||||
)
|
||||
|
||||
print("\n1. Submitting job...")
|
||||
|
||||
try:
|
||||
# Submit a job
|
||||
result = handler.submit(
|
||||
prompt="What are the key trends in AI for 2024?",
|
||||
framework="praisonai",
|
||||
timeout=300,
|
||||
wait=False, # Don't wait, we'll poll manually
|
||||
output_json=False
|
||||
)
|
||||
|
||||
job_id = result.get("job_id")
|
||||
print(f" Job ID: {job_id}")
|
||||
print(f" Status: {result.get('status')}")
|
||||
|
||||
print("\n2. Polling for completion...")
|
||||
|
||||
# Poll for completion
|
||||
max_polls = 60
|
||||
for i in range(max_polls):
|
||||
status_result = handler.status(job_id)
|
||||
status = status_result.get("status")
|
||||
progress = status_result.get("progress", 0)
|
||||
|
||||
print(f" [{i+1}/{max_polls}] Status: {status}, Progress: {progress}%")
|
||||
|
||||
if status in ["succeeded", "failed", "cancelled"]:
|
||||
break
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
print("\n3. Getting result...")
|
||||
|
||||
# Get result
|
||||
result = handler.result(job_id)
|
||||
print(f" Status: {result.get('status')}")
|
||||
|
||||
if result.get("result"):
|
||||
output = str(result.get("result"))
|
||||
print(f" Result preview: {output[:200]}...")
|
||||
|
||||
print("\n4. Listing all jobs...")
|
||||
|
||||
# List jobs
|
||||
handler.list_jobs(page=1, page_size=10)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n Error: {e}")
|
||||
print("\n Make sure the jobs server is running:")
|
||||
print(" python -m uvicorn praisonai.jobs.server:create_app --port 8005 --factory")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
# Recipe Scheduler Example
|
||||
|
||||
This example demonstrates how to schedule recipes and agents to run periodically.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
pip install praisonai praisonaiagents
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
## Python Example
|
||||
|
||||
```bash
|
||||
python example_scheduler.py
|
||||
```
|
||||
|
||||
Press Ctrl+C to stop the scheduler and see statistics.
|
||||
|
||||
## CLI Examples
|
||||
|
||||
### Start a scheduler with task
|
||||
|
||||
```bash
|
||||
# Basic scheduler
|
||||
praisonai schedule start news-checker "Check AI news" --interval hourly
|
||||
|
||||
# With timeout and budget
|
||||
praisonai schedule start reporter "Generate report" \
|
||||
--interval daily \
|
||||
--timeout 300 \
|
||||
--max-cost 1.00
|
||||
|
||||
# With max retries
|
||||
praisonai schedule start checker "Check updates" \
|
||||
--interval hourly \
|
||||
--max-retries 5
|
||||
```
|
||||
|
||||
### Start a scheduler with recipe
|
||||
|
||||
```bash
|
||||
# Schedule a recipe
|
||||
praisonai schedule start news-monitor --recipe news-analyzer --interval hourly
|
||||
|
||||
# With all options
|
||||
praisonai schedule start my-scheduler \
|
||||
--recipe my-recipe \
|
||||
--interval "*/6h" \
|
||||
--timeout 600 \
|
||||
--max-cost 2.00 \
|
||||
--max-retries 3
|
||||
```
|
||||
|
||||
### Foreground mode (for testing)
|
||||
|
||||
```bash
|
||||
# Run in foreground (Ctrl+C to stop)
|
||||
praisonai schedule "Check news" --interval "*/30s"
|
||||
```
|
||||
|
||||
### Manage schedulers
|
||||
|
||||
```bash
|
||||
# List all schedulers
|
||||
praisonai schedule list
|
||||
|
||||
# View logs
|
||||
praisonai schedule logs news-checker --follow
|
||||
|
||||
# Show statistics
|
||||
praisonai schedule stats news-checker
|
||||
|
||||
# Stop a scheduler
|
||||
praisonai schedule stop news-checker
|
||||
|
||||
# Stop all schedulers
|
||||
praisonai schedule stop-all
|
||||
|
||||
# Delete a scheduler
|
||||
praisonai schedule delete news-checker
|
||||
```
|
||||
|
||||
### Export scheduler config
|
||||
|
||||
```bash
|
||||
praisonai schedule save news-checker --output news-checker.yaml
|
||||
```
|
||||
|
||||
## Interval Formats
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
| `hourly` | Every hour |
|
||||
| `daily` | Every day |
|
||||
| `*/30m` | Every 30 minutes |
|
||||
| `*/6h` | Every 6 hours |
|
||||
| `*/30s` | Every 30 seconds (for testing) |
|
||||
| `3600` | Every 3600 seconds |
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Periodic Execution**: Run tasks at regular intervals
|
||||
- **Retry Logic**: Automatic retries on failure
|
||||
- **Cost Budgeting**: Set spending limits
|
||||
- **Daemon Mode**: Run in background
|
||||
- **Statistics**: Track success rates
|
||||
|
||||
## Safe Defaults
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `interval` | hourly | Execution interval |
|
||||
| `max_retries` | 3 | Retry attempts |
|
||||
| `timeout` | 300 | Timeout per execution |
|
||||
| `max_cost` | 1.00 | Budget limit (USD) |
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Recipe Scheduler Example
|
||||
|
||||
This example demonstrates how to schedule recipes and agents
|
||||
to run periodically with retry logic and cost budgeting.
|
||||
|
||||
Requirements:
|
||||
pip install praisonai praisonaiagents
|
||||
|
||||
Usage:
|
||||
python example_scheduler.py
|
||||
"""
|
||||
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run a scheduled agent."""
|
||||
|
||||
print("=" * 60)
|
||||
print("Recipe Scheduler Example")
|
||||
print("=" * 60)
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.scheduler import AgentScheduler
|
||||
|
||||
# Create an agent
|
||||
agent = Agent(
|
||||
name="News Monitor",
|
||||
instructions="You are a news monitoring assistant. Provide brief updates.",
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Create scheduler
|
||||
scheduler = AgentScheduler(
|
||||
agent=agent,
|
||||
task="What is the latest news in AI? Give a one-sentence summary.",
|
||||
timeout=60,
|
||||
max_cost=0.10 # $0.10 budget limit
|
||||
)
|
||||
|
||||
print("\n1. Starting scheduler...")
|
||||
print(" Interval: every 30 seconds (for demo)")
|
||||
print(" Max retries: 3")
|
||||
print(" Timeout: 60s")
|
||||
print(" Budget: $0.10")
|
||||
|
||||
# Handle Ctrl+C gracefully
|
||||
def signal_handler(sig, frame):
|
||||
print("\n\n2. Stopping scheduler...")
|
||||
scheduler.stop()
|
||||
|
||||
# Show stats
|
||||
stats = scheduler.get_stats()
|
||||
print("\n3. Final Statistics:")
|
||||
print(f" Total Executions: {stats['total_executions']}")
|
||||
print(f" Successful: {stats['successful_executions']}")
|
||||
print(f" Failed: {stats['failed_executions']}")
|
||||
print(f" Success Rate: {stats['success_rate']:.1f}%")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed!")
|
||||
print("=" * 60)
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
# Start scheduler with short interval for demo
|
||||
scheduler.start(
|
||||
schedule_expr="*/30s", # Every 30 seconds for demo
|
||||
max_retries=3,
|
||||
run_immediately=True
|
||||
)
|
||||
|
||||
print("\n Press Ctrl+C to stop...\n")
|
||||
|
||||
# Keep running
|
||||
while scheduler.is_running:
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
# Example TEMPLATE.yaml with Runtime Configuration
|
||||
# This demonstrates the new runtime block for background/job/schedule execution
|
||||
|
||||
schema_version: "1.0"
|
||||
name: news-monitor
|
||||
version: "1.0.0"
|
||||
description: |
|
||||
A recipe that monitors AI news and summarizes findings.
|
||||
Supports background execution, async jobs, and scheduled runs.
|
||||
|
||||
author: praisonai
|
||||
license: Apache-2.0
|
||||
tags:
|
||||
- news
|
||||
- monitoring
|
||||
- ai
|
||||
|
||||
# Dependencies
|
||||
requires:
|
||||
packages:
|
||||
- praisonaiagents>=0.0.1
|
||||
env:
|
||||
- OPENAI_API_KEY
|
||||
|
||||
# Entry points
|
||||
workflow: workflow.yaml
|
||||
agents: agents.yaml
|
||||
|
||||
# Configuration schema
|
||||
config:
|
||||
topic:
|
||||
type: string
|
||||
description: Topic to monitor
|
||||
default: "AI and machine learning"
|
||||
max_articles:
|
||||
type: integer
|
||||
description: Maximum articles to fetch
|
||||
default: 5
|
||||
|
||||
# Default values
|
||||
defaults:
|
||||
topic: "AI and machine learning"
|
||||
max_articles: 5
|
||||
|
||||
# Runtime configuration for operational features
|
||||
runtime:
|
||||
# Background task execution
|
||||
background:
|
||||
enabled: true
|
||||
max_concurrent: 3
|
||||
cleanup_delay_sec: 3600
|
||||
|
||||
# Async job execution
|
||||
job:
|
||||
enabled: true
|
||||
timeout_sec: 600
|
||||
webhook_url: "${WEBHOOK_URL}" # Environment variable interpolation
|
||||
idempotency_scope: "session"
|
||||
events:
|
||||
- completed
|
||||
- failed
|
||||
|
||||
# Scheduled execution
|
||||
schedule:
|
||||
enabled: true
|
||||
interval: "hourly"
|
||||
max_retries: 3
|
||||
run_immediately: false
|
||||
timeout_sec: 300
|
||||
max_cost_usd: 1.00
|
||||
|
||||
# CLI integration
|
||||
cli:
|
||||
command: news-monitor
|
||||
arguments:
|
||||
- name: topic
|
||||
short: -t
|
||||
description: Topic to monitor
|
||||
- name: max-articles
|
||||
short: -n
|
||||
description: Maximum articles
|
||||
|
||||
# Example usage
|
||||
examples:
|
||||
- name: Basic usage
|
||||
command: praisonai recipe run news-monitor
|
||||
- name: With custom topic
|
||||
command: praisonai recipe run news-monitor -i '{"topic": "LLMs"}'
|
||||
- name: Background execution
|
||||
command: praisonai recipe run news-monitor --background
|
||||
- name: Submit as job
|
||||
command: praisonai run submit "Monitor AI news" --recipe news-monitor
|
||||
- name: Schedule hourly
|
||||
command: praisonai schedule news-monitor --interval hourly
|
||||
@@ -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.5.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.5.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.6.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.6.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -63,19 +63,30 @@ class AgentSchedulerHandler:
|
||||
from datetime import datetime
|
||||
|
||||
if not unknown_args:
|
||||
print("❌ Error: Please provide scheduler name and task")
|
||||
print("❌ Error: Please provide scheduler name and task/recipe")
|
||||
print("\nUsage:")
|
||||
print(' praisonai schedule start <name> "Your task" --interval hourly')
|
||||
print(' praisonai schedule start <name> --recipe <recipe-name> --interval hourly')
|
||||
print("\nExample:")
|
||||
print(' praisonai schedule start news-checker "Check AI news" --interval hourly')
|
||||
print(' praisonai schedule start news-checker --recipe news-monitor --interval hourly')
|
||||
return 1
|
||||
|
||||
# Parse arguments
|
||||
name = unknown_args[0]
|
||||
task = unknown_args[1] if len(unknown_args) > 1 else None
|
||||
|
||||
if not task:
|
||||
print("❌ Error: Please provide a task")
|
||||
# Check for --recipe flag in unknown_args
|
||||
recipe_name = None
|
||||
task = None
|
||||
if "--recipe" in unknown_args:
|
||||
recipe_idx = unknown_args.index("--recipe")
|
||||
if recipe_idx + 1 < len(unknown_args):
|
||||
recipe_name = unknown_args[recipe_idx + 1]
|
||||
else:
|
||||
task = unknown_args[1] if len(unknown_args) > 1 else None
|
||||
|
||||
if not task and not recipe_name:
|
||||
print("❌ Error: Please provide a task or --recipe <name>")
|
||||
return 1
|
||||
|
||||
# Get options
|
||||
@@ -93,7 +104,10 @@ class AgentSchedulerHandler:
|
||||
|
||||
# Start daemon
|
||||
print(f"🚀 Starting scheduler '{name}'...")
|
||||
print(f" Task: {task}")
|
||||
if recipe_name:
|
||||
print(f" Recipe: {recipe_name}")
|
||||
else:
|
||||
print(f" Task: {task}")
|
||||
print(f" Interval: {interval}")
|
||||
if timeout:
|
||||
print(f" Timeout: {timeout}s")
|
||||
@@ -103,6 +117,7 @@ class AgentSchedulerHandler:
|
||||
pid = daemon_manager.start_scheduler_daemon(
|
||||
name=name,
|
||||
task=task,
|
||||
recipe_name=recipe_name,
|
||||
interval=interval,
|
||||
max_cost=max_cost,
|
||||
timeout=timeout,
|
||||
@@ -114,6 +129,7 @@ class AgentSchedulerHandler:
|
||||
"name": name,
|
||||
"pid": pid,
|
||||
"task": task,
|
||||
"recipe_name": recipe_name,
|
||||
"interval": interval,
|
||||
"timeout": timeout,
|
||||
"max_cost": max_cost,
|
||||
@@ -555,6 +571,18 @@ class AgentSchedulerHandler:
|
||||
logging.getLogger('praisonai.scheduler').setLevel(logging.WARNING)
|
||||
|
||||
try:
|
||||
# Check if this is a recipe name (not a file, not a prompt with spaces)
|
||||
is_recipe_mode = False
|
||||
if first_arg and not is_yaml_mode:
|
||||
# Try to resolve as recipe if it looks like a recipe name (no spaces, no file extension)
|
||||
if ' ' not in first_arg and not first_arg.endswith('.yaml') and not first_arg.endswith('.yml'):
|
||||
try:
|
||||
from praisonai.recipe.bridge import resolve
|
||||
resolve(first_arg) # Just check if it resolves
|
||||
is_recipe_mode = True
|
||||
except Exception:
|
||||
pass # Not a recipe, continue with prompt mode
|
||||
|
||||
if is_yaml_mode:
|
||||
# YAML mode: Load from agents.yaml
|
||||
yaml_path = first_arg
|
||||
@@ -566,12 +594,23 @@ class AgentSchedulerHandler:
|
||||
timeout_override=timeout_override,
|
||||
max_cost_override=max_cost_override
|
||||
)
|
||||
elif is_recipe_mode:
|
||||
# Recipe mode: Load from recipe name
|
||||
print(f"🍳 Loading recipe: {first_arg}")
|
||||
scheduler = AgentScheduler.from_recipe(
|
||||
recipe_name=first_arg,
|
||||
interval_override=interval_override,
|
||||
max_retries_override=max_retries_override,
|
||||
timeout_override=timeout_override,
|
||||
max_cost_override=max_cost_override
|
||||
)
|
||||
else:
|
||||
# Prompt mode: Create agent from direct prompt
|
||||
if not first_arg:
|
||||
print("❌ Error: Please provide either a YAML file or a task prompt")
|
||||
print("❌ Error: Please provide either a YAML file, recipe name, or a task prompt")
|
||||
print("\nExamples:")
|
||||
print(" praisonai schedule agents.yaml")
|
||||
print(" praisonai schedule my-recipe --interval hourly")
|
||||
print(' praisonai schedule "Check news every hour" --interval hourly')
|
||||
return 1
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ Commands:
|
||||
- praisonai background clear # Clear completed tasks
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from typing import Optional, List
|
||||
|
||||
@@ -235,62 +234,123 @@ def handle_background_command(args: List[str], verbose: bool = False):
|
||||
Handle background CLI commands.
|
||||
|
||||
Usage:
|
||||
praisonai background list [--status <status>]
|
||||
praisonai background status <task_id>
|
||||
praisonai background cancel <task_id>
|
||||
praisonai background clear
|
||||
praisonai background list [--status <status>] [--json] [--page N] [--page-size N]
|
||||
praisonai background status <task_id> [--json]
|
||||
praisonai background cancel <task_id> [--json]
|
||||
praisonai background clear [--all] [--older-than SEC] [--json]
|
||||
praisonai background submit --recipe <name> [--input JSON] [--session-id ID] [--timeout SEC] [--json]
|
||||
"""
|
||||
handler = BackgroundHandler(verbose=verbose)
|
||||
import argparse
|
||||
import json
|
||||
|
||||
parser = argparse.ArgumentParser(prog="praisonai background", description="Manage background tasks")
|
||||
subparsers = parser.add_subparsers(dest="subcommand", help="Available commands")
|
||||
|
||||
# List command
|
||||
list_parser = subparsers.add_parser("list", help="List background tasks")
|
||||
list_parser.add_argument("--status", choices=["pending", "running", "completed", "failed", "cancelled"], help="Filter by status")
|
||||
list_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
|
||||
list_parser.add_argument("--page", type=int, default=1, help="Page number")
|
||||
list_parser.add_argument("--page-size", type=int, default=20, help="Tasks per page")
|
||||
|
||||
# Status command
|
||||
status_parser = subparsers.add_parser("status", help="Get task status")
|
||||
status_parser.add_argument("task_id", help="Task ID")
|
||||
status_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
|
||||
|
||||
# Cancel command
|
||||
cancel_parser = subparsers.add_parser("cancel", help="Cancel a task")
|
||||
cancel_parser.add_argument("task_id", help="Task ID")
|
||||
cancel_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
|
||||
|
||||
# Clear command
|
||||
clear_parser = subparsers.add_parser("clear", help="Clear completed tasks")
|
||||
clear_parser.add_argument("--all", action="store_true", help="Clear all tasks including running")
|
||||
clear_parser.add_argument("--older-than", type=int, help="Clear tasks older than N seconds")
|
||||
clear_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
|
||||
|
||||
# Submit command (NEW - for recipe background submission)
|
||||
submit_parser = subparsers.add_parser("submit", help="Submit a recipe as background task")
|
||||
submit_parser.add_argument("--recipe", required=True, dest="recipe_name", help="Recipe name to execute")
|
||||
submit_parser.add_argument("--input", "-i", dest="input_data", help="Input data as JSON string")
|
||||
submit_parser.add_argument("--config", "-c", help="Config overrides as JSON string")
|
||||
submit_parser.add_argument("--session-id", "-s", help="Session ID for conversation continuity")
|
||||
submit_parser.add_argument("--timeout", type=int, default=300, help="Timeout in seconds (default: 300)")
|
||||
submit_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
|
||||
|
||||
if not args:
|
||||
print("Usage: praisonai background <command> [options]")
|
||||
print("\nCommands:")
|
||||
print(" list [--status <status>] List background tasks")
|
||||
print(" status <task_id> Get task status")
|
||||
print(" cancel <task_id> Cancel a task")
|
||||
print(" clear Clear completed tasks")
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
command = args[0]
|
||||
# Handle legacy help
|
||||
if args[0] in ["help", "--help", "-h"]:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
if command == "list":
|
||||
status = None
|
||||
if "--status" in args:
|
||||
idx = args.index("--status")
|
||||
if idx + 1 < len(args):
|
||||
status = args[idx + 1]
|
||||
parsed = parser.parse_args(args)
|
||||
|
||||
if not parsed.subcommand:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
handler = BackgroundHandler(verbose=verbose)
|
||||
|
||||
try:
|
||||
if parsed.subcommand == "list":
|
||||
asyncio.run(handler.list_tasks(status=parsed.status))
|
||||
|
||||
asyncio.run(handler.list_tasks(status=status))
|
||||
|
||||
elif command == "status":
|
||||
if len(args) < 2:
|
||||
print("Usage: praisonai background status <task_id>")
|
||||
return
|
||||
elif parsed.subcommand == "status":
|
||||
asyncio.run(handler.get_status(parsed.task_id))
|
||||
|
||||
asyncio.run(handler.get_status(args[1]))
|
||||
|
||||
elif command == "cancel":
|
||||
if len(args) < 2:
|
||||
print("Usage: praisonai background cancel <task_id>")
|
||||
return
|
||||
elif parsed.subcommand == "cancel":
|
||||
asyncio.run(handler.cancel_task(parsed.task_id))
|
||||
|
||||
asyncio.run(handler.cancel_task(args[1]))
|
||||
elif parsed.subcommand == "clear":
|
||||
asyncio.run(handler.clear_completed())
|
||||
|
||||
elif parsed.subcommand == "submit":
|
||||
# Parse input and config
|
||||
input_data = None
|
||||
if parsed.input_data:
|
||||
try:
|
||||
input_data = json.loads(parsed.input_data)
|
||||
except json.JSONDecodeError:
|
||||
input_data = {"input": parsed.input_data}
|
||||
|
||||
config = None
|
||||
if parsed.config:
|
||||
try:
|
||||
config = json.loads(parsed.config)
|
||||
except json.JSONDecodeError:
|
||||
print("Error: --config must be valid JSON")
|
||||
return
|
||||
|
||||
# Submit recipe as background task
|
||||
from praisonai.recipe.operations import run_background
|
||||
|
||||
task = run_background(
|
||||
parsed.recipe_name,
|
||||
input=input_data,
|
||||
config=config,
|
||||
session_id=parsed.session_id,
|
||||
timeout_sec=parsed.timeout,
|
||||
)
|
||||
|
||||
if parsed.output_json:
|
||||
print(json.dumps({
|
||||
"ok": True,
|
||||
"task_id": task.task_id,
|
||||
"recipe": task.recipe_name,
|
||||
"session_id": task.session_id,
|
||||
}))
|
||||
else:
|
||||
print(f"✓ Recipe '{parsed.recipe_name}' submitted to background")
|
||||
print(f" Task ID: {task.task_id}")
|
||||
print(f" Session: {task.session_id}")
|
||||
print(f"\nCheck status with: praisonai background status {task.task_id}")
|
||||
|
||||
elif command == "clear":
|
||||
asyncio.run(handler.clear_completed())
|
||||
|
||||
elif command == "help" or command == "--help":
|
||||
print("Background CLI Commands:")
|
||||
print("\n praisonai background list [--status <status>]")
|
||||
print(" List all background tasks")
|
||||
print(" Status: pending, running, completed, failed, cancelled")
|
||||
print("\n praisonai background status <task_id>")
|
||||
print(" Get detailed status of a specific task")
|
||||
print("\n praisonai background cancel <task_id>")
|
||||
print(" Cancel a running task")
|
||||
print("\n praisonai background clear")
|
||||
print(" Clear all completed tasks from the list")
|
||||
|
||||
else:
|
||||
print(f"Unknown command: {command}")
|
||||
print("Use 'praisonai background help' for available commands")
|
||||
except Exception as e:
|
||||
if getattr(parsed, 'output_json', False):
|
||||
print(json.dumps({"ok": False, "error": str(e)}))
|
||||
else:
|
||||
print(f"✗ Error: {e}")
|
||||
@@ -53,11 +53,14 @@ class JobsHandler:
|
||||
self,
|
||||
prompt: str,
|
||||
agent_file: Optional[str] = None,
|
||||
recipe_name: Optional[str] = None,
|
||||
recipe_config: Optional[dict] = None,
|
||||
framework: str = "praisonai",
|
||||
timeout: int = 3600,
|
||||
wait: bool = False,
|
||||
poll_interval: int = 5,
|
||||
idempotency_key: Optional[str] = None,
|
||||
idempotency_scope: str = "none",
|
||||
webhook_url: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
output_json: bool = False
|
||||
@@ -68,14 +71,25 @@ class JobsHandler:
|
||||
Args:
|
||||
prompt: The prompt/task for the agent
|
||||
agent_file: Optional path to agents.yaml
|
||||
recipe_name: Optional recipe name (mutually exclusive with agent_file)
|
||||
recipe_config: Optional recipe configuration overrides
|
||||
framework: Framework to use
|
||||
timeout: Job timeout in seconds
|
||||
wait: If True, wait for completion
|
||||
poll_interval: Seconds between status polls
|
||||
idempotency_key: Key for deduplication
|
||||
idempotency_scope: Scope for deduplication (none, session, global)
|
||||
webhook_url: URL for completion webhook
|
||||
session_id: Session ID for conversation continuity
|
||||
output_json: If True, output JSON format
|
||||
|
||||
Returns:
|
||||
Job submission response
|
||||
"""
|
||||
# Validate mutually exclusive options
|
||||
if agent_file and recipe_name:
|
||||
raise ValueError("Cannot specify both --agent-file and --recipe. Choose one.")
|
||||
|
||||
client = self._get_client()
|
||||
|
||||
payload = {
|
||||
@@ -86,12 +100,18 @@ class JobsHandler:
|
||||
|
||||
if agent_file:
|
||||
payload["agent_file"] = agent_file
|
||||
if recipe_name:
|
||||
payload["recipe_name"] = recipe_name
|
||||
if recipe_config:
|
||||
payload["recipe_config"] = recipe_config
|
||||
if webhook_url:
|
||||
payload["webhook_url"] = webhook_url
|
||||
if session_id:
|
||||
payload["session_id"] = session_id
|
||||
if idempotency_key:
|
||||
payload["idempotency_key"] = idempotency_key
|
||||
if idempotency_scope and idempotency_scope != "none":
|
||||
payload["idempotency_scope"] = idempotency_scope
|
||||
|
||||
headers = {}
|
||||
if idempotency_key:
|
||||
@@ -494,12 +514,17 @@ def handle_run_command(args: List[str], verbose: bool = False):
|
||||
submit_parser = subparsers.add_parser("submit", help="Submit a new job")
|
||||
submit_parser.add_argument("prompt", help="The prompt/task for the agent")
|
||||
submit_parser.add_argument("--agent-file", help="Path to agents.yaml")
|
||||
submit_parser.add_argument("--recipe", dest="recipe_name", help="Recipe name to execute (mutually exclusive with --agent-file)")
|
||||
submit_parser.add_argument("--recipe-config", help="Recipe config as JSON string")
|
||||
submit_parser.add_argument("--framework", default="praisonai", help="Framework to use")
|
||||
submit_parser.add_argument("--timeout", type=int, default=3600, help="Timeout in seconds")
|
||||
submit_parser.add_argument("--wait", action="store_true", help="Wait for completion")
|
||||
submit_parser.add_argument("--stream", action="store_true", help="Stream job progress after submission")
|
||||
submit_parser.add_argument("--idempotency-key", help="Idempotency key to prevent duplicates")
|
||||
submit_parser.add_argument("--idempotency-scope", default="none", choices=["none", "session", "global"], help="Idempotency scope")
|
||||
submit_parser.add_argument("--webhook-url", help="Webhook URL for completion callback")
|
||||
submit_parser.add_argument("--session-id", help="Session ID for grouping jobs")
|
||||
submit_parser.add_argument("--metadata", action="append", metavar="KEY=VALUE", help="Custom metadata (can be used multiple times)")
|
||||
submit_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON for scripting")
|
||||
submit_parser.add_argument("--api-url", default="http://127.0.0.1:8005", help="API server URL")
|
||||
|
||||
@@ -552,13 +577,25 @@ def handle_run_command(args: List[str], verbose: bool = False):
|
||||
|
||||
try:
|
||||
if parsed.subcommand == "submit":
|
||||
# Parse recipe config if provided
|
||||
recipe_config = None
|
||||
if getattr(parsed, "recipe_config", None):
|
||||
try:
|
||||
recipe_config = json.loads(parsed.recipe_config)
|
||||
except json.JSONDecodeError:
|
||||
print("Error: --recipe-config must be valid JSON")
|
||||
sys.exit(1)
|
||||
|
||||
handler.submit(
|
||||
prompt=parsed.prompt,
|
||||
agent_file=parsed.agent_file,
|
||||
recipe_name=getattr(parsed, "recipe_name", None),
|
||||
recipe_config=recipe_config,
|
||||
framework=parsed.framework,
|
||||
timeout=parsed.timeout,
|
||||
wait=parsed.wait,
|
||||
idempotency_key=getattr(parsed, "idempotency_key", None),
|
||||
idempotency_scope=getattr(parsed, "idempotency_scope", "none"),
|
||||
webhook_url=getattr(parsed, "webhook_url", None),
|
||||
session_id=getattr(parsed, "session_id", None),
|
||||
output_json=getattr(parsed, "output_json", False)
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
"""
|
||||
Package CLI Feature Handler
|
||||
|
||||
Provides pip-like CLI commands for package management:
|
||||
- install: Install Python packages
|
||||
- uninstall: Uninstall Python packages
|
||||
- list: List installed packages
|
||||
- search: Search packages
|
||||
- index: Manage package indexes
|
||||
|
||||
All commands use the canonical `praisonai` or `praisonai package` prefix.
|
||||
|
||||
Security:
|
||||
- Default to single authoritative index (PyPI)
|
||||
- Warn loudly about dependency confusion when using extra indexes
|
||||
- Require explicit opt-in for extra index fallback
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
|
||||
# Default configuration
|
||||
DEFAULT_INDEX_URL = "https://pypi.org/simple"
|
||||
CONFIG_DIR = Path.home() / ".praison"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
||||
|
||||
|
||||
class PackageConfig:
|
||||
"""Package configuration manager."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize config."""
|
||||
self._config = None
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
"""Load configuration from file."""
|
||||
if self._config is not None:
|
||||
return self._config
|
||||
|
||||
self._config = {
|
||||
"package": {
|
||||
"index_url": DEFAULT_INDEX_URL,
|
||||
"extra_index_urls": [],
|
||||
"allow_extra_index": False,
|
||||
"trusted_hosts": [],
|
||||
}
|
||||
}
|
||||
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError:
|
||||
try:
|
||||
import tomli as tomllib
|
||||
except ImportError:
|
||||
return self._config
|
||||
|
||||
try:
|
||||
with open(CONFIG_FILE, "rb") as f:
|
||||
file_config = tomllib.load(f)
|
||||
if "package" in file_config:
|
||||
self._config["package"].update(file_config["package"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Environment variable overrides
|
||||
if os.environ.get("PRAISONAI_PACKAGE_INDEX_URL"):
|
||||
self._config["package"]["index_url"] = os.environ["PRAISONAI_PACKAGE_INDEX_URL"]
|
||||
|
||||
if os.environ.get("PRAISONAI_PACKAGE_EXTRA_INDEX_URLS"):
|
||||
urls = os.environ["PRAISONAI_PACKAGE_EXTRA_INDEX_URLS"].split(",")
|
||||
self._config["package"]["extra_index_urls"] = [u.strip() for u in urls if u.strip()]
|
||||
|
||||
return self._config
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get config value."""
|
||||
config = self._load_config()
|
||||
parts = key.split(".")
|
||||
value = config
|
||||
for part in parts:
|
||||
if isinstance(value, dict) and part in value:
|
||||
value = value[part]
|
||||
else:
|
||||
return default
|
||||
return value
|
||||
|
||||
def save_index(self, index_url: str) -> None:
|
||||
"""Save index URL to config."""
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config = {}
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError:
|
||||
try:
|
||||
import tomli as tomllib
|
||||
except ImportError:
|
||||
# Fall back to simple write
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
f.write(f'[package]\nindex_url = "{index_url}"\n')
|
||||
return
|
||||
|
||||
try:
|
||||
with open(CONFIG_FILE, "rb") as f:
|
||||
config = tomllib.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if "package" not in config:
|
||||
config["package"] = {}
|
||||
config["package"]["index_url"] = index_url
|
||||
|
||||
# Write config (simple TOML format)
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
for section, values in config.items():
|
||||
f.write(f"[{section}]\n")
|
||||
for key, value in values.items():
|
||||
if isinstance(value, str):
|
||||
f.write(f'{key} = "{value}"\n')
|
||||
elif isinstance(value, bool):
|
||||
f.write(f'{key} = {"true" if value else "false"}\n')
|
||||
elif isinstance(value, list):
|
||||
f.write(f'{key} = {json.dumps(value)}\n')
|
||||
else:
|
||||
f.write(f'{key} = {value}\n')
|
||||
f.write("\n")
|
||||
|
||||
|
||||
class PackageHandler:
|
||||
"""
|
||||
CLI handler for package operations.
|
||||
|
||||
Commands:
|
||||
- install: Install Python packages
|
||||
- uninstall: Uninstall Python packages
|
||||
- list: List installed packages
|
||||
- search: Search packages (best-effort)
|
||||
- index: Manage package indexes
|
||||
"""
|
||||
|
||||
# Stable exit codes
|
||||
EXIT_SUCCESS = 0
|
||||
EXIT_GENERAL_ERROR = 1
|
||||
EXIT_VALIDATION_ERROR = 2
|
||||
EXIT_NETWORK_ERROR = 10
|
||||
EXIT_DEPENDENCY_ERROR = 11
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the handler."""
|
||||
self.config = PackageConfig()
|
||||
|
||||
def handle(self, args: List[str]) -> int:
|
||||
"""
|
||||
Handle package 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,
|
||||
"search": self.cmd_search,
|
||||
"index": self.cmd_index,
|
||||
"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,
|
||||
}
|
||||
|
||||
handler = commands.get(command)
|
||||
if handler:
|
||||
return handler(remaining)
|
||||
|
||||
self._print_error(f"Unknown command: {command}")
|
||||
self._print_help()
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
def _print_help(self):
|
||||
"""Print help message."""
|
||||
print("""
|
||||
PraisonAI Package Commands
|
||||
|
||||
Usage: praisonai package <command> [options]
|
||||
praisonai install <spec...>
|
||||
praisonai uninstall <pkg...>
|
||||
|
||||
Commands:
|
||||
install Install Python packages (shortcut: praisonai install)
|
||||
uninstall Uninstall Python packages (shortcut: praisonai uninstall)
|
||||
list List installed packages
|
||||
search Search for packages
|
||||
index Manage package indexes
|
||||
|
||||
Examples:
|
||||
praisonai install requests
|
||||
praisonai install "requests>=2.28" httpx
|
||||
praisonai uninstall requests
|
||||
praisonai package list
|
||||
praisonai package search langchain
|
||||
praisonai package index show
|
||||
praisonai package index set https://my-index.example.com/simple
|
||||
|
||||
Options for 'install':
|
||||
--index-url URL Use custom index URL
|
||||
--extra-index-url URL Add extra index (requires --allow-extra-index)
|
||||
--allow-extra-index Allow extra index URLs (security risk!)
|
||||
--python PATH Python interpreter to use
|
||||
--upgrade, -U Upgrade packages
|
||||
--no-deps Don't install dependencies
|
||||
--json Output in JSON format
|
||||
|
||||
Options for 'uninstall':
|
||||
--python PATH Python interpreter to use
|
||||
--yes, -y Don't ask for confirmation
|
||||
--json Output in JSON format
|
||||
|
||||
Security Notes:
|
||||
- By default, only the primary index is used (PyPI)
|
||||
- Using --extra-index-url can lead to dependency confusion attacks
|
||||
- Always prefer --index-url over --extra-index-url when possible
|
||||
""")
|
||||
|
||||
def _print_error(self, message: str):
|
||||
"""Print error message."""
|
||||
print(f"Error: {message}", file=sys.stderr)
|
||||
|
||||
def _print_warning(self, message: str):
|
||||
"""Print warning message."""
|
||||
print(f"Warning: {message}", file=sys.stderr)
|
||||
|
||||
def _print_success(self, message: str):
|
||||
"""Print success message."""
|
||||
print(f"✓ {message}")
|
||||
|
||||
def _print_json(self, data: Any):
|
||||
"""Print JSON output."""
|
||||
print(json.dumps(data, indent=2))
|
||||
|
||||
def _parse_args(self, args: List[str], spec: Dict[str, Any]) -> Tuple[Dict[str, Any], List[str]]:
|
||||
"""Parse command arguments, returning parsed args and positional args."""
|
||||
result = {k: v.get("default") for k, v in spec.items()}
|
||||
positional = []
|
||||
|
||||
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 i + 1 < len(args):
|
||||
result[key] = args[i + 1]
|
||||
i += 1
|
||||
else:
|
||||
positional.append(arg)
|
||||
elif arg.startswith("-") and len(arg) == 2:
|
||||
# Short flag
|
||||
found = False
|
||||
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
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
positional.append(arg)
|
||||
else:
|
||||
positional.append(arg)
|
||||
i += 1
|
||||
|
||||
return result, positional
|
||||
|
||||
def _get_python(self, python_path: str = None) -> str:
|
||||
"""Get Python interpreter path."""
|
||||
if python_path:
|
||||
return python_path
|
||||
return sys.executable
|
||||
|
||||
def _run_pip(
|
||||
self,
|
||||
args: List[str],
|
||||
python: str = None,
|
||||
capture: bool = False,
|
||||
) -> Tuple[int, str, str]:
|
||||
"""
|
||||
Run pip command.
|
||||
|
||||
Args:
|
||||
args: pip arguments
|
||||
python: Python interpreter path
|
||||
capture: Capture output instead of streaming
|
||||
|
||||
Returns:
|
||||
Tuple of (return_code, stdout, stderr)
|
||||
"""
|
||||
python = self._get_python(python)
|
||||
cmd = [python, "-m", "pip"] + args
|
||||
|
||||
if capture:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "PAGER": "cat"},
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
else:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
env={**os.environ, "PAGER": "cat"},
|
||||
)
|
||||
return result.returncode, "", ""
|
||||
|
||||
def cmd_install(self, args: List[str]) -> int:
|
||||
"""Install Python packages."""
|
||||
spec = {
|
||||
"index_url": {"default": None},
|
||||
"extra_index_url": {"default": None},
|
||||
"allow_extra_index": {"flag": True, "default": False},
|
||||
"python": {"default": None},
|
||||
"upgrade": {"flag": True, "short": "-U", "default": False},
|
||||
"no_deps": {"flag": True, "default": False},
|
||||
"json": {"flag": True, "default": False},
|
||||
}
|
||||
parsed, packages = self._parse_args(args, spec)
|
||||
|
||||
if not packages:
|
||||
self._print_error("No packages specified")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
# Build pip command
|
||||
pip_args = ["install"]
|
||||
|
||||
# Index URL
|
||||
index_url = parsed["index_url"] or self.config.get("package.index_url", DEFAULT_INDEX_URL)
|
||||
pip_args.extend(["--index-url", index_url])
|
||||
|
||||
# Extra index URL (with security warning)
|
||||
extra_index = parsed["extra_index_url"]
|
||||
if extra_index:
|
||||
allow_extra = parsed["allow_extra_index"] or self.config.get("package.allow_extra_index", False)
|
||||
if not allow_extra:
|
||||
self._print_error(
|
||||
"Extra index URLs are disabled by default for security.\n"
|
||||
"Using multiple indexes can lead to dependency confusion attacks.\n"
|
||||
"To enable, use --allow-extra-index flag or set package.allow_extra_index=true in config."
|
||||
)
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
self._print_warning(
|
||||
"⚠️ SECURITY WARNING: Using extra index URLs can lead to dependency confusion attacks!\n"
|
||||
" An attacker could publish a malicious package with the same name on PyPI.\n"
|
||||
" Only use this if you trust both indexes and understand the risks."
|
||||
)
|
||||
pip_args.extend(["--extra-index-url", extra_index])
|
||||
|
||||
# Other options
|
||||
if parsed["upgrade"]:
|
||||
pip_args.append("--upgrade")
|
||||
if parsed["no_deps"]:
|
||||
pip_args.append("--no-deps")
|
||||
|
||||
# Add packages
|
||||
pip_args.extend(packages)
|
||||
|
||||
if parsed["json"]:
|
||||
returncode, stdout, stderr = self._run_pip(pip_args, parsed["python"], capture=True)
|
||||
self._print_json({
|
||||
"ok": returncode == 0,
|
||||
"packages": packages,
|
||||
"returncode": returncode,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
})
|
||||
return returncode
|
||||
else:
|
||||
print(f"Installing: {', '.join(packages)}")
|
||||
returncode, _, _ = self._run_pip(pip_args, parsed["python"])
|
||||
if returncode == 0:
|
||||
self._print_success("Installation complete")
|
||||
return returncode
|
||||
|
||||
def cmd_uninstall(self, args: List[str]) -> int:
|
||||
"""Uninstall Python packages."""
|
||||
spec = {
|
||||
"python": {"default": None},
|
||||
"yes": {"flag": True, "short": "-y", "default": False},
|
||||
"json": {"flag": True, "default": False},
|
||||
}
|
||||
parsed, packages = self._parse_args(args, spec)
|
||||
|
||||
if not packages:
|
||||
self._print_error("No packages specified")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
pip_args = ["uninstall"]
|
||||
if parsed["yes"]:
|
||||
pip_args.append("-y")
|
||||
pip_args.extend(packages)
|
||||
|
||||
if parsed["json"]:
|
||||
returncode, stdout, stderr = self._run_pip(pip_args, parsed["python"], capture=True)
|
||||
self._print_json({
|
||||
"ok": returncode == 0,
|
||||
"packages": packages,
|
||||
"returncode": returncode,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
})
|
||||
return returncode
|
||||
else:
|
||||
print(f"Uninstalling: {', '.join(packages)}")
|
||||
returncode, _, _ = self._run_pip(pip_args, parsed["python"])
|
||||
if returncode == 0:
|
||||
self._print_success("Uninstall complete")
|
||||
return returncode
|
||||
|
||||
def cmd_list(self, args: List[str]) -> int:
|
||||
"""List installed packages."""
|
||||
spec = {
|
||||
"python": {"default": None},
|
||||
"json": {"flag": True, "default": False},
|
||||
"outdated": {"flag": True, "default": False},
|
||||
}
|
||||
parsed, _ = self._parse_args(args, spec)
|
||||
|
||||
pip_args = ["list", "--format=json"]
|
||||
if parsed["outdated"]:
|
||||
pip_args.append("--outdated")
|
||||
|
||||
returncode, stdout, stderr = self._run_pip(pip_args, parsed["python"], capture=True)
|
||||
|
||||
if returncode != 0:
|
||||
self._print_error(stderr or "Failed to list packages")
|
||||
return returncode
|
||||
|
||||
try:
|
||||
packages = json.loads(stdout)
|
||||
except json.JSONDecodeError:
|
||||
packages = []
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json({
|
||||
"ok": True,
|
||||
"packages": packages,
|
||||
"count": len(packages),
|
||||
})
|
||||
else:
|
||||
if not packages:
|
||||
print("No packages installed.")
|
||||
else:
|
||||
print(f"{'Package':<30} {'Version':<15}")
|
||||
print("-" * 45)
|
||||
for pkg in packages:
|
||||
name = pkg.get("name", "")
|
||||
version = pkg.get("version", "")
|
||||
print(f"{name:<30} {version:<15}")
|
||||
print(f"\nTotal: {len(packages)} packages")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def cmd_search(self, args: List[str]) -> int:
|
||||
"""Search for packages (best-effort, uses pip search or PyPI API)."""
|
||||
spec = {
|
||||
"json": {"flag": True, "default": False},
|
||||
}
|
||||
parsed, query_parts = self._parse_args(args, spec)
|
||||
|
||||
if not query_parts:
|
||||
self._print_error("Search query required")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
query = " ".join(query_parts)
|
||||
|
||||
# pip search is deprecated, use PyPI JSON API
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = f"https://pypi.org/pypi/{query}/json"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
|
||||
info = data.get("info", {})
|
||||
result = {
|
||||
"name": info.get("name"),
|
||||
"version": info.get("version"),
|
||||
"summary": info.get("summary"),
|
||||
"author": info.get("author"),
|
||||
"home_page": info.get("home_page"),
|
||||
}
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json({"ok": True, "results": [result]})
|
||||
else:
|
||||
print(f"Found: {result['name']} ({result['version']})")
|
||||
print(f" {result['summary']}")
|
||||
if result['home_page']:
|
||||
print(f" Homepage: {result['home_page']}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
if parsed["json"]:
|
||||
self._print_json({"ok": True, "results": [], "message": "No exact match found"})
|
||||
else:
|
||||
print(f"No exact match for '{query}'")
|
||||
print("Try: pip search <query> (if available) or browse https://pypi.org")
|
||||
return self.EXIT_SUCCESS
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
if parsed["json"]:
|
||||
self._print_json({"ok": False, "error": str(e)})
|
||||
else:
|
||||
self._print_error(f"Search failed: {e}")
|
||||
return self.EXIT_NETWORK_ERROR
|
||||
|
||||
def cmd_index(self, args: List[str]) -> int:
|
||||
"""Manage package indexes."""
|
||||
if not args:
|
||||
return self._index_show([])
|
||||
|
||||
subcommand = args[0]
|
||||
remaining = args[1:]
|
||||
|
||||
subcommands = {
|
||||
"show": self._index_show,
|
||||
"set": self._index_set,
|
||||
"add": self._index_add,
|
||||
"remove": self._index_remove,
|
||||
}
|
||||
|
||||
handler = subcommands.get(subcommand)
|
||||
if handler:
|
||||
return handler(remaining)
|
||||
|
||||
self._print_error(f"Unknown index subcommand: {subcommand}")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
def _index_show(self, args: List[str]) -> int:
|
||||
"""Show current index configuration."""
|
||||
spec = {
|
||||
"json": {"flag": True, "default": False},
|
||||
}
|
||||
parsed, _ = self._parse_args(args, spec)
|
||||
|
||||
index_url = self.config.get("package.index_url", DEFAULT_INDEX_URL)
|
||||
extra_urls = self.config.get("package.extra_index_urls", [])
|
||||
allow_extra = self.config.get("package.allow_extra_index", False)
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json({
|
||||
"ok": True,
|
||||
"index_url": index_url,
|
||||
"extra_index_urls": extra_urls,
|
||||
"allow_extra_index": allow_extra,
|
||||
})
|
||||
else:
|
||||
print(f"Primary Index: {index_url}")
|
||||
if extra_urls:
|
||||
print(f"Extra Indexes: {', '.join(extra_urls)}")
|
||||
print(f"Allow Extra Index: {'yes' if allow_extra else 'no'}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _index_set(self, args: List[str]) -> int:
|
||||
"""Set primary index URL."""
|
||||
if not args:
|
||||
self._print_error("Index URL required")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
url = args[0]
|
||||
|
||||
# Validate URL format
|
||||
if not url.startswith(("http://", "https://")):
|
||||
self._print_error("Index URL must start with http:// or https://")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
self.config.save_index(url)
|
||||
self._print_success(f"Primary index set to: {url}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _index_add(self, args: List[str]) -> int:
|
||||
"""Add extra index URL."""
|
||||
self._print_warning(
|
||||
"Adding extra index URLs is a security risk!\n"
|
||||
"This can lead to dependency confusion attacks.\n"
|
||||
"Consider using --index-url to switch indexes instead."
|
||||
)
|
||||
|
||||
if not args:
|
||||
self._print_error("Index URL required")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
# For now, just print instructions
|
||||
print("\nTo use extra index, run:")
|
||||
print(f" praisonai install <pkg> --extra-index-url {args[0]} --allow-extra-index")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def _index_remove(self, args: List[str]) -> int:
|
||||
"""Remove extra index URL."""
|
||||
print("Extra index URLs are not persisted by default.")
|
||||
print("To reset to PyPI, run:")
|
||||
print(f" praisonai package index set {DEFAULT_INDEX_URL}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
@@ -255,12 +255,62 @@ class RecipeHandler:
|
||||
spec = {
|
||||
"source": {"default": None},
|
||||
"tags": {"default": None},
|
||||
"registry": {"default": None},
|
||||
"token": {"default": None},
|
||||
"json": {"flag": True, "default": False},
|
||||
"offline": {"flag": True, "default": False},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
try:
|
||||
# If registry URL provided, list from that registry
|
||||
if parsed["registry"]:
|
||||
from praisonai.recipe.registry import get_registry
|
||||
import os
|
||||
registry = get_registry(
|
||||
registry=parsed["registry"],
|
||||
token=parsed["token"] or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
|
||||
)
|
||||
result = registry.list_recipes(
|
||||
tags=parsed["tags"].split(",") if parsed["tags"] else None
|
||||
)
|
||||
recipes = result.get("recipes", []) if isinstance(result, dict) else result
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json(recipes)
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
if not recipes:
|
||||
print("No recipes found in registry.")
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
table = Table(title=f"Recipes from {parsed['registry']}")
|
||||
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:
|
||||
table.add_row(
|
||||
r.get("name", ""),
|
||||
r.get("version", ""),
|
||||
(r.get("description", "")[:50] + "...") if len(r.get("description", "")) > 50 else r.get("description", ""),
|
||||
", ".join(r.get("tags", [])[:3]),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
except ImportError:
|
||||
for r in recipes:
|
||||
print(f"{r.get('name')} ({r.get('version')}): {r.get('description', '')}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
# Default: list local recipes
|
||||
recipes = self.recipe.list_recipes(
|
||||
source_filter=parsed["source"],
|
||||
tags=parsed["tags"].split(",") if parsed["tags"] else None,
|
||||
@@ -309,6 +359,8 @@ class RecipeHandler:
|
||||
"""Search recipes by query."""
|
||||
spec = {
|
||||
"query": {"positional": True, "default": ""},
|
||||
"registry": {"default": None},
|
||||
"token": {"default": None},
|
||||
"json": {"flag": True, "default": False},
|
||||
"offline": {"flag": True, "default": False},
|
||||
}
|
||||
@@ -319,6 +371,32 @@ class RecipeHandler:
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
try:
|
||||
# If registry URL provided, search that registry
|
||||
if parsed["registry"]:
|
||||
from praisonai.recipe.registry import get_registry
|
||||
import os
|
||||
registry = get_registry(
|
||||
registry=parsed["registry"],
|
||||
token=parsed["token"] or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
|
||||
)
|
||||
result = registry.search(parsed["query"])
|
||||
matches = result.get("results", []) if isinstance(result, dict) else result
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json(matches)
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
if not matches:
|
||||
print(f"No recipes found matching '{parsed['query']}' in registry")
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
print(f"Found {len(matches)} recipe(s) matching '{parsed['query']}':")
|
||||
for r in matches:
|
||||
print(f" {r.get('name')}: {r.get('description', '')}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
# Default: search local recipes
|
||||
recipes = self.recipe.list_recipes(offline=parsed["offline"])
|
||||
query = parsed["query"].lower()
|
||||
|
||||
@@ -473,6 +551,7 @@ class RecipeHandler:
|
||||
"session": {"short": "-s", "default": None},
|
||||
"json": {"flag": True, "default": False},
|
||||
"stream": {"flag": True, "default": False},
|
||||
"background": {"flag": True, "default": False},
|
||||
"dry_run": {"flag": True, "default": False},
|
||||
"explain": {"flag": True, "default": False},
|
||||
"verbose": {"short": "-v", "flag": True, "default": False},
|
||||
@@ -531,6 +610,13 @@ class RecipeHandler:
|
||||
}
|
||||
|
||||
try:
|
||||
# Background execution mode
|
||||
if parsed["background"]:
|
||||
return self._run_background(
|
||||
parsed["recipe"], input_data, config,
|
||||
parsed["session"], options, parsed["json"]
|
||||
)
|
||||
|
||||
if parsed["stream"]:
|
||||
return self._run_stream(
|
||||
parsed["recipe"], input_data, config,
|
||||
@@ -578,6 +664,50 @@ class RecipeHandler:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_RUNTIME_ERROR
|
||||
|
||||
def _run_background(
|
||||
self,
|
||||
recipe_name: str,
|
||||
input_data: Dict[str, Any],
|
||||
config: Dict[str, Any],
|
||||
session_id: Optional[str],
|
||||
options: Dict[str, Any],
|
||||
json_output: bool,
|
||||
) -> int:
|
||||
"""Run recipe as a background task."""
|
||||
try:
|
||||
from praisonai.recipe.operations import run_background
|
||||
|
||||
task = run_background(
|
||||
recipe_name,
|
||||
input=input_data or None,
|
||||
config=config or None,
|
||||
session_id=session_id,
|
||||
timeout_sec=options.get('timeout_sec', 300),
|
||||
)
|
||||
|
||||
if json_output:
|
||||
self._print_json({
|
||||
"ok": True,
|
||||
"task_id": task.task_id,
|
||||
"recipe": task.recipe_name,
|
||||
"session_id": task.session_id,
|
||||
"message": "Task submitted to background"
|
||||
})
|
||||
else:
|
||||
self._print_success(f"Recipe '{recipe_name}' submitted to background")
|
||||
print(f" Task ID: {task.task_id}")
|
||||
print(f" Session: {task.session_id}")
|
||||
print(f"\nCheck status with: praisonai background status {task.task_id}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
except Exception as e:
|
||||
if json_output:
|
||||
self._print_json({"ok": False, "error": str(e)})
|
||||
else:
|
||||
self._print_error(f"Failed to submit background task: {e}")
|
||||
return self.EXIT_RUNTIME_ERROR
|
||||
|
||||
def _run_stream(
|
||||
self,
|
||||
recipe_name: str,
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
Registry CLI Feature Handler
|
||||
|
||||
Provides CLI commands for recipe registry management:
|
||||
- serve: Start local HTTP registry server
|
||||
- status: Check registry status
|
||||
|
||||
All commands use the canonical `praisonai registry` prefix.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class RegistryHandler:
|
||||
"""
|
||||
CLI handler for registry operations.
|
||||
|
||||
Commands:
|
||||
- serve: Start local HTTP registry server
|
||||
- status: Check registry server status
|
||||
"""
|
||||
|
||||
# Stable exit codes
|
||||
EXIT_SUCCESS = 0
|
||||
EXIT_GENERAL_ERROR = 1
|
||||
EXIT_VALIDATION_ERROR = 2
|
||||
EXIT_NETWORK_ERROR = 10
|
||||
EXIT_AUTH_ERROR = 9
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the handler."""
|
||||
pass
|
||||
|
||||
def handle(self, args: List[str]) -> int:
|
||||
"""
|
||||
Handle registry 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 = {
|
||||
"serve": self.cmd_serve,
|
||||
"status": self.cmd_status,
|
||||
"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,
|
||||
}
|
||||
|
||||
handler = commands.get(command)
|
||||
if handler:
|
||||
return handler(remaining)
|
||||
|
||||
self._print_error(f"Unknown command: {command}")
|
||||
self._print_help()
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
def _print_help(self):
|
||||
"""Print help message."""
|
||||
print("""
|
||||
PraisonAI Registry Commands
|
||||
|
||||
Usage: praisonai registry <command> [options]
|
||||
|
||||
Commands:
|
||||
serve Start local HTTP registry server
|
||||
status Check registry server status
|
||||
|
||||
Examples:
|
||||
praisonai registry serve
|
||||
praisonai registry serve --port 7777 --token mysecret
|
||||
praisonai registry serve --read-only
|
||||
praisonai registry status --registry http://localhost:7777
|
||||
|
||||
Options for 'serve':
|
||||
--host HOST Host to bind to (default: 127.0.0.1)
|
||||
--port PORT Port to bind to (default: 7777)
|
||||
--dir PATH Registry directory (default: ~/.praison/registry)
|
||||
--token TOKEN Require token for write operations
|
||||
--read-only Disable all write operations
|
||||
--json Output in JSON format
|
||||
|
||||
Options for 'status':
|
||||
--registry URL Registry URL to check (default: http://localhost:7777)
|
||||
--json Output in JSON format
|
||||
""")
|
||||
|
||||
def _print_error(self, message: str):
|
||||
"""Print error message."""
|
||||
print(f"Error: {message}", file=sys.stderr)
|
||||
|
||||
def _print_success(self, message: str):
|
||||
"""Print success message."""
|
||||
print(f"✓ {message}")
|
||||
|
||||
def _print_json(self, data: Any):
|
||||
"""Print JSON output."""
|
||||
import json
|
||||
print(json.dumps(data, indent=2))
|
||||
|
||||
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()}
|
||||
|
||||
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 i + 1 < len(args):
|
||||
result[key] = args[i + 1]
|
||||
i += 1
|
||||
elif arg.startswith("-") and len(arg) == 2:
|
||||
# Short flag
|
||||
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
|
||||
|
||||
return result
|
||||
|
||||
def cmd_serve(self, args: List[str]) -> int:
|
||||
"""Start local HTTP registry server."""
|
||||
spec = {
|
||||
"host": {"default": "127.0.0.1"},
|
||||
"port": {"default": "7777"},
|
||||
"dir": {"default": None},
|
||||
"token": {"default": None},
|
||||
"read_only": {"flag": True, "default": False},
|
||||
"json": {"flag": True, "default": False},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
try:
|
||||
from praisonai.recipe.server import run_server
|
||||
from praisonai.recipe.registry import DEFAULT_REGISTRY_PATH
|
||||
|
||||
host = parsed["host"]
|
||||
port = int(parsed["port"])
|
||||
registry_path = Path(parsed["dir"]) if parsed["dir"] else DEFAULT_REGISTRY_PATH
|
||||
token = parsed["token"] or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
|
||||
read_only = parsed["read_only"]
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json({
|
||||
"ok": True,
|
||||
"message": "Starting registry server",
|
||||
"host": host,
|
||||
"port": port,
|
||||
"registry_path": str(registry_path),
|
||||
"read_only": read_only,
|
||||
"auth_required": bool(token),
|
||||
})
|
||||
|
||||
run_server(
|
||||
host=host,
|
||||
port=port,
|
||||
registry_path=registry_path,
|
||||
token=token,
|
||||
read_only=read_only,
|
||||
)
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer stopped.")
|
||||
return self.EXIT_SUCCESS
|
||||
except Exception as e:
|
||||
self._print_error(str(e))
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
|
||||
def cmd_status(self, args: List[str]) -> int:
|
||||
"""Check registry server status."""
|
||||
spec = {
|
||||
"registry": {"default": "http://localhost:7777"},
|
||||
"json": {"flag": True, "default": False},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
try:
|
||||
from praisonai.recipe.registry import HttpRegistry
|
||||
|
||||
registry = HttpRegistry(parsed["registry"])
|
||||
health = registry.health()
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json(health)
|
||||
else:
|
||||
status = "healthy" if health.get("ok") else "unhealthy"
|
||||
print(f"Registry: {parsed['registry']}")
|
||||
print(f"Status: {status}")
|
||||
if health.get("read_only"):
|
||||
print("Mode: read-only")
|
||||
if health.get("auth_required"):
|
||||
print("Auth: required for writes")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
except Exception as e:
|
||||
if parsed["json"]:
|
||||
self._print_json({
|
||||
"ok": False,
|
||||
"error": str(e),
|
||||
"registry": parsed["registry"],
|
||||
})
|
||||
else:
|
||||
self._print_error(f"Cannot connect to registry: {e}")
|
||||
return self.EXIT_NETWORK_ERROR
|
||||
@@ -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', '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']
|
||||
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', 'registry', 'package', 'install', 'uninstall']
|
||||
|
||||
parser = argparse.ArgumentParser(prog="praisonai", description="praisonAI command-line interface")
|
||||
parser.add_argument("--framework", choices=["crewai", "autogen", "praisonai"], help="Specify the framework")
|
||||
@@ -1383,6 +1383,34 @@ class PraisonAI:
|
||||
handler = DoctorHandler()
|
||||
exit_code = handler.run(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'registry':
|
||||
# Registry command - manage recipe registry server
|
||||
from .features.registry import RegistryHandler
|
||||
handler = RegistryHandler()
|
||||
exit_code = handler.handle(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'package':
|
||||
# Package command - pip-like package management
|
||||
from .features.package import PackageHandler
|
||||
handler = PackageHandler()
|
||||
exit_code = handler.handle(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'install':
|
||||
# Install command - shortcut for package install
|
||||
from .features.package import PackageHandler
|
||||
handler = PackageHandler()
|
||||
exit_code = handler.cmd_install(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'uninstall':
|
||||
# Uninstall command - shortcut for package uninstall
|
||||
from .features.package import PackageHandler
|
||||
handler = PackageHandler()
|
||||
exit_code = handler.cmd_uninstall(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# Only check framework availability for agent-related operations
|
||||
if not args.command and (args.init or args.auto or args.framework):
|
||||
|
||||
@@ -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.5 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==2.7.6 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -203,7 +203,12 @@ class JobExecutor:
|
||||
Run the agent for a job.
|
||||
|
||||
This is the core execution logic that runs the PraisonAI agent.
|
||||
Supports both direct agent execution and recipe-based execution.
|
||||
"""
|
||||
# Check if this is a recipe job
|
||||
if job.recipe_name:
|
||||
return await self._run_recipe(job)
|
||||
|
||||
# Imports are done lazily in _run_praisonai_agents and _run_legacy_praisonai
|
||||
|
||||
# Determine agent configuration
|
||||
@@ -241,6 +246,50 @@ class JobExecutor:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _run_recipe(self, job: Job) -> Any:
|
||||
"""Run a recipe-based job."""
|
||||
try:
|
||||
from praisonai.recipe.bridge import resolve, execute_resolved_recipe
|
||||
except ImportError:
|
||||
raise RuntimeError("Recipe execution requires praisonai.recipe module")
|
||||
|
||||
# Update progress
|
||||
job.update_progress(percentage=10.0, step=f"Resolving recipe: {job.recipe_name}")
|
||||
await self.store.save(job)
|
||||
await self._notify_progress(job)
|
||||
|
||||
# Resolve the recipe
|
||||
resolved = resolve(
|
||||
job.recipe_name,
|
||||
input_data=job.prompt,
|
||||
config=job.recipe_config or {},
|
||||
session_id=job.session_id,
|
||||
options={'timeout_sec': job.timeout},
|
||||
)
|
||||
|
||||
# Update job with recipe info
|
||||
job.agent_id = f"recipe:{resolved.name}"
|
||||
job.run_id = resolved.run_id
|
||||
|
||||
# Update progress
|
||||
job.update_progress(percentage=20.0, step=f"Executing recipe: {resolved.name}")
|
||||
await self.store.save(job)
|
||||
await self._notify_progress(job)
|
||||
|
||||
# Execute the recipe
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: execute_resolved_recipe(resolved)
|
||||
)
|
||||
|
||||
# Update progress
|
||||
job.update_progress(percentage=90.0, step="Finalizing")
|
||||
await self.store.save(job)
|
||||
await self._notify_progress(job)
|
||||
|
||||
return result
|
||||
|
||||
async def _run_praisonai_agents(self, job: Job, agent_file: str) -> Any:
|
||||
"""Run using praisonaiagents framework."""
|
||||
try:
|
||||
|
||||
@@ -25,12 +25,15 @@ class JobSubmitRequest(BaseModel):
|
||||
prompt: str = Field(..., description="The prompt or task for the agent")
|
||||
agent_file: Optional[str] = Field(None, description="Path to agents.yaml file")
|
||||
agent_yaml: Optional[str] = Field(None, description="Inline agent YAML configuration")
|
||||
recipe_name: Optional[str] = Field(None, description="Recipe name to execute (mutually exclusive with agent_file)")
|
||||
recipe_config: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Recipe configuration overrides")
|
||||
framework: Optional[str] = Field("praisonai", description="Framework to use (praisonai, crewai, autogen)")
|
||||
config: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Additional configuration")
|
||||
webhook_url: Optional[str] = Field(None, description="URL to POST results when complete")
|
||||
timeout: Optional[int] = Field(3600, description="Timeout in seconds (default: 1 hour)")
|
||||
session_id: Optional[str] = Field(None, description="Session ID for conversation continuity")
|
||||
idempotency_key: Optional[str] = Field(None, description="Idempotency key to prevent duplicate submissions")
|
||||
idempotency_scope: Optional[str] = Field("none", description="Idempotency scope: none, session, global")
|
||||
|
||||
|
||||
class JobSubmitResponse(BaseModel):
|
||||
@@ -96,12 +99,15 @@ class Job(BaseModel):
|
||||
prompt: str = Field(...)
|
||||
agent_file: Optional[str] = Field(None)
|
||||
agent_yaml: Optional[str] = Field(None)
|
||||
recipe_name: Optional[str] = Field(None)
|
||||
recipe_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
framework: str = Field("praisonai")
|
||||
config: Dict[str, Any] = Field(default_factory=dict)
|
||||
webhook_url: Optional[str] = Field(None)
|
||||
timeout: int = Field(3600)
|
||||
session_id: Optional[str] = Field(None)
|
||||
idempotency_key: Optional[str] = Field(None)
|
||||
idempotency_scope: str = Field("none")
|
||||
|
||||
# Progress tracking
|
||||
progress_percentage: float = Field(0.0)
|
||||
|
||||
@@ -20,10 +20,17 @@ __all__ = [
|
||||
"validate",
|
||||
"list_recipes",
|
||||
"describe",
|
||||
# Runtime Bridge API
|
||||
"resolve",
|
||||
"run_background",
|
||||
"submit_job",
|
||||
"schedule",
|
||||
# Data classes
|
||||
"RecipeResult",
|
||||
"RecipeEvent",
|
||||
"RecipeConfig",
|
||||
"ResolvedRecipe",
|
||||
"RuntimeConfig",
|
||||
# Exceptions
|
||||
"RecipeError",
|
||||
"RecipeNotFoundError",
|
||||
@@ -99,5 +106,29 @@ def __getattr__(name):
|
||||
from .models import ExitCode
|
||||
_module_cache[name] = ExitCode
|
||||
return ExitCode
|
||||
elif name == "resolve":
|
||||
from .bridge import resolve
|
||||
_module_cache[name] = resolve
|
||||
return resolve
|
||||
elif name == "run_background":
|
||||
from .operations import run_background
|
||||
_module_cache[name] = run_background
|
||||
return run_background
|
||||
elif name == "submit_job":
|
||||
from .operations import submit_job
|
||||
_module_cache[name] = submit_job
|
||||
return submit_job
|
||||
elif name == "schedule":
|
||||
from .operations import schedule
|
||||
_module_cache[name] = schedule
|
||||
return schedule
|
||||
elif name == "ResolvedRecipe":
|
||||
from .bridge import ResolvedRecipe
|
||||
_module_cache[name] = ResolvedRecipe
|
||||
return ResolvedRecipe
|
||||
elif name == "RuntimeConfig":
|
||||
from .runtime import RuntimeConfig
|
||||
_module_cache[name] = RuntimeConfig
|
||||
return RuntimeConfig
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Recipe Runtime Bridge.
|
||||
|
||||
Provides unified recipe resolution and execution for operational runtimes:
|
||||
- Background tasks
|
||||
- Async jobs
|
||||
- Scheduled execution
|
||||
|
||||
This module bridges the gap between recipes and operational features,
|
||||
keeping all glue code in the wrapper (praisonai) without modifying core SDK.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedRecipe:
|
||||
"""
|
||||
A fully resolved recipe ready for execution.
|
||||
|
||||
Contains all information needed to execute a recipe in any runtime mode.
|
||||
"""
|
||||
name: str
|
||||
version: str
|
||||
description: str
|
||||
path: Path
|
||||
|
||||
# Workflow/agents configuration
|
||||
workflow_config: Dict[str, Any] = field(default_factory=dict)
|
||||
agents_config: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# User-provided input and config
|
||||
input_data: Any = None
|
||||
config: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Runtime configuration
|
||||
runtime_config: Optional[Any] = None # RuntimeConfig
|
||||
|
||||
# Execution context
|
||||
session_id: Optional[str] = None
|
||||
run_id: str = field(default_factory=lambda: f"run_{uuid.uuid4().hex[:12]}")
|
||||
|
||||
# Options
|
||||
options: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def get_timeout_sec(self, default: int = 300) -> int:
|
||||
"""Get timeout from runtime config or options."""
|
||||
if self.options.get('timeout_sec'):
|
||||
return self.options['timeout_sec']
|
||||
if self.runtime_config and hasattr(self.runtime_config, 'schedule'):
|
||||
return self.runtime_config.schedule.timeout_sec
|
||||
return default
|
||||
|
||||
def get_max_cost_usd(self, default: float = 1.00) -> float:
|
||||
"""Get max cost from runtime config or options."""
|
||||
if self.options.get('max_cost_usd'):
|
||||
return self.options['max_cost_usd']
|
||||
if self.runtime_config and hasattr(self.runtime_config, 'schedule'):
|
||||
return self.runtime_config.schedule.max_cost_usd
|
||||
return default
|
||||
|
||||
|
||||
def resolve(
|
||||
name: str,
|
||||
input_data: Any = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
session_id: Optional[str] = None,
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
) -> ResolvedRecipe:
|
||||
"""
|
||||
Resolve a recipe name into a fully executable ResolvedRecipe.
|
||||
|
||||
Args:
|
||||
name: Recipe name or path
|
||||
input_data: User input for the recipe
|
||||
config: Configuration overrides
|
||||
session_id: Session ID for conversation continuity
|
||||
options: Execution options (timeout_sec, verbose, etc.)
|
||||
|
||||
Returns:
|
||||
ResolvedRecipe ready for execution
|
||||
|
||||
Raises:
|
||||
RecipeNotFoundError: If recipe cannot be found
|
||||
RecipeValidationError: If recipe is invalid
|
||||
"""
|
||||
from praisonai.templates import TemplateLoader
|
||||
from praisonai.recipe.exceptions import RecipeNotFoundError
|
||||
|
||||
options = options or {}
|
||||
config = config or {}
|
||||
|
||||
loader = TemplateLoader(offline=options.get('offline', False))
|
||||
|
||||
try:
|
||||
# Load template configuration
|
||||
template_config = loader.load(name, config=config, offline=options.get('offline', False))
|
||||
except Exception as e:
|
||||
# Try to find similar recipes for helpful error message
|
||||
try:
|
||||
from praisonai.recipe.core import list_recipes
|
||||
available = list_recipes()
|
||||
suggestions = _find_similar_recipes(name, [r.name for r in available])
|
||||
if suggestions:
|
||||
raise RecipeNotFoundError(
|
||||
f"Recipe '{name}' not found. Did you mean: {', '.join(suggestions[:3])}?"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise RecipeNotFoundError(f"Recipe '{name}' not found: {e}")
|
||||
|
||||
# Load workflow and agents configs
|
||||
try:
|
||||
workflow_config = loader.load_workflow_config(template_config)
|
||||
except Exception:
|
||||
workflow_config = {}
|
||||
|
||||
try:
|
||||
agents_config = loader.load_agents_config(template_config)
|
||||
except Exception:
|
||||
agents_config = {}
|
||||
|
||||
# Generate session_id if not provided
|
||||
if not session_id:
|
||||
session_id = f"session_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
return ResolvedRecipe(
|
||||
name=template_config.name,
|
||||
version=template_config.version,
|
||||
description=template_config.description,
|
||||
path=template_config.path,
|
||||
workflow_config=workflow_config,
|
||||
agents_config=agents_config,
|
||||
input_data=input_data,
|
||||
config={**template_config.defaults, **config},
|
||||
runtime_config=template_config.runtime,
|
||||
session_id=session_id,
|
||||
options=options,
|
||||
)
|
||||
|
||||
|
||||
def execute_resolved_recipe(
|
||||
resolved: ResolvedRecipe,
|
||||
progress_callback: Optional[callable] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Execute a resolved recipe and return the result.
|
||||
|
||||
This is the canonical execution function used by all runtime modes.
|
||||
|
||||
Args:
|
||||
resolved: Resolved recipe to execute
|
||||
progress_callback: Optional callback for progress updates
|
||||
|
||||
Returns:
|
||||
Recipe execution result
|
||||
"""
|
||||
from praisonai.recipe.core import run
|
||||
|
||||
# Build options dict
|
||||
options = {
|
||||
'timeout_sec': resolved.get_timeout_sec(),
|
||||
'verbose': resolved.options.get('verbose', False),
|
||||
'dry_run': resolved.options.get('dry_run', False),
|
||||
**resolved.options,
|
||||
}
|
||||
|
||||
# Execute using core recipe.run
|
||||
result = run(
|
||||
resolved.name,
|
||||
input=resolved.input_data,
|
||||
config=resolved.config,
|
||||
session_id=resolved.session_id,
|
||||
options=options,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def execute_resolved_recipe_stream(
|
||||
resolved: ResolvedRecipe,
|
||||
progress_callback: Optional[callable] = None,
|
||||
) -> Iterator[Any]:
|
||||
"""
|
||||
Execute a resolved recipe with streaming output.
|
||||
|
||||
Args:
|
||||
resolved: Resolved recipe to execute
|
||||
progress_callback: Optional callback for progress updates
|
||||
|
||||
Yields:
|
||||
RecipeEvent objects during execution
|
||||
"""
|
||||
from praisonai.recipe.core import run_stream
|
||||
|
||||
# Build options dict
|
||||
options = {
|
||||
'timeout_sec': resolved.get_timeout_sec(),
|
||||
'verbose': resolved.options.get('verbose', False),
|
||||
**resolved.options,
|
||||
}
|
||||
|
||||
# Execute using core recipe.run_stream
|
||||
for event in run_stream(
|
||||
resolved.name,
|
||||
input=resolved.input_data,
|
||||
config=resolved.config,
|
||||
session_id=resolved.session_id,
|
||||
options=options,
|
||||
):
|
||||
if progress_callback:
|
||||
try:
|
||||
progress_callback(event)
|
||||
except Exception as e:
|
||||
logger.warning(f"Progress callback error: {e}")
|
||||
yield event
|
||||
|
||||
|
||||
def _find_similar_recipes(name: str, available: List[str], max_results: int = 5) -> List[str]:
|
||||
"""Find recipes with similar names using simple string matching."""
|
||||
name_lower = name.lower()
|
||||
|
||||
# Exact prefix match
|
||||
prefix_matches = [r for r in available if r.lower().startswith(name_lower)]
|
||||
|
||||
# Contains match
|
||||
contains_matches = [r for r in available if name_lower in r.lower() and r not in prefix_matches]
|
||||
|
||||
# Levenshtein-like similarity (simplified)
|
||||
def similarity(a: str, b: str) -> float:
|
||||
a, b = a.lower(), b.lower()
|
||||
if a == b:
|
||||
return 1.0
|
||||
if a in b or b in a:
|
||||
return 0.8
|
||||
# Count common characters
|
||||
common = sum(1 for c in a if c in b)
|
||||
return common / max(len(a), len(b))
|
||||
|
||||
other_matches = sorted(
|
||||
[r for r in available if r not in prefix_matches and r not in contains_matches],
|
||||
key=lambda r: similarity(name, r),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
results = prefix_matches + contains_matches + other_matches
|
||||
return results[:max_results]
|
||||
|
||||
|
||||
# Convenience functions for different runtime modes
|
||||
|
||||
def get_recipe_task_description(resolved: ResolvedRecipe) -> str:
|
||||
"""Get a task description suitable for scheduler/background."""
|
||||
if resolved.input_data:
|
||||
if isinstance(resolved.input_data, str):
|
||||
return resolved.input_data
|
||||
elif isinstance(resolved.input_data, dict):
|
||||
return str(resolved.input_data.get('prompt', resolved.input_data.get('task', str(resolved.input_data))))
|
||||
return f"Execute recipe: {resolved.name}"
|
||||
|
||||
|
||||
def get_recipe_metadata(resolved: ResolvedRecipe) -> Dict[str, Any]:
|
||||
"""Get metadata dict for job/scheduler state persistence."""
|
||||
return {
|
||||
'recipe_name': resolved.name,
|
||||
'recipe_version': resolved.version,
|
||||
'recipe_path': str(resolved.path) if resolved.path else None,
|
||||
'session_id': resolved.session_id,
|
||||
'run_id': resolved.run_id,
|
||||
'config': resolved.config,
|
||||
'options': resolved.options,
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
"""
|
||||
Recipe Operations Module.
|
||||
|
||||
Provides high-level APIs for running recipes in different operational modes:
|
||||
- run_background(): Execute recipe as a background task
|
||||
- submit_job(): Submit recipe to async jobs server
|
||||
- schedule(): Create a scheduled recipe executor
|
||||
|
||||
All functions are lazy-loaded and have zero performance impact when not used.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Safe defaults
|
||||
DEFAULT_TIMEOUT_SEC = 300
|
||||
DEFAULT_MAX_COST_USD = 1.00
|
||||
DEFAULT_MAX_RETRIES = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackgroundTaskHandle:
|
||||
"""Handle for a background recipe task."""
|
||||
task_id: str
|
||||
recipe_name: str
|
||||
session_id: Optional[str] = None
|
||||
_runner: Any = field(default=None, repr=False)
|
||||
_task: Any = field(default=None, repr=False)
|
||||
|
||||
async def status(self) -> str:
|
||||
"""Get task status."""
|
||||
if self._runner and self._task:
|
||||
return self._task.status.value
|
||||
return "unknown"
|
||||
|
||||
async def wait(self, timeout: Optional[float] = None) -> Any:
|
||||
"""Wait for task completion and return result."""
|
||||
if self._runner and self._task:
|
||||
return await self._runner.wait_for_task(self._task.id, timeout=timeout)
|
||||
return None
|
||||
|
||||
async def cancel(self) -> bool:
|
||||
"""Cancel the task."""
|
||||
if self._runner:
|
||||
return await self._runner.cancel(self.task_id)
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobHandle:
|
||||
"""Handle for an async job."""
|
||||
job_id: str
|
||||
recipe_name: str
|
||||
status: str = "queued"
|
||||
poll_url: Optional[str] = None
|
||||
stream_url: Optional[str] = None
|
||||
api_url: str = "http://127.0.0.1:8005"
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get job status from API."""
|
||||
try:
|
||||
import httpx
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.get(f"{self.api_url}/api/v1/runs/{self.job_id}")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get job status: {e}")
|
||||
return {"job_id": self.job_id, "status": "unknown", "error": str(e)}
|
||||
|
||||
def get_result(self) -> Any:
|
||||
"""Get job result from API."""
|
||||
try:
|
||||
import httpx
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.get(f"{self.api_url}/api/v1/runs/{self.job_id}/result")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get job result: {e}")
|
||||
return {"job_id": self.job_id, "error": str(e)}
|
||||
|
||||
def cancel(self) -> bool:
|
||||
"""Cancel the job."""
|
||||
try:
|
||||
import httpx
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.post(f"{self.api_url}/api/v1/runs/{self.job_id}/cancel")
|
||||
return response.status_code < 400
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cancel job: {e}")
|
||||
return False
|
||||
|
||||
def wait(self, poll_interval: int = 5, timeout: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Wait for job completion by polling."""
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
status = self.get_status()
|
||||
if status.get("status") in ("succeeded", "failed", "cancelled"):
|
||||
return self.get_result()
|
||||
|
||||
if timeout and (time.time() - start_time) > timeout:
|
||||
return {"job_id": self.job_id, "status": "timeout", "error": "Wait timeout exceeded"}
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipeScheduler:
|
||||
"""Wrapper around AgentScheduler for recipe-based scheduling."""
|
||||
recipe_name: str
|
||||
interval: str = "hourly"
|
||||
max_retries: int = DEFAULT_MAX_RETRIES
|
||||
timeout_sec: int = DEFAULT_TIMEOUT_SEC
|
||||
max_cost_usd: float = DEFAULT_MAX_COST_USD
|
||||
run_immediately: bool = False
|
||||
_scheduler: Any = field(default=None, repr=False)
|
||||
_resolved: Any = field(default=None, repr=False)
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the scheduler."""
|
||||
if self._scheduler:
|
||||
return self._scheduler.start(
|
||||
schedule_expr=self.interval,
|
||||
max_retries=self.max_retries,
|
||||
run_immediately=self.run_immediately
|
||||
)
|
||||
return False
|
||||
|
||||
def stop(self) -> bool:
|
||||
"""Stop the scheduler."""
|
||||
if self._scheduler:
|
||||
return self._scheduler.stop()
|
||||
return False
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get scheduler statistics."""
|
||||
if self._scheduler:
|
||||
return self._scheduler.get_stats()
|
||||
return {}
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if scheduler is running."""
|
||||
if self._scheduler:
|
||||
return self._scheduler.is_running
|
||||
return False
|
||||
|
||||
|
||||
def run_background(
|
||||
name: str,
|
||||
*,
|
||||
input: Any = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
session_id: Optional[str] = None,
|
||||
timeout_sec: Optional[int] = None,
|
||||
max_concurrent: int = 5,
|
||||
on_complete: Optional[callable] = None,
|
||||
) -> BackgroundTaskHandle:
|
||||
"""
|
||||
Run a recipe as a background task.
|
||||
|
||||
Args:
|
||||
name: Recipe name
|
||||
input: Input data for the recipe
|
||||
config: Configuration overrides
|
||||
session_id: Session ID for conversation continuity
|
||||
timeout_sec: Timeout in seconds (default: 300)
|
||||
max_concurrent: Max concurrent tasks (default: 5)
|
||||
on_complete: Callback when task completes
|
||||
|
||||
Returns:
|
||||
BackgroundTaskHandle for tracking the task
|
||||
|
||||
Example:
|
||||
task = recipe.run_background("my-recipe", input={"query": "test"})
|
||||
print(f"Task ID: {task.task_id}")
|
||||
result = await task.wait()
|
||||
"""
|
||||
import asyncio
|
||||
from .bridge import resolve, execute_resolved_recipe
|
||||
|
||||
# Resolve the recipe
|
||||
resolved = resolve(
|
||||
name,
|
||||
input_data=input,
|
||||
config=config,
|
||||
session_id=session_id,
|
||||
options={'timeout_sec': timeout_sec or DEFAULT_TIMEOUT_SEC},
|
||||
)
|
||||
|
||||
# Import BackgroundRunner lazily
|
||||
try:
|
||||
from praisonaiagents.background import BackgroundRunner
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"Background tasks require praisonaiagents. "
|
||||
"Install with: pip install praisonaiagents"
|
||||
)
|
||||
|
||||
# Create or get runner
|
||||
runner = BackgroundRunner(max_concurrent_tasks=max_concurrent)
|
||||
|
||||
# Define the task function
|
||||
def recipe_task():
|
||||
return execute_resolved_recipe(resolved)
|
||||
|
||||
# Submit the task
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# We're in an async context, use create_task
|
||||
import concurrent.futures
|
||||
future = concurrent.futures.Future()
|
||||
|
||||
async def submit_and_return():
|
||||
task = await runner.submit(
|
||||
recipe_task,
|
||||
name=f"recipe:{resolved.name}",
|
||||
timeout=timeout_sec,
|
||||
on_complete=on_complete,
|
||||
)
|
||||
return task
|
||||
|
||||
asyncio.ensure_future(submit_and_return()).add_done_callback(
|
||||
lambda f: future.set_result(f.result())
|
||||
)
|
||||
task = future.result(timeout=10)
|
||||
else:
|
||||
# Sync context
|
||||
task = loop.run_until_complete(runner.submit(
|
||||
recipe_task,
|
||||
name=f"recipe:{resolved.name}",
|
||||
timeout=timeout_sec,
|
||||
on_complete=on_complete,
|
||||
))
|
||||
|
||||
return BackgroundTaskHandle(
|
||||
task_id=task.id,
|
||||
recipe_name=resolved.name,
|
||||
session_id=resolved.session_id,
|
||||
_runner=runner,
|
||||
_task=task,
|
||||
)
|
||||
|
||||
|
||||
def submit_job(
|
||||
name: str,
|
||||
*,
|
||||
input: Any = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
session_id: Optional[str] = None,
|
||||
timeout_sec: Optional[int] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
webhook_url: Optional[str] = None,
|
||||
api_url: str = "http://127.0.0.1:8005",
|
||||
wait: bool = False,
|
||||
poll_interval: int = 5,
|
||||
) -> JobHandle:
|
||||
"""
|
||||
Submit a recipe to the async jobs server.
|
||||
|
||||
Args:
|
||||
name: Recipe name
|
||||
input: Input data for the recipe
|
||||
config: Configuration overrides
|
||||
session_id: Session ID for conversation continuity
|
||||
timeout_sec: Timeout in seconds (default: 3600)
|
||||
idempotency_key: Key for deduplication
|
||||
webhook_url: URL for completion webhook
|
||||
api_url: Jobs API URL (default: http://127.0.0.1:8005)
|
||||
wait: If True, wait for completion before returning
|
||||
poll_interval: Polling interval when waiting (seconds)
|
||||
|
||||
Returns:
|
||||
JobHandle for tracking the job
|
||||
|
||||
Example:
|
||||
job = recipe.submit_job("my-recipe", input={"query": "test"}, wait=True)
|
||||
print(f"Result: {job.get_result()}")
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"Job submission requires httpx. "
|
||||
"Install with: pip install httpx"
|
||||
)
|
||||
|
||||
# Build request payload
|
||||
payload = {
|
||||
"prompt": str(input) if input else f"Execute recipe: {name}",
|
||||
"recipe_name": name,
|
||||
"timeout": timeout_sec or 3600,
|
||||
}
|
||||
|
||||
if config:
|
||||
payload["config"] = config
|
||||
if session_id:
|
||||
payload["session_id"] = session_id
|
||||
if idempotency_key:
|
||||
payload["idempotency_key"] = idempotency_key
|
||||
if webhook_url:
|
||||
payload["webhook_url"] = webhook_url
|
||||
|
||||
# Submit to API
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.post(
|
||||
f"{api_url}/api/v1/runs",
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
handle = JobHandle(
|
||||
job_id=data["job_id"],
|
||||
recipe_name=name,
|
||||
status=data.get("status", "queued"),
|
||||
poll_url=data.get("poll_url"),
|
||||
stream_url=data.get("stream_url"),
|
||||
api_url=api_url,
|
||||
)
|
||||
|
||||
if wait:
|
||||
handle.wait(poll_interval=poll_interval, timeout=timeout_sec)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def schedule(
|
||||
name: str,
|
||||
*,
|
||||
input: Any = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
interval: Optional[str] = None,
|
||||
max_retries: Optional[int] = None,
|
||||
run_immediately: Optional[bool] = None,
|
||||
timeout_sec: Optional[int] = None,
|
||||
max_cost_usd: Optional[float] = None,
|
||||
on_success: Optional[callable] = None,
|
||||
on_failure: Optional[callable] = None,
|
||||
) -> RecipeScheduler:
|
||||
"""
|
||||
Create a scheduler for periodic recipe execution.
|
||||
|
||||
Args:
|
||||
name: Recipe name
|
||||
input: Input data for the recipe
|
||||
config: Configuration overrides
|
||||
interval: Schedule interval (hourly, daily, */30m, etc.)
|
||||
max_retries: Max retry attempts (default: 3)
|
||||
run_immediately: Run once immediately (default: False)
|
||||
timeout_sec: Timeout per execution (default: 300)
|
||||
max_cost_usd: Budget limit (default: $1.00)
|
||||
on_success: Callback on successful execution
|
||||
on_failure: Callback on failed execution
|
||||
|
||||
Returns:
|
||||
RecipeScheduler instance (call .start() to begin)
|
||||
|
||||
Example:
|
||||
scheduler = recipe.schedule("news-monitor", interval="hourly")
|
||||
scheduler.start()
|
||||
# ... later ...
|
||||
scheduler.stop()
|
||||
"""
|
||||
from .bridge import resolve, execute_resolved_recipe, get_recipe_task_description
|
||||
|
||||
# Resolve the recipe
|
||||
resolved = resolve(
|
||||
name,
|
||||
input_data=input,
|
||||
config=config,
|
||||
options={'timeout_sec': timeout_sec or DEFAULT_TIMEOUT_SEC},
|
||||
)
|
||||
|
||||
# Get runtime config defaults
|
||||
runtime = resolved.runtime_config
|
||||
if runtime and hasattr(runtime, 'schedule'):
|
||||
sched_config = runtime.schedule
|
||||
interval = interval or sched_config.interval
|
||||
max_retries = max_retries if max_retries is not None else sched_config.max_retries
|
||||
run_immediately = run_immediately if run_immediately is not None else sched_config.run_immediately
|
||||
timeout_sec = timeout_sec or sched_config.timeout_sec
|
||||
max_cost_usd = max_cost_usd if max_cost_usd is not None else sched_config.max_cost_usd
|
||||
|
||||
# Apply defaults
|
||||
interval = interval or "hourly"
|
||||
max_retries = max_retries if max_retries is not None else DEFAULT_MAX_RETRIES
|
||||
run_immediately = run_immediately if run_immediately is not None else False
|
||||
timeout_sec = timeout_sec or DEFAULT_TIMEOUT_SEC
|
||||
max_cost_usd = max_cost_usd if max_cost_usd is not None else DEFAULT_MAX_COST_USD
|
||||
|
||||
# Import AgentScheduler lazily
|
||||
try:
|
||||
from praisonai.scheduler import AgentScheduler
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"Scheduling requires praisonai scheduler module."
|
||||
)
|
||||
|
||||
# Create a recipe executor agent wrapper
|
||||
class RecipeExecutorAgent:
|
||||
"""Wrapper that makes a recipe look like an agent for the scheduler."""
|
||||
def __init__(self, resolved_recipe):
|
||||
self.resolved = resolved_recipe
|
||||
self.name = f"RecipeAgent:{resolved_recipe.name}"
|
||||
|
||||
def start(self, task: str) -> Any:
|
||||
return execute_resolved_recipe(self.resolved)
|
||||
|
||||
# Create the agent wrapper
|
||||
agent = RecipeExecutorAgent(resolved)
|
||||
task = get_recipe_task_description(resolved)
|
||||
|
||||
# Create the scheduler
|
||||
scheduler = AgentScheduler(
|
||||
agent=agent,
|
||||
task=task,
|
||||
timeout=timeout_sec,
|
||||
max_cost=max_cost_usd,
|
||||
on_success=on_success,
|
||||
on_failure=on_failure,
|
||||
)
|
||||
|
||||
return RecipeScheduler(
|
||||
recipe_name=resolved.name,
|
||||
interval=interval,
|
||||
max_retries=max_retries,
|
||||
timeout_sec=timeout_sec,
|
||||
max_cost_usd=max_cost_usd,
|
||||
run_immediately=run_immediately,
|
||||
_scheduler=scheduler,
|
||||
_resolved=resolved,
|
||||
)
|
||||
@@ -4,8 +4,10 @@ Recipe Registry Module
|
||||
Provides local and remote registry support for recipe distribution.
|
||||
Supports:
|
||||
- Local filesystem registry (~/.praison/registry)
|
||||
- Remote HTTP registry (optional)
|
||||
- Local HTTP registry (http://localhost:7777)
|
||||
- Remote HTTP registry (https://registry.example.com)
|
||||
- Publish, pull, search, list operations
|
||||
- Atomic writes and file locking for concurrency safety
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -13,15 +15,18 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Dict, List, Optional, Union, Protocol
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
# Default registry paths
|
||||
DEFAULT_REGISTRY_PATH = Path.home() / ".praison" / "registry"
|
||||
DEFAULT_RUNS_PATH = Path.home() / ".praison" / "runs"
|
||||
DEFAULT_REGISTRY_PORT = 7777
|
||||
|
||||
|
||||
class RegistryError(Exception):
|
||||
@@ -44,6 +49,52 @@ class RegistryAuthError(RegistryError):
|
||||
pass
|
||||
|
||||
|
||||
class RegistryNetworkError(RegistryError):
|
||||
"""Network error connecting to registry."""
|
||||
pass
|
||||
|
||||
|
||||
class RegistryConflictError(RegistryError):
|
||||
"""Conflict error (e.g., already exists without force)."""
|
||||
pass
|
||||
|
||||
|
||||
class RegistryProtocol(Protocol):
|
||||
"""Protocol defining registry interface for type checking."""
|
||||
|
||||
def publish(
|
||||
self,
|
||||
bundle_path: Union[str, Path],
|
||||
force: bool = False,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]: ...
|
||||
|
||||
def pull(
|
||||
self,
|
||||
name: str,
|
||||
version: Optional[str] = None,
|
||||
output_dir: Optional[Path] = None,
|
||||
verify_checksum: bool = True,
|
||||
) -> Dict[str, Any]: ...
|
||||
|
||||
def list_recipes(
|
||||
self,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]: ...
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]: ...
|
||||
|
||||
def get_versions(self, name: str) -> List[str]: ...
|
||||
|
||||
def get_info(self, name: str, version: Optional[str] = None) -> Dict[str, Any]: ...
|
||||
|
||||
def delete(self, name: str, version: Optional[str] = None) -> bool: ...
|
||||
|
||||
|
||||
def _get_timestamp() -> str:
|
||||
"""Get current timestamp in ISO format."""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@@ -58,6 +109,46 @@ def _calculate_checksum(file_path: Path) -> str:
|
||||
return sha256.hexdigest()
|
||||
|
||||
|
||||
def _normalize_name(name: str) -> str:
|
||||
"""Normalize recipe name per PEP 503 rules."""
|
||||
return re.sub(r"[-_.]+", "-", name).lower()
|
||||
|
||||
|
||||
def _validate_name(name: str) -> bool:
|
||||
"""Validate recipe name format."""
|
||||
if not name or len(name) > 128:
|
||||
return False
|
||||
return bool(re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$', name))
|
||||
|
||||
|
||||
def _validate_version(version: str) -> bool:
|
||||
"""Validate version string (semver-like)."""
|
||||
if not version:
|
||||
return False
|
||||
return bool(re.match(r'^\d+\.\d+\.\d+([a-zA-Z0-9._-]*)?$', version))
|
||||
|
||||
|
||||
def _atomic_write(file_path: Path, data: bytes) -> None:
|
||||
"""Write data atomically using temp file + rename."""
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=file_path.parent, suffix='.tmp')
|
||||
try:
|
||||
os.write(fd, data)
|
||||
os.fsync(fd)
|
||||
os.close(fd)
|
||||
os.rename(tmp_path, file_path)
|
||||
except Exception:
|
||||
os.close(fd)
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
|
||||
|
||||
def _atomic_write_json(file_path: Path, data: Dict[str, Any]) -> None:
|
||||
"""Write JSON data atomically."""
|
||||
_atomic_write(file_path, json.dumps(data, indent=2).encode('utf-8'))
|
||||
|
||||
|
||||
class LocalRegistry:
|
||||
"""
|
||||
Local filesystem-based recipe registry.
|
||||
@@ -394,14 +485,18 @@ class LocalRegistry:
|
||||
return True
|
||||
|
||||
|
||||
class RemoteRegistry:
|
||||
class HttpRegistry:
|
||||
"""
|
||||
Remote HTTP-based recipe registry client.
|
||||
HTTP-based recipe registry client.
|
||||
|
||||
Works with both local HTTP registry (http://localhost:7777) and
|
||||
remote HTTP registries (https://registry.example.com).
|
||||
|
||||
Supports:
|
||||
- Token-based authentication
|
||||
- Publish/pull operations
|
||||
- Search and list
|
||||
- Token-based authentication (Bearer token)
|
||||
- Multipart file upload for publish
|
||||
- ETag/If-None-Match for efficient downloads
|
||||
- Proper error handling with specific exceptions
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -411,50 +506,147 @@ class RemoteRegistry:
|
||||
timeout: int = 30,
|
||||
):
|
||||
"""
|
||||
Initialize remote registry client.
|
||||
Initialize HTTP registry client.
|
||||
|
||||
Args:
|
||||
url: Registry base URL
|
||||
token: Authentication token
|
||||
url: Registry base URL (http://localhost:7777 or https://...)
|
||||
token: Authentication token (or set PRAISONAI_REGISTRY_TOKEN env var)
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.url = url.rstrip("/")
|
||||
self.token = token or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
|
||||
self.timeout = timeout
|
||||
self._etag_cache: Dict[str, str] = {}
|
||||
|
||||
def _get_headers(self, content_type: str = "application/json") -> Dict[str, str]:
|
||||
"""Get request headers with optional auth."""
|
||||
headers = {"Content-Type": content_type}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
return headers
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
data: Optional[Dict] = None,
|
||||
files: Optional[Dict] = None,
|
||||
data: Optional[bytes] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make HTTP request to registry."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = f"{self.url}{path}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req_headers = headers or self._get_headers()
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
||||
# Cache ETag for future requests
|
||||
etag = response.headers.get("ETag")
|
||||
if etag:
|
||||
self._etag_cache[path] = etag
|
||||
return json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise RegistryAuthError("Authentication failed")
|
||||
raise RegistryAuthError("Authentication failed. Check your token.")
|
||||
elif e.code == 403:
|
||||
raise RegistryAuthError("Access denied. Token may lack required permissions.")
|
||||
elif e.code == 404:
|
||||
raise RecipeNotFoundError(f"Not found: {path}")
|
||||
elif e.code == 409:
|
||||
raise RegistryConflictError("Recipe version already exists. Use --force to overwrite.")
|
||||
else:
|
||||
body = e.read().decode() if e.fp else ""
|
||||
raise RegistryError(f"Registry error ({e.code}): {body}")
|
||||
except urllib.error.URLError as e:
|
||||
raise RegistryError(f"Connection error: {e.reason}")
|
||||
raise RegistryNetworkError(f"Connection error: {e.reason}")
|
||||
|
||||
def _download_file(self, path: str, dest_path: Path) -> Dict[str, Any]:
|
||||
"""Download file from registry with ETag support."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = f"{self.url}{path}"
|
||||
headers = self._get_headers()
|
||||
|
||||
# Add If-None-Match if we have cached ETag
|
||||
if path in self._etag_cache:
|
||||
headers["If-None-Match"] = self._etag_cache[path]
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, headers=headers, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
||||
etag = response.headers.get("ETag")
|
||||
if etag:
|
||||
self._etag_cache[path] = etag
|
||||
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(dest_path, "wb") as f:
|
||||
shutil.copyfileobj(response, f)
|
||||
|
||||
return {"downloaded": True, "path": str(dest_path)}
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 304:
|
||||
return {"downloaded": False, "cached": True}
|
||||
elif e.code == 404:
|
||||
raise RecipeNotFoundError(f"Not found: {path}")
|
||||
else:
|
||||
raise RegistryError(f"Download error ({e.code})")
|
||||
except urllib.error.URLError as e:
|
||||
raise RegistryNetworkError(f"Connection error: {e.reason}")
|
||||
|
||||
def _upload_file(self, path: str, file_path: Path, force: bool = False) -> Dict[str, Any]:
|
||||
"""Upload file to registry using multipart form."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import uuid
|
||||
|
||||
boundary = f"----PraisonAI{uuid.uuid4().hex}"
|
||||
|
||||
# Build multipart body
|
||||
body_parts = []
|
||||
|
||||
# Add force field
|
||||
body_parts.append(f"--{boundary}".encode())
|
||||
body_parts.append(b'Content-Disposition: form-data; name="force"')
|
||||
body_parts.append(b"")
|
||||
body_parts.append(b"true" if force else b"false")
|
||||
|
||||
# Add file
|
||||
body_parts.append(f"--{boundary}".encode())
|
||||
body_parts.append(f'Content-Disposition: form-data; name="bundle"; filename="{file_path.name}"'.encode())
|
||||
body_parts.append(b"Content-Type: application/gzip")
|
||||
body_parts.append(b"")
|
||||
with open(file_path, "rb") as f:
|
||||
body_parts.append(f.read())
|
||||
|
||||
body_parts.append(f"--{boundary}--".encode())
|
||||
body_parts.append(b"")
|
||||
|
||||
body = b"\r\n".join(body_parts)
|
||||
|
||||
headers = self._get_headers(f"multipart/form-data; boundary={boundary}")
|
||||
|
||||
url = f"{self.url}{path}"
|
||||
try:
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
||||
return json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise RegistryAuthError("Authentication required for publish")
|
||||
elif e.code == 409:
|
||||
raise RegistryConflictError("Recipe version already exists. Use --force to overwrite.")
|
||||
else:
|
||||
body_text = e.read().decode() if e.fp else ""
|
||||
raise RegistryError(f"Upload error ({e.code}): {body_text}")
|
||||
except urllib.error.URLError as e:
|
||||
raise RegistryNetworkError(f"Connection error: {e.reason}")
|
||||
|
||||
def health(self) -> Dict[str, Any]:
|
||||
"""Check registry health."""
|
||||
return self._request("GET", "/healthz")
|
||||
|
||||
def publish(
|
||||
self,
|
||||
@@ -462,24 +654,39 @@ class RemoteRegistry:
|
||||
force: bool = False,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Publish bundle to remote registry."""
|
||||
"""
|
||||
Publish bundle to HTTP registry.
|
||||
|
||||
Args:
|
||||
bundle_path: Path to .praison bundle file
|
||||
force: Overwrite existing version if True
|
||||
metadata: Additional metadata (ignored for HTTP, included in bundle)
|
||||
|
||||
Returns:
|
||||
Dict with name, version, checksum
|
||||
"""
|
||||
bundle_path = Path(bundle_path)
|
||||
if not bundle_path.exists():
|
||||
raise RegistryError(f"Bundle not found: {bundle_path}")
|
||||
|
||||
# For remote, we'd upload the file
|
||||
# This is a simplified implementation
|
||||
with open(bundle_path, "rb") as f:
|
||||
bundle_data = f.read()
|
||||
# Extract name/version from bundle to construct path
|
||||
try:
|
||||
with tarfile.open(bundle_path, "r:gz") as tar:
|
||||
manifest_file = tar.extractfile("manifest.json")
|
||||
if not manifest_file:
|
||||
raise RegistryError("Bundle missing manifest.json")
|
||||
manifest = json.load(manifest_file)
|
||||
except tarfile.TarError as e:
|
||||
raise RegistryError(f"Invalid bundle format: {e}")
|
||||
|
||||
import base64
|
||||
encoded = base64.b64encode(bundle_data).decode()
|
||||
name = manifest.get("name")
|
||||
version = manifest.get("version")
|
||||
|
||||
return self._request("POST", "/v1/recipes/publish", {
|
||||
"bundle": encoded,
|
||||
"force": force,
|
||||
"metadata": metadata or {},
|
||||
})
|
||||
if not name or not version:
|
||||
raise RegistryError("Bundle manifest missing name or version")
|
||||
|
||||
# Upload to /v1/recipes/{name}/{version}
|
||||
return self._upload_file(f"/v1/recipes/{name}/{version}", bundle_path, force=force)
|
||||
|
||||
def pull(
|
||||
self,
|
||||
@@ -488,20 +695,41 @@ class RemoteRegistry:
|
||||
output_dir: Optional[Path] = None,
|
||||
verify_checksum: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Pull recipe from remote registry."""
|
||||
version_str = f"@{version}" if version else ""
|
||||
result = self._request("GET", f"/v1/recipes/{name}{version_str}/download")
|
||||
"""
|
||||
Pull recipe from HTTP registry.
|
||||
|
||||
# Decode and save bundle
|
||||
import base64
|
||||
bundle_data = base64.b64decode(result["bundle"])
|
||||
Args:
|
||||
name: Recipe name
|
||||
version: Version to pull (default: latest)
|
||||
output_dir: Directory to extract to
|
||||
verify_checksum: Verify bundle checksum
|
||||
|
||||
Returns:
|
||||
Dict with name, version, path
|
||||
"""
|
||||
# Get recipe info to determine version
|
||||
if not version:
|
||||
info = self._request("GET", f"/v1/recipes/{name}")
|
||||
version = info.get("latest")
|
||||
if not version:
|
||||
raise RecipeNotFoundError(f"No versions found for: {name}")
|
||||
|
||||
output_dir = Path(output_dir) if output_dir else Path.cwd()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
bundle_path = output_dir / f"{name}-{version}.praison"
|
||||
|
||||
bundle_path = output_dir / f"{name}-{result['version']}.praison"
|
||||
with open(bundle_path, "wb") as f:
|
||||
f.write(bundle_data)
|
||||
# Download bundle
|
||||
download_path = f"/v1/recipes/{name}/{version}/download"
|
||||
self._download_file(download_path, bundle_path)
|
||||
|
||||
# Verify checksum if requested
|
||||
if verify_checksum:
|
||||
info = self._request("GET", f"/v1/recipes/{name}/{version}")
|
||||
expected = info.get("checksum")
|
||||
if expected:
|
||||
actual = _calculate_checksum(bundle_path)
|
||||
if expected != actual:
|
||||
bundle_path.unlink()
|
||||
raise RegistryError(f"Checksum mismatch for {name}@{version}")
|
||||
|
||||
# Extract
|
||||
recipe_dir = output_dir / name
|
||||
@@ -512,24 +740,87 @@ class RemoteRegistry:
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"version": result["version"],
|
||||
"version": version,
|
||||
"path": str(recipe_dir),
|
||||
"bundle_path": str(bundle_path),
|
||||
}
|
||||
|
||||
def list_recipes(self, tags: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
||||
"""List recipes from remote registry."""
|
||||
params = ""
|
||||
if tags:
|
||||
params = f"?tags={','.join(tags)}"
|
||||
return self._request("GET", f"/v1/recipes{params}").get("recipes", [])
|
||||
|
||||
def search(self, query: str, tags: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
||||
"""Search recipes in remote registry."""
|
||||
params = f"?q={query}"
|
||||
def list_recipes(
|
||||
self,
|
||||
tags: Optional[List[str]] = None,
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List recipes from HTTP registry.
|
||||
|
||||
Args:
|
||||
tags: Filter by tags
|
||||
page: Page number (1-indexed)
|
||||
per_page: Results per page
|
||||
|
||||
Returns:
|
||||
List of recipe info dicts
|
||||
"""
|
||||
params = f"?page={page}&per_page={per_page}"
|
||||
if tags:
|
||||
params += f"&tags={','.join(tags)}"
|
||||
return self._request("GET", f"/v1/recipes/search{params}").get("results", [])
|
||||
result = self._request("GET", f"/v1/recipes{params}")
|
||||
return result.get("recipes", [])
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search recipes in HTTP registry.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
tags: Filter by tags
|
||||
|
||||
Returns:
|
||||
List of matching recipe info dicts
|
||||
"""
|
||||
from urllib.parse import quote as url_quote
|
||||
params = f"?q={url_quote(query)}"
|
||||
if tags:
|
||||
params += f"&tags={','.join(tags)}"
|
||||
result = self._request("GET", f"/v1/search{params}")
|
||||
return result.get("results", [])
|
||||
|
||||
def get_versions(self, name: str) -> List[str]:
|
||||
"""Get all versions of a recipe."""
|
||||
result = self._request("GET", f"/v1/recipes/{name}")
|
||||
return result.get("versions", [])
|
||||
|
||||
def get_info(self, name: str, version: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Get detailed info about a recipe version."""
|
||||
if version:
|
||||
return self._request("GET", f"/v1/recipes/{name}/{version}")
|
||||
return self._request("GET", f"/v1/recipes/{name}")
|
||||
|
||||
def delete(self, name: str, version: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Delete a recipe or specific version.
|
||||
|
||||
Args:
|
||||
name: Recipe name
|
||||
version: Version to delete (None = all versions)
|
||||
|
||||
Returns:
|
||||
True if deleted
|
||||
"""
|
||||
if version:
|
||||
self._request("DELETE", f"/v1/recipes/{name}/{version}")
|
||||
else:
|
||||
self._request("DELETE", f"/v1/recipes/{name}")
|
||||
return True
|
||||
|
||||
|
||||
# Alias for backwards compatibility
|
||||
RemoteRegistry = HttpRegistry
|
||||
|
||||
|
||||
def get_registry(
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Recipe Runtime Configuration Models.
|
||||
|
||||
Defines the runtime configuration schema for recipes, enabling:
|
||||
- Background task execution
|
||||
- Async job submission
|
||||
- Scheduled execution (24/7 daemon)
|
||||
|
||||
All fields are optional and backward compatible with existing recipes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# Safe defaults for operational features
|
||||
DEFAULT_TIMEOUT_SEC = 300 # 5 minutes
|
||||
DEFAULT_MAX_COST_USD = 1.00 # $1.00 budget limit
|
||||
DEFAULT_MAX_RETRIES = 3
|
||||
DEFAULT_MAX_CONCURRENT = 5
|
||||
DEFAULT_CLEANUP_DELAY_SEC = 3600 # 1 hour
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackgroundRuntimeConfig:
|
||||
"""Configuration for background task execution."""
|
||||
enabled: bool = False
|
||||
max_concurrent: int = DEFAULT_MAX_CONCURRENT
|
||||
cleanup_delay_sec: int = DEFAULT_CLEANUP_DELAY_SEC
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> 'BackgroundRuntimeConfig':
|
||||
"""Create from dictionary with defaults."""
|
||||
if not data:
|
||||
return cls()
|
||||
return cls(
|
||||
enabled=data.get('enabled', False),
|
||||
max_concurrent=data.get('max_concurrent', DEFAULT_MAX_CONCURRENT),
|
||||
cleanup_delay_sec=data.get('cleanup_delay_sec', DEFAULT_CLEANUP_DELAY_SEC),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobRuntimeConfig:
|
||||
"""Configuration for async job execution."""
|
||||
enabled: bool = False
|
||||
timeout_sec: int = DEFAULT_TIMEOUT_SEC
|
||||
webhook_url: Optional[str] = None # Supports ${ENV_VAR} interpolation
|
||||
idempotency_scope: str = "none" # none|session|global
|
||||
events: List[str] = field(default_factory=lambda: ["completed", "failed"])
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> 'JobRuntimeConfig':
|
||||
"""Create from dictionary with defaults."""
|
||||
if not data:
|
||||
return cls()
|
||||
return cls(
|
||||
enabled=data.get('enabled', False),
|
||||
timeout_sec=data.get('timeout_sec', DEFAULT_TIMEOUT_SEC),
|
||||
webhook_url=data.get('webhook_url'),
|
||||
idempotency_scope=data.get('idempotency_scope', 'none'),
|
||||
events=data.get('events', ['completed', 'failed']),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduleRuntimeConfig:
|
||||
"""Configuration for scheduled execution."""
|
||||
enabled: bool = False
|
||||
interval: str = "hourly" # hourly|daily|*/30m|seconds|cron-like
|
||||
max_retries: int = DEFAULT_MAX_RETRIES
|
||||
run_immediately: bool = False
|
||||
timeout_sec: int = DEFAULT_TIMEOUT_SEC
|
||||
max_cost_usd: float = DEFAULT_MAX_COST_USD
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> 'ScheduleRuntimeConfig':
|
||||
"""Create from dictionary with defaults."""
|
||||
if not data:
|
||||
return cls()
|
||||
return cls(
|
||||
enabled=data.get('enabled', False),
|
||||
interval=data.get('interval', 'hourly'),
|
||||
max_retries=data.get('max_retries', DEFAULT_MAX_RETRIES),
|
||||
run_immediately=data.get('run_immediately', False),
|
||||
timeout_sec=data.get('timeout_sec', DEFAULT_TIMEOUT_SEC),
|
||||
max_cost_usd=data.get('max_cost_usd', DEFAULT_MAX_COST_USD),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeConfig:
|
||||
"""
|
||||
Complete runtime configuration for a recipe.
|
||||
|
||||
Parsed from the optional `runtime` block in TEMPLATE.yaml:
|
||||
|
||||
```yaml
|
||||
runtime:
|
||||
background:
|
||||
enabled: false
|
||||
max_concurrent: 5
|
||||
cleanup_delay_sec: 3600
|
||||
job:
|
||||
enabled: false
|
||||
timeout_sec: 3600
|
||||
webhook_url: "${WEBHOOK_URL}"
|
||||
idempotency_scope: "session"
|
||||
events: ["completed", "failed"]
|
||||
schedule:
|
||||
enabled: false
|
||||
interval: "hourly"
|
||||
max_retries: 3
|
||||
run_immediately: true
|
||||
timeout_sec: 300
|
||||
max_cost_usd: 1.00
|
||||
```
|
||||
"""
|
||||
background: BackgroundRuntimeConfig = field(default_factory=BackgroundRuntimeConfig)
|
||||
job: JobRuntimeConfig = field(default_factory=JobRuntimeConfig)
|
||||
schedule: ScheduleRuntimeConfig = field(default_factory=ScheduleRuntimeConfig)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> 'RuntimeConfig':
|
||||
"""Create from dictionary with defaults."""
|
||||
if not data:
|
||||
return cls()
|
||||
return cls(
|
||||
background=BackgroundRuntimeConfig.from_dict(data.get('background')),
|
||||
job=JobRuntimeConfig.from_dict(data.get('job')),
|
||||
schedule=ScheduleRuntimeConfig.from_dict(data.get('schedule')),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
'background': {
|
||||
'enabled': self.background.enabled,
|
||||
'max_concurrent': self.background.max_concurrent,
|
||||
'cleanup_delay_sec': self.background.cleanup_delay_sec,
|
||||
},
|
||||
'job': {
|
||||
'enabled': self.job.enabled,
|
||||
'timeout_sec': self.job.timeout_sec,
|
||||
'webhook_url': self.job.webhook_url,
|
||||
'idempotency_scope': self.job.idempotency_scope,
|
||||
'events': self.job.events,
|
||||
},
|
||||
'schedule': {
|
||||
'enabled': self.schedule.enabled,
|
||||
'interval': self.schedule.interval,
|
||||
'max_retries': self.schedule.max_retries,
|
||||
'run_immediately': self.schedule.run_immediately,
|
||||
'timeout_sec': self.schedule.timeout_sec,
|
||||
'max_cost_usd': self.schedule.max_cost_usd,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def expand_env_vars(value: Any) -> Any:
|
||||
"""
|
||||
Safely expand environment variables in a value.
|
||||
|
||||
Only supports ${VAR} syntax for safety (no eval, no shell expansion).
|
||||
|
||||
Args:
|
||||
value: Value to expand (string, dict, or list)
|
||||
|
||||
Returns:
|
||||
Value with environment variables expanded
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
# Match ${VAR} pattern only
|
||||
pattern = r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}'
|
||||
|
||||
def replace_env(match):
|
||||
var_name = match.group(1)
|
||||
return os.environ.get(var_name, match.group(0)) # Keep original if not found
|
||||
|
||||
return re.sub(pattern, replace_env, value)
|
||||
|
||||
elif isinstance(value, dict):
|
||||
return {k: expand_env_vars(v) for k, v in value.items()}
|
||||
|
||||
elif isinstance(value, list):
|
||||
return [expand_env_vars(item) for item in value]
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def parse_runtime_config(
|
||||
raw_runtime: Optional[Dict[str, Any]],
|
||||
expand_env: bool = True
|
||||
) -> RuntimeConfig:
|
||||
"""
|
||||
Parse runtime configuration from raw YAML data.
|
||||
|
||||
Args:
|
||||
raw_runtime: Raw runtime dict from TEMPLATE.yaml
|
||||
expand_env: Whether to expand environment variables
|
||||
|
||||
Returns:
|
||||
Parsed RuntimeConfig with defaults applied
|
||||
"""
|
||||
if not raw_runtime:
|
||||
return RuntimeConfig()
|
||||
|
||||
# Expand environment variables if requested
|
||||
if expand_env:
|
||||
raw_runtime = expand_env_vars(raw_runtime)
|
||||
|
||||
return RuntimeConfig.from_dict(raw_runtime)
|
||||
@@ -0,0 +1,613 @@
|
||||
"""
|
||||
Recipe Registry HTTP Server
|
||||
|
||||
Provides a local HTTP server for recipe registry operations.
|
||||
Supports:
|
||||
- GET /healthz - Health check
|
||||
- GET /v1/recipes - List recipes (pagination)
|
||||
- GET /v1/recipes/{name} - Get recipe info (all versions)
|
||||
- GET /v1/recipes/{name}/{version} - Get specific version info
|
||||
- GET /v1/recipes/{name}/{version}/download - Download bundle
|
||||
- POST /v1/recipes/{name}/{version} - Publish bundle (multipart)
|
||||
- DELETE /v1/recipes/{name}/{version} - Delete version
|
||||
- GET /v1/search?q=... - Search recipes
|
||||
|
||||
Usage:
|
||||
from praisonai.recipe.server import create_app, run_server
|
||||
|
||||
# Run server
|
||||
run_server(host="127.0.0.1", port=7777)
|
||||
|
||||
# Or get ASGI app for custom deployment
|
||||
app = create_app(registry_path="~/.praison/registry")
|
||||
"""
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qs, unquote
|
||||
|
||||
from .registry import (
|
||||
LocalRegistry,
|
||||
RegistryError,
|
||||
RecipeNotFoundError,
|
||||
RecipeExistsError,
|
||||
RegistryAuthError,
|
||||
DEFAULT_REGISTRY_PATH,
|
||||
DEFAULT_REGISTRY_PORT,
|
||||
_calculate_checksum,
|
||||
_get_timestamp,
|
||||
)
|
||||
|
||||
|
||||
class RegistryServer:
|
||||
"""
|
||||
Simple HTTP server for recipe registry.
|
||||
|
||||
Uses only stdlib for minimal dependencies.
|
||||
For production, consider using with uvicorn/gunicorn.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry_path: Optional[Path] = None,
|
||||
token: Optional[str] = None,
|
||||
read_only: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize registry server.
|
||||
|
||||
Args:
|
||||
registry_path: Path to registry directory
|
||||
token: Required token for write operations (optional)
|
||||
read_only: If True, disable all write operations
|
||||
"""
|
||||
self.registry = LocalRegistry(registry_path)
|
||||
self.token = token or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
|
||||
self.read_only = read_only
|
||||
self._routes: List[Tuple[str, str, Callable]] = []
|
||||
self._setup_routes()
|
||||
|
||||
def _setup_routes(self):
|
||||
"""Setup URL routes."""
|
||||
self._routes = [
|
||||
("GET", r"^/healthz$", self._handle_health),
|
||||
("GET", r"^/v1/recipes$", self._handle_list_recipes),
|
||||
("GET", r"^/v1/recipes/([^/]+)$", self._handle_get_recipe),
|
||||
("GET", r"^/v1/recipes/([^/]+)/([^/]+)$", self._handle_get_version),
|
||||
("GET", r"^/v1/recipes/([^/]+)/([^/]+)/download$", self._handle_download),
|
||||
("POST", r"^/v1/recipes/([^/]+)/([^/]+)$", self._handle_publish),
|
||||
("DELETE", r"^/v1/recipes/([^/]+)/([^/]+)$", self._handle_delete_version),
|
||||
("DELETE", r"^/v1/recipes/([^/]+)$", self._handle_delete_recipe),
|
||||
("GET", r"^/v1/search$", self._handle_search),
|
||||
]
|
||||
|
||||
def _check_auth(self, headers: Dict[str, str]) -> bool:
|
||||
"""Check if request is authorized for write operations."""
|
||||
if not self.token:
|
||||
return True
|
||||
|
||||
auth = headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
return auth[7:] == self.token
|
||||
return False
|
||||
|
||||
def _json_response(
|
||||
self,
|
||||
data: Any,
|
||||
status: int = 200,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Create JSON response."""
|
||||
resp_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Registry-Version": "1.0",
|
||||
}
|
||||
if headers:
|
||||
resp_headers.update(headers)
|
||||
|
||||
body = json.dumps(data, indent=2).encode("utf-8")
|
||||
return status, resp_headers, body
|
||||
|
||||
def _error_response(
|
||||
self,
|
||||
message: str,
|
||||
status: int = 400,
|
||||
code: Optional[str] = None,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Create error response."""
|
||||
return self._json_response({
|
||||
"ok": False,
|
||||
"error": message,
|
||||
"code": code or "error",
|
||||
}, status=status)
|
||||
|
||||
def _file_response(
|
||||
self,
|
||||
file_path: Path,
|
||||
filename: str,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Create file download response."""
|
||||
with open(file_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
checksum = hashlib.sha256(content).hexdigest()
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/gzip",
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Length": str(len(content)),
|
||||
"ETag": f'"{checksum[:16]}"',
|
||||
"X-Checksum-SHA256": checksum,
|
||||
}
|
||||
|
||||
return 200, headers, content
|
||||
|
||||
def _parse_multipart(
|
||||
self,
|
||||
body: bytes,
|
||||
content_type: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Parse multipart form data."""
|
||||
# Extract boundary
|
||||
boundary = None
|
||||
for part in content_type.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("boundary="):
|
||||
boundary = part[9:].strip('"')
|
||||
break
|
||||
|
||||
if not boundary:
|
||||
raise ValueError("Missing boundary in multipart data")
|
||||
|
||||
result = {"fields": {}, "files": {}}
|
||||
boundary_bytes = f"--{boundary}".encode()
|
||||
|
||||
parts = body.split(boundary_bytes)
|
||||
for part in parts[1:]: # Skip preamble
|
||||
if part.startswith(b"--"):
|
||||
break # End marker
|
||||
|
||||
# Split headers and content
|
||||
if b"\r\n\r\n" in part:
|
||||
header_section, content = part.split(b"\r\n\r\n", 1)
|
||||
else:
|
||||
continue
|
||||
|
||||
# Parse headers
|
||||
headers = {}
|
||||
for line in header_section.decode("utf-8", errors="ignore").split("\r\n"):
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
headers[key.strip().lower()] = value.strip()
|
||||
|
||||
# Get field name and filename
|
||||
disposition = headers.get("content-disposition", "")
|
||||
name = None
|
||||
filename = None
|
||||
|
||||
for item in disposition.split(";"):
|
||||
item = item.strip()
|
||||
if item.startswith("name="):
|
||||
name = item[5:].strip('"')
|
||||
elif item.startswith("filename="):
|
||||
filename = item[9:].strip('"')
|
||||
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# Remove trailing \r\n
|
||||
content = content.rstrip(b"\r\n")
|
||||
|
||||
if filename:
|
||||
result["files"][name] = {
|
||||
"filename": filename,
|
||||
"content": content,
|
||||
"content_type": headers.get("content-type", "application/octet-stream"),
|
||||
}
|
||||
else:
|
||||
result["fields"][name] = content.decode("utf-8", errors="ignore")
|
||||
|
||||
return result
|
||||
|
||||
def handle_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
headers: Dict[str, str],
|
||||
body: bytes,
|
||||
query_string: str = "",
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""
|
||||
Handle HTTP request.
|
||||
|
||||
Args:
|
||||
method: HTTP method
|
||||
path: URL path
|
||||
headers: Request headers
|
||||
body: Request body
|
||||
query_string: Query string
|
||||
|
||||
Returns:
|
||||
Tuple of (status_code, headers, body)
|
||||
"""
|
||||
# Parse query string
|
||||
query = parse_qs(query_string)
|
||||
|
||||
# Find matching route
|
||||
for route_method, pattern, handler in self._routes:
|
||||
if method != route_method:
|
||||
continue
|
||||
|
||||
match = re.match(pattern, path)
|
||||
if match:
|
||||
try:
|
||||
return handler(
|
||||
headers=headers,
|
||||
body=body,
|
||||
query=query,
|
||||
path_params=match.groups(),
|
||||
)
|
||||
except RecipeNotFoundError as e:
|
||||
return self._error_response(str(e), status=404, code="not_found")
|
||||
except RecipeExistsError as e:
|
||||
return self._error_response(str(e), status=409, code="conflict")
|
||||
except RegistryAuthError as e:
|
||||
return self._error_response(str(e), status=401, code="auth_error")
|
||||
except RegistryError as e:
|
||||
return self._error_response(str(e), status=400, code="registry_error")
|
||||
except Exception as e:
|
||||
return self._error_response(f"Internal error: {e}", status=500, code="internal_error")
|
||||
|
||||
return self._error_response("Not found", status=404, code="not_found")
|
||||
|
||||
def _handle_health(self, **kwargs) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle GET /healthz."""
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
"status": "healthy",
|
||||
"timestamp": _get_timestamp(),
|
||||
"read_only": self.read_only,
|
||||
"auth_required": bool(self.token),
|
||||
})
|
||||
|
||||
def _handle_list_recipes(
|
||||
self,
|
||||
query: Dict[str, List[str]],
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle GET /v1/recipes."""
|
||||
page = int(query.get("page", ["1"])[0])
|
||||
per_page = min(int(query.get("per_page", ["50"])[0]), 100)
|
||||
tags = query.get("tags", [""])[0].split(",") if query.get("tags") else None
|
||||
|
||||
if tags and tags[0] == "":
|
||||
tags = None
|
||||
|
||||
recipes = self.registry.list_recipes(tags=tags)
|
||||
|
||||
# Paginate
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
paginated = recipes[start:end]
|
||||
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
"recipes": paginated,
|
||||
"total": len(recipes),
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"has_more": end < len(recipes),
|
||||
})
|
||||
|
||||
def _handle_get_recipe(
|
||||
self,
|
||||
path_params: Tuple[str, ...],
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle GET /v1/recipes/{name}."""
|
||||
name = unquote(path_params[0])
|
||||
|
||||
versions = self.registry.get_versions(name)
|
||||
info = self.registry.get_info(name)
|
||||
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
"name": name,
|
||||
"versions": versions,
|
||||
"latest": info.get("version"),
|
||||
"description": info.get("description", ""),
|
||||
"tags": info.get("tags", []),
|
||||
})
|
||||
|
||||
def _handle_get_version(
|
||||
self,
|
||||
path_params: Tuple[str, ...],
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle GET /v1/recipes/{name}/{version}."""
|
||||
name = unquote(path_params[0])
|
||||
version = unquote(path_params[1])
|
||||
|
||||
info = self.registry.get_info(name, version)
|
||||
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
**info,
|
||||
})
|
||||
|
||||
def _handle_download(
|
||||
self,
|
||||
path_params: Tuple[str, ...],
|
||||
headers: Dict[str, str],
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle GET /v1/recipes/{name}/{version}/download."""
|
||||
name = unquote(path_params[0])
|
||||
version = unquote(path_params[1])
|
||||
|
||||
# Get bundle path
|
||||
bundle_name = f"{name}-{version}.praison"
|
||||
bundle_path = self.registry.recipes_path / name / version / bundle_name
|
||||
|
||||
if not bundle_path.exists():
|
||||
raise RecipeNotFoundError(f"Bundle not found: {name}@{version}")
|
||||
|
||||
# Check ETag for caching
|
||||
checksum = _calculate_checksum(bundle_path)
|
||||
etag = f'"{checksum[:16]}"'
|
||||
|
||||
if_none_match = headers.get("If-None-Match", "")
|
||||
if if_none_match == etag:
|
||||
return 304, {"ETag": etag}, b""
|
||||
|
||||
return self._file_response(bundle_path, bundle_name)
|
||||
|
||||
def _handle_publish(
|
||||
self,
|
||||
path_params: Tuple[str, ...],
|
||||
headers: Dict[str, str],
|
||||
body: bytes,
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle POST /v1/recipes/{name}/{version}."""
|
||||
if self.read_only:
|
||||
return self._error_response("Registry is read-only", status=403, code="read_only")
|
||||
|
||||
if not self._check_auth(headers):
|
||||
return self._error_response("Authentication required", status=401, code="auth_required")
|
||||
|
||||
name = unquote(path_params[0])
|
||||
version = unquote(path_params[1])
|
||||
|
||||
# Parse multipart data
|
||||
content_type = headers.get("Content-Type", "")
|
||||
if not content_type.startswith("multipart/form-data"):
|
||||
return self._error_response("Expected multipart/form-data", status=400)
|
||||
|
||||
try:
|
||||
data = self._parse_multipart(body, content_type)
|
||||
except ValueError as e:
|
||||
return self._error_response(f"Invalid multipart data: {e}", status=400)
|
||||
|
||||
if "bundle" not in data["files"]:
|
||||
return self._error_response("Missing bundle file", status=400)
|
||||
|
||||
force = data["fields"].get("force", "false").lower() == "true"
|
||||
bundle_content = data["files"]["bundle"]["content"]
|
||||
|
||||
# Save to temp file and publish
|
||||
with tempfile.NamedTemporaryFile(suffix=".praison", delete=False) as tmp:
|
||||
tmp.write(bundle_content)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
result = self.registry.publish(tmp_path, force=force)
|
||||
|
||||
# Verify name/version match
|
||||
if result["name"] != name or result["version"] != version:
|
||||
# Rollback
|
||||
self.registry.delete(result["name"], result["version"])
|
||||
return self._error_response(
|
||||
f"Bundle name/version ({result['name']}@{result['version']}) "
|
||||
f"doesn't match URL ({name}@{version})",
|
||||
status=400,
|
||||
)
|
||||
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
**result,
|
||||
}, status=201)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
def _handle_delete_version(
|
||||
self,
|
||||
path_params: Tuple[str, ...],
|
||||
headers: Dict[str, str],
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle DELETE /v1/recipes/{name}/{version}."""
|
||||
if self.read_only:
|
||||
return self._error_response("Registry is read-only", status=403, code="read_only")
|
||||
|
||||
if not self._check_auth(headers):
|
||||
return self._error_response("Authentication required", status=401, code="auth_required")
|
||||
|
||||
name = unquote(path_params[0])
|
||||
version = unquote(path_params[1])
|
||||
|
||||
self.registry.delete(name, version)
|
||||
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
"deleted": f"{name}@{version}",
|
||||
})
|
||||
|
||||
def _handle_delete_recipe(
|
||||
self,
|
||||
path_params: Tuple[str, ...],
|
||||
headers: Dict[str, str],
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle DELETE /v1/recipes/{name}."""
|
||||
if self.read_only:
|
||||
return self._error_response("Registry is read-only", status=403, code="read_only")
|
||||
|
||||
if not self._check_auth(headers):
|
||||
return self._error_response("Authentication required", status=401, code="auth_required")
|
||||
|
||||
name = unquote(path_params[0])
|
||||
|
||||
self.registry.delete(name)
|
||||
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
"deleted": name,
|
||||
})
|
||||
|
||||
def _handle_search(
|
||||
self,
|
||||
query: Dict[str, List[str]],
|
||||
**kwargs,
|
||||
) -> Tuple[int, Dict[str, str], bytes]:
|
||||
"""Handle GET /v1/search."""
|
||||
q = query.get("q", [""])[0]
|
||||
tags = query.get("tags", [""])[0].split(",") if query.get("tags") else None
|
||||
|
||||
if tags and tags[0] == "":
|
||||
tags = None
|
||||
|
||||
if not q:
|
||||
return self._error_response("Query parameter 'q' required", status=400)
|
||||
|
||||
results = self.registry.search(q, tags=tags)
|
||||
|
||||
return self._json_response({
|
||||
"ok": True,
|
||||
"query": q,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
})
|
||||
|
||||
|
||||
def create_wsgi_app(
|
||||
registry_path: Optional[Path] = None,
|
||||
token: Optional[str] = None,
|
||||
read_only: bool = False,
|
||||
) -> Callable:
|
||||
"""
|
||||
Create WSGI application for the registry server.
|
||||
|
||||
Args:
|
||||
registry_path: Path to registry directory
|
||||
token: Required token for write operations
|
||||
read_only: If True, disable write operations
|
||||
|
||||
Returns:
|
||||
WSGI application callable
|
||||
"""
|
||||
server = RegistryServer(
|
||||
registry_path=registry_path,
|
||||
token=token,
|
||||
read_only=read_only,
|
||||
)
|
||||
|
||||
def app(environ, start_response):
|
||||
method = environ.get("REQUEST_METHOD", "GET")
|
||||
path = environ.get("PATH_INFO", "/")
|
||||
query_string = environ.get("QUERY_STRING", "")
|
||||
|
||||
# Read headers
|
||||
headers = {}
|
||||
for key, value in environ.items():
|
||||
if key.startswith("HTTP_"):
|
||||
header_name = key[5:].replace("_", "-").title()
|
||||
headers[header_name] = value
|
||||
headers["Content-Type"] = environ.get("CONTENT_TYPE", "")
|
||||
|
||||
# Read body
|
||||
try:
|
||||
content_length = int(environ.get("CONTENT_LENGTH", 0))
|
||||
except (ValueError, TypeError):
|
||||
content_length = 0
|
||||
|
||||
body = environ["wsgi.input"].read(content_length) if content_length > 0 else b""
|
||||
|
||||
# Handle request
|
||||
status_code, resp_headers, resp_body = server.handle_request(
|
||||
method=method,
|
||||
path=path,
|
||||
headers=headers,
|
||||
body=body,
|
||||
query_string=query_string,
|
||||
)
|
||||
|
||||
# Send response
|
||||
status = f"{status_code} {'OK' if status_code < 400 else 'Error'}"
|
||||
response_headers = [(k, v) for k, v in resp_headers.items()]
|
||||
start_response(status, response_headers)
|
||||
|
||||
return [resp_body]
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def run_server(
|
||||
host: str = "127.0.0.1",
|
||||
port: int = DEFAULT_REGISTRY_PORT,
|
||||
registry_path: Optional[Path] = None,
|
||||
token: Optional[str] = None,
|
||||
read_only: bool = False,
|
||||
):
|
||||
"""
|
||||
Run the registry server using stdlib wsgiref.
|
||||
|
||||
Args:
|
||||
host: Host to bind to
|
||||
port: Port to bind to
|
||||
registry_path: Path to registry directory
|
||||
token: Required token for write operations
|
||||
read_only: If True, disable write operations
|
||||
"""
|
||||
from wsgiref.simple_server import make_server, WSGIRequestHandler
|
||||
|
||||
# Custom handler to suppress logs unless verbose
|
||||
class QuietHandler(WSGIRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
pass # Suppress default logging
|
||||
|
||||
app = create_wsgi_app(
|
||||
registry_path=registry_path,
|
||||
token=token,
|
||||
read_only=read_only,
|
||||
)
|
||||
|
||||
server = make_server(host, port, app, handler_class=QuietHandler)
|
||||
|
||||
print(f"Recipe Registry Server running on http://{host}:{port}")
|
||||
print(f"Registry path: {registry_path or DEFAULT_REGISTRY_PATH}")
|
||||
if token:
|
||||
print("Authentication: enabled (token required for writes)")
|
||||
if read_only:
|
||||
print("Mode: read-only")
|
||||
print("\nEndpoints:")
|
||||
print(" GET /healthz - Health check")
|
||||
print(" GET /v1/recipes - List recipes")
|
||||
print(" GET /v1/recipes/{name} - Get recipe info")
|
||||
print(" GET /v1/recipes/{name}/{version} - Get version info")
|
||||
print(" GET /v1/recipes/{name}/{version}/download - Download bundle")
|
||||
print(" POST /v1/recipes/{name}/{version} - Publish bundle")
|
||||
print(" DELETE /v1/recipes/{name}/{version} - Delete version")
|
||||
print(" GET /v1/search?q=... - Search recipes")
|
||||
print("\nPress Ctrl+C to stop")
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down...")
|
||||
server.shutdown()
|
||||
@@ -436,6 +436,103 @@ class AgentScheduler:
|
||||
return self.start(interval, max_retries, run_immediately)
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_recipe(
|
||||
cls,
|
||||
recipe_name: str,
|
||||
*,
|
||||
input_data: Any = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
interval_override: Optional[str] = None,
|
||||
max_retries_override: Optional[int] = None,
|
||||
timeout_override: Optional[int] = None,
|
||||
max_cost_override: Optional[float] = None,
|
||||
on_success: Optional[Callable] = None,
|
||||
on_failure: Optional[Callable] = None
|
||||
) -> 'AgentScheduler':
|
||||
"""
|
||||
Create AgentScheduler from a recipe name.
|
||||
|
||||
Args:
|
||||
recipe_name: Name of the recipe to schedule
|
||||
input_data: Input data for the recipe
|
||||
config: Configuration overrides for the recipe
|
||||
interval_override: Override schedule interval from recipe runtime config
|
||||
max_retries_override: Override max_retries from recipe runtime config
|
||||
timeout_override: Override timeout from recipe runtime config
|
||||
max_cost_override: Override max_cost from recipe runtime config
|
||||
on_success: Callback function on successful execution
|
||||
on_failure: Callback function on failed execution
|
||||
|
||||
Returns:
|
||||
Configured AgentScheduler instance
|
||||
|
||||
Example:
|
||||
scheduler = AgentScheduler.from_recipe("news-monitor")
|
||||
scheduler.start(schedule_expr="hourly")
|
||||
"""
|
||||
from praisonai.recipe.bridge import resolve, execute_resolved_recipe, get_recipe_task_description
|
||||
|
||||
# Resolve the recipe
|
||||
resolved = resolve(
|
||||
recipe_name,
|
||||
input_data=input_data,
|
||||
config=config or {},
|
||||
options={'timeout_sec': timeout_override or 300},
|
||||
)
|
||||
|
||||
# Get runtime config defaults from recipe
|
||||
interval = interval_override or "hourly"
|
||||
max_retries = max_retries_override if max_retries_override is not None else 3
|
||||
timeout = timeout_override or 300
|
||||
max_cost = max_cost_override if max_cost_override is not None else 1.00
|
||||
|
||||
runtime = resolved.runtime_config
|
||||
if runtime and hasattr(runtime, 'schedule'):
|
||||
sched_config = runtime.schedule
|
||||
interval = interval_override or sched_config.interval
|
||||
max_retries = max_retries_override if max_retries_override is not None else sched_config.max_retries
|
||||
timeout = timeout_override or sched_config.timeout_sec
|
||||
max_cost = max_cost_override if max_cost_override is not None else sched_config.max_cost_usd
|
||||
|
||||
# Create a recipe executor agent wrapper
|
||||
class RecipeExecutorAgent:
|
||||
"""Wrapper that makes a recipe look like an agent for the scheduler."""
|
||||
def __init__(self, resolved_recipe):
|
||||
self.resolved = resolved_recipe
|
||||
self.name = f"RecipeAgent:{resolved_recipe.name}"
|
||||
|
||||
def start(self, task: str) -> Any:
|
||||
return execute_resolved_recipe(self.resolved)
|
||||
|
||||
# Create the agent wrapper
|
||||
agent = RecipeExecutorAgent(resolved)
|
||||
task = get_recipe_task_description(resolved)
|
||||
|
||||
# Create scheduler instance
|
||||
scheduler = cls(
|
||||
agent=agent,
|
||||
task=task,
|
||||
timeout=timeout,
|
||||
max_cost=max_cost,
|
||||
on_success=on_success,
|
||||
on_failure=on_failure,
|
||||
)
|
||||
|
||||
# Store recipe metadata and schedule config
|
||||
scheduler._recipe_name = recipe_name
|
||||
scheduler._recipe_resolved = resolved
|
||||
scheduler._yaml_schedule_config = {
|
||||
'interval': interval,
|
||||
'max_retries': max_retries,
|
||||
'run_immediately': False,
|
||||
'timeout': timeout,
|
||||
'max_cost': max_cost,
|
||||
}
|
||||
|
||||
return scheduler
|
||||
|
||||
|
||||
def create_agent_scheduler(
|
||||
agent,
|
||||
task: str,
|
||||
|
||||
@@ -69,8 +69,9 @@ class DaemonManager:
|
||||
def start_scheduler_daemon(
|
||||
self,
|
||||
name: str,
|
||||
task: str,
|
||||
interval: str,
|
||||
task: Optional[str] = None,
|
||||
recipe_name: Optional[str] = None,
|
||||
interval: str = "hourly",
|
||||
max_cost: Optional[float] = None,
|
||||
timeout: Optional[int] = None,
|
||||
max_retries: int = 3
|
||||
@@ -80,7 +81,8 @@ class DaemonManager:
|
||||
|
||||
Args:
|
||||
name: Scheduler name
|
||||
task: Task to schedule
|
||||
task: Task to schedule (mutually exclusive with recipe_name)
|
||||
recipe_name: Recipe name to schedule (mutually exclusive with task)
|
||||
interval: Schedule interval
|
||||
max_cost: Maximum cost budget
|
||||
timeout: Timeout per execution
|
||||
@@ -95,18 +97,26 @@ class DaemonManager:
|
||||
"-m",
|
||||
"praisonai.cli.main",
|
||||
"schedule",
|
||||
task,
|
||||
"--interval", interval,
|
||||
"--max-retries", str(max_retries)
|
||||
]
|
||||
|
||||
# Add task or recipe
|
||||
if recipe_name:
|
||||
command.append(recipe_name) # Recipe name will be auto-detected
|
||||
elif task:
|
||||
command.append(task)
|
||||
else:
|
||||
raise ValueError("Either task or recipe_name must be provided")
|
||||
|
||||
command.extend(["--interval", interval, "--max-retries", str(max_retries)])
|
||||
|
||||
if timeout:
|
||||
command.extend(["--timeout", str(timeout)])
|
||||
|
||||
if max_cost:
|
||||
command.extend(["--max-cost", str(max_cost)])
|
||||
|
||||
return self.start_daemon(name, task, interval, command)
|
||||
display_task = recipe_name or task
|
||||
return self.start_daemon(name, display_task, interval, command)
|
||||
|
||||
def stop_daemon(self, pid: int, timeout: int = 10) -> bool:
|
||||
"""
|
||||
|
||||
@@ -47,6 +47,9 @@ class TemplateConfig:
|
||||
# CLI integration
|
||||
cli: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Runtime configuration (background/job/schedule)
|
||||
runtime: Optional[Any] = None # RuntimeConfig, lazy loaded
|
||||
|
||||
# Raw config dict
|
||||
raw: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@@ -162,6 +165,12 @@ class TemplateLoader:
|
||||
# Handle agents - can be inline list or file reference string
|
||||
agents = raw.get("agents", "agents.yaml")
|
||||
|
||||
# Parse runtime configuration (lazy import to avoid circular deps)
|
||||
runtime = None
|
||||
if "runtime" in raw:
|
||||
from praisonai.recipe.runtime import parse_runtime_config
|
||||
runtime = parse_runtime_config(raw.get("runtime"), expand_env=True)
|
||||
|
||||
return TemplateConfig(
|
||||
name=raw.get("name", template_dir.name),
|
||||
description=raw.get("description", ""),
|
||||
@@ -176,6 +185,7 @@ class TemplateLoader:
|
||||
defaults=raw.get("defaults", {}),
|
||||
skills=raw.get("skills", []),
|
||||
cli=raw.get("cli", {}),
|
||||
runtime=runtime,
|
||||
raw=raw,
|
||||
path=template_dir
|
||||
)
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.7.5"
|
||||
__version__ = "2.7.6"
|
||||
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
Integration tests for recipe registry HTTP server.
|
||||
|
||||
Tests the full HTTP server with real requests.
|
||||
"""
|
||||
|
||||
import json
|
||||
import tarfile
|
||||
import pytest
|
||||
|
||||
from praisonai.recipe.server import RegistryServer, create_wsgi_app
|
||||
|
||||
|
||||
class TestRegistryServer:
|
||||
"""Test RegistryServer class."""
|
||||
|
||||
@pytest.fixture
|
||||
def registry_path(self, tmp_path):
|
||||
"""Create temporary registry path."""
|
||||
return tmp_path / "registry"
|
||||
|
||||
@pytest.fixture
|
||||
def server(self, registry_path):
|
||||
"""Create a RegistryServer instance."""
|
||||
return RegistryServer(registry_path=registry_path)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_bundle(self, tmp_path):
|
||||
"""Create a sample .praison bundle."""
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
|
||||
manifest = {
|
||||
"name": "test-recipe",
|
||||
"version": "1.0.0",
|
||||
"description": "A test recipe",
|
||||
"tags": ["test", "sample"],
|
||||
"author": "test",
|
||||
"files": ["recipe.yaml"],
|
||||
}
|
||||
|
||||
(bundle_dir / "manifest.json").write_text(json.dumps(manifest))
|
||||
(bundle_dir / "recipe.yaml").write_text("name: test")
|
||||
|
||||
bundle_path = tmp_path / "test-recipe-1.0.0.praison"
|
||||
with tarfile.open(bundle_path, "w:gz") as tar:
|
||||
tar.add(bundle_dir / "manifest.json", arcname="manifest.json")
|
||||
tar.add(bundle_dir / "recipe.yaml", arcname="recipe.yaml")
|
||||
|
||||
return bundle_path
|
||||
|
||||
def test_health_endpoint(self, server):
|
||||
"""Test GET /healthz."""
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/healthz",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
data = json.loads(body)
|
||||
assert data["ok"] is True
|
||||
assert data["status"] == "healthy"
|
||||
|
||||
def test_list_recipes_empty(self, server):
|
||||
"""Test GET /v1/recipes with empty registry."""
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
data = json.loads(body)
|
||||
assert data["ok"] is True
|
||||
assert data["recipes"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_recipes_with_data(self, server, sample_bundle):
|
||||
"""Test GET /v1/recipes with recipes."""
|
||||
# First publish a recipe
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
data = json.loads(body)
|
||||
assert data["total"] == 1
|
||||
assert data["recipes"][0]["name"] == "test-recipe"
|
||||
|
||||
def test_get_recipe_info(self, server, sample_bundle):
|
||||
"""Test GET /v1/recipes/{name}."""
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes/test-recipe",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
data = json.loads(body)
|
||||
assert data["name"] == "test-recipe"
|
||||
assert "1.0.0" in data["versions"]
|
||||
|
||||
def test_get_recipe_not_found(self, server):
|
||||
"""Test GET /v1/recipes/{name} for non-existent recipe."""
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes/nonexistent",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 404
|
||||
data = json.loads(body)
|
||||
assert data["ok"] is False
|
||||
|
||||
def test_get_version_info(self, server, sample_bundle):
|
||||
"""Test GET /v1/recipes/{name}/{version}."""
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes/test-recipe/1.0.0",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
data = json.loads(body)
|
||||
assert data["name"] == "test-recipe"
|
||||
assert data["version"] == "1.0.0"
|
||||
|
||||
def test_download_bundle(self, server, sample_bundle):
|
||||
"""Test GET /v1/recipes/{name}/{version}/download."""
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes/test-recipe/1.0.0/download",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
assert headers["Content-Type"] == "application/gzip"
|
||||
assert "X-Checksum-SHA256" in headers
|
||||
assert len(body) > 0
|
||||
|
||||
def test_download_with_etag(self, server, sample_bundle):
|
||||
"""Test ETag caching for downloads."""
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
# First request
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes/test-recipe/1.0.0/download",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
etag = headers.get("ETag")
|
||||
assert etag is not None
|
||||
|
||||
# Second request with If-None-Match
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/recipes/test-recipe/1.0.0/download",
|
||||
headers={"If-None-Match": etag},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 304 # Not Modified
|
||||
|
||||
def test_search_recipes(self, server, sample_bundle):
|
||||
"""Test GET /v1/search."""
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/search",
|
||||
headers={},
|
||||
body=b"",
|
||||
query_string="q=test",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
data = json.loads(body)
|
||||
assert data["ok"] is True
|
||||
assert len(data["results"]) == 1
|
||||
|
||||
def test_search_no_query(self, server):
|
||||
"""Test GET /v1/search without query."""
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/v1/search",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 400
|
||||
data = json.loads(body)
|
||||
assert data["ok"] is False
|
||||
|
||||
def test_publish_requires_auth(self, registry_path, sample_bundle):
|
||||
"""Test POST /v1/recipes/{name}/{version} requires auth when token set."""
|
||||
server = RegistryServer(registry_path=registry_path, token="secret")
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="POST",
|
||||
path="/v1/recipes/test-recipe/1.0.0",
|
||||
headers={"Content-Type": "multipart/form-data; boundary=----test"},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 401
|
||||
|
||||
def test_publish_with_auth(self, registry_path, sample_bundle):
|
||||
"""Test POST /v1/recipes/{name}/{version} with valid auth."""
|
||||
server = RegistryServer(registry_path=registry_path, token="secret")
|
||||
|
||||
# Build multipart body
|
||||
boundary = "----test"
|
||||
bundle_content = sample_bundle.read_bytes()
|
||||
|
||||
body_parts = [
|
||||
f"--{boundary}".encode(),
|
||||
b'Content-Disposition: form-data; name="force"',
|
||||
b"",
|
||||
b"false",
|
||||
f"--{boundary}".encode(),
|
||||
f'Content-Disposition: form-data; name="bundle"; filename="test.praison"'.encode(),
|
||||
b"Content-Type: application/gzip",
|
||||
b"",
|
||||
bundle_content,
|
||||
f"--{boundary}--".encode(),
|
||||
b"",
|
||||
]
|
||||
body = b"\r\n".join(body_parts)
|
||||
|
||||
status, headers, body_response = server.handle_request(
|
||||
method="POST",
|
||||
path="/v1/recipes/test-recipe/1.0.0",
|
||||
headers={
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Authorization": "Bearer secret",
|
||||
},
|
||||
body=body,
|
||||
)
|
||||
|
||||
assert status == 201
|
||||
data = json.loads(body_response)
|
||||
assert data["ok"] is True
|
||||
assert data["name"] == "test-recipe"
|
||||
|
||||
def test_delete_requires_auth(self, registry_path, sample_bundle):
|
||||
"""Test DELETE /v1/recipes/{name}/{version} requires auth."""
|
||||
server = RegistryServer(registry_path=registry_path, token="secret")
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="DELETE",
|
||||
path="/v1/recipes/test-recipe/1.0.0",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 401
|
||||
|
||||
def test_delete_with_auth(self, registry_path, sample_bundle):
|
||||
"""Test DELETE /v1/recipes/{name}/{version} with valid auth."""
|
||||
server = RegistryServer(registry_path=registry_path, token="secret")
|
||||
server.registry.publish(sample_bundle)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="DELETE",
|
||||
path="/v1/recipes/test-recipe/1.0.0",
|
||||
headers={"Authorization": "Bearer secret"},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
data = json.loads(body)
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_read_only_mode(self, registry_path, sample_bundle):
|
||||
"""Test read-only mode blocks writes."""
|
||||
server = RegistryServer(registry_path=registry_path, read_only=True)
|
||||
|
||||
status, headers, body = server.handle_request(
|
||||
method="POST",
|
||||
path="/v1/recipes/test-recipe/1.0.0",
|
||||
headers={"Content-Type": "multipart/form-data; boundary=----test"},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 403
|
||||
data = json.loads(body)
|
||||
assert "read-only" in data["error"].lower()
|
||||
|
||||
def test_not_found_route(self, server):
|
||||
"""Test 404 for unknown routes."""
|
||||
status, headers, body = server.handle_request(
|
||||
method="GET",
|
||||
path="/unknown",
|
||||
headers={},
|
||||
body=b"",
|
||||
)
|
||||
|
||||
assert status == 404
|
||||
|
||||
|
||||
class TestWSGIApp:
|
||||
"""Test WSGI application."""
|
||||
|
||||
@pytest.fixture
|
||||
def app(self, tmp_path):
|
||||
"""Create WSGI app."""
|
||||
return create_wsgi_app(registry_path=tmp_path / "registry")
|
||||
|
||||
def test_wsgi_health(self, app):
|
||||
"""Test WSGI app health endpoint."""
|
||||
environ = {
|
||||
"REQUEST_METHOD": "GET",
|
||||
"PATH_INFO": "/healthz",
|
||||
"QUERY_STRING": "",
|
||||
"CONTENT_LENGTH": "0",
|
||||
"wsgi.input": MockInput(b""),
|
||||
}
|
||||
|
||||
response_started = []
|
||||
def start_response(status, headers):
|
||||
response_started.append((status, headers))
|
||||
|
||||
result = list(app(environ, start_response))
|
||||
|
||||
assert len(response_started) == 1
|
||||
assert "200" in response_started[0][0]
|
||||
|
||||
body = b"".join(result)
|
||||
data = json.loads(body)
|
||||
assert data["ok"] is True
|
||||
|
||||
|
||||
class MockInput:
|
||||
"""Mock WSGI input stream."""
|
||||
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
self.pos = 0
|
||||
|
||||
def read(self, size=-1):
|
||||
if size < 0:
|
||||
result = self.data[self.pos:]
|
||||
self.pos = len(self.data)
|
||||
else:
|
||||
result = self.data[self.pos:self.pos + size]
|
||||
self.pos += size
|
||||
return result
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Unit tests for Recipe Runtime Configuration.
|
||||
|
||||
Tests:
|
||||
- RuntimeConfig parsing from dict
|
||||
- Environment variable expansion
|
||||
- Default values
|
||||
- Schema validation
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestRuntimeConfig:
|
||||
"""Tests for RuntimeConfig dataclass."""
|
||||
|
||||
def test_empty_runtime_config(self):
|
||||
"""Test creating RuntimeConfig with no data."""
|
||||
from praisonai.recipe.runtime import RuntimeConfig
|
||||
|
||||
config = RuntimeConfig()
|
||||
|
||||
assert config.background.enabled is False
|
||||
assert config.job.enabled is False
|
||||
assert config.schedule.enabled is False
|
||||
|
||||
def test_runtime_config_from_dict(self):
|
||||
"""Test creating RuntimeConfig from dictionary."""
|
||||
from praisonai.recipe.runtime import RuntimeConfig
|
||||
|
||||
data = {
|
||||
'background': {
|
||||
'enabled': True,
|
||||
'max_concurrent': 10,
|
||||
},
|
||||
'job': {
|
||||
'enabled': True,
|
||||
'timeout_sec': 600,
|
||||
'webhook_url': 'https://example.com/webhook',
|
||||
},
|
||||
'schedule': {
|
||||
'enabled': True,
|
||||
'interval': 'daily',
|
||||
'max_retries': 5,
|
||||
},
|
||||
}
|
||||
|
||||
config = RuntimeConfig.from_dict(data)
|
||||
|
||||
assert config.background.enabled is True
|
||||
assert config.background.max_concurrent == 10
|
||||
assert config.job.enabled is True
|
||||
assert config.job.timeout_sec == 600
|
||||
assert config.job.webhook_url == 'https://example.com/webhook'
|
||||
assert config.schedule.enabled is True
|
||||
assert config.schedule.interval == 'daily'
|
||||
assert config.schedule.max_retries == 5
|
||||
|
||||
def test_runtime_config_defaults(self):
|
||||
"""Test that defaults are applied correctly."""
|
||||
from praisonai.recipe.runtime import (
|
||||
RuntimeConfig,
|
||||
DEFAULT_TIMEOUT_SEC,
|
||||
DEFAULT_MAX_COST_USD,
|
||||
DEFAULT_MAX_RETRIES,
|
||||
DEFAULT_MAX_CONCURRENT,
|
||||
)
|
||||
|
||||
config = RuntimeConfig.from_dict({})
|
||||
|
||||
assert config.background.max_concurrent == DEFAULT_MAX_CONCURRENT
|
||||
assert config.job.timeout_sec == DEFAULT_TIMEOUT_SEC
|
||||
assert config.schedule.max_retries == DEFAULT_MAX_RETRIES
|
||||
assert config.schedule.max_cost_usd == DEFAULT_MAX_COST_USD
|
||||
|
||||
def test_runtime_config_to_dict(self):
|
||||
"""Test converting RuntimeConfig to dictionary."""
|
||||
from praisonai.recipe.runtime import RuntimeConfig
|
||||
|
||||
config = RuntimeConfig()
|
||||
data = config.to_dict()
|
||||
|
||||
assert 'background' in data
|
||||
assert 'job' in data
|
||||
assert 'schedule' in data
|
||||
assert data['background']['enabled'] is False
|
||||
assert data['job']['enabled'] is False
|
||||
assert data['schedule']['enabled'] is False
|
||||
|
||||
|
||||
class TestEnvVarExpansion:
|
||||
"""Tests for environment variable expansion."""
|
||||
|
||||
def test_expand_simple_env_var(self):
|
||||
"""Test expanding a simple environment variable."""
|
||||
from praisonai.recipe.runtime import expand_env_vars
|
||||
|
||||
with patch.dict(os.environ, {'MY_VAR': 'test_value'}):
|
||||
result = expand_env_vars('${MY_VAR}')
|
||||
assert result == 'test_value'
|
||||
|
||||
def test_expand_env_var_in_string(self):
|
||||
"""Test expanding env var embedded in string."""
|
||||
from praisonai.recipe.runtime import expand_env_vars
|
||||
|
||||
with patch.dict(os.environ, {'API_KEY': 'secret123'}):
|
||||
result = expand_env_vars('Bearer ${API_KEY}')
|
||||
assert result == 'Bearer secret123'
|
||||
|
||||
def test_expand_missing_env_var(self):
|
||||
"""Test that missing env vars are kept as-is."""
|
||||
from praisonai.recipe.runtime import expand_env_vars
|
||||
|
||||
result = expand_env_vars('${NONEXISTENT_VAR}')
|
||||
assert result == '${NONEXISTENT_VAR}'
|
||||
|
||||
def test_expand_env_var_in_dict(self):
|
||||
"""Test expanding env vars in nested dict."""
|
||||
from praisonai.recipe.runtime import expand_env_vars
|
||||
|
||||
with patch.dict(os.environ, {'WEBHOOK': 'https://example.com'}):
|
||||
data = {
|
||||
'url': '${WEBHOOK}/callback',
|
||||
'nested': {
|
||||
'value': '${WEBHOOK}/nested'
|
||||
}
|
||||
}
|
||||
result = expand_env_vars(data)
|
||||
|
||||
assert result['url'] == 'https://example.com/callback'
|
||||
assert result['nested']['value'] == 'https://example.com/nested'
|
||||
|
||||
def test_expand_env_var_in_list(self):
|
||||
"""Test expanding env vars in list."""
|
||||
from praisonai.recipe.runtime import expand_env_vars
|
||||
|
||||
with patch.dict(os.environ, {'VAL': 'expanded'}):
|
||||
data = ['${VAL}', 'static', '${VAL}_suffix']
|
||||
result = expand_env_vars(data)
|
||||
|
||||
assert result == ['expanded', 'static', 'expanded_suffix']
|
||||
|
||||
def test_no_expansion_for_non_string(self):
|
||||
"""Test that non-string values are returned as-is."""
|
||||
from praisonai.recipe.runtime import expand_env_vars
|
||||
|
||||
assert expand_env_vars(123) == 123
|
||||
assert expand_env_vars(True) is True
|
||||
assert expand_env_vars(None) is None
|
||||
|
||||
|
||||
class TestParseRuntimeConfig:
|
||||
"""Tests for parse_runtime_config function."""
|
||||
|
||||
def test_parse_none(self):
|
||||
"""Test parsing None returns default config."""
|
||||
from praisonai.recipe.runtime import parse_runtime_config, RuntimeConfig
|
||||
|
||||
config = parse_runtime_config(None)
|
||||
|
||||
assert isinstance(config, RuntimeConfig)
|
||||
assert config.background.enabled is False
|
||||
|
||||
def test_parse_with_env_expansion(self):
|
||||
"""Test parsing with environment variable expansion."""
|
||||
from praisonai.recipe.runtime import parse_runtime_config
|
||||
|
||||
with patch.dict(os.environ, {'WEBHOOK_URL': 'https://hooks.example.com'}):
|
||||
data = {
|
||||
'job': {
|
||||
'enabled': True,
|
||||
'webhook_url': '${WEBHOOK_URL}/notify',
|
||||
}
|
||||
}
|
||||
config = parse_runtime_config(data, expand_env=True)
|
||||
|
||||
assert config.job.webhook_url == 'https://hooks.example.com/notify'
|
||||
|
||||
def test_parse_without_env_expansion(self):
|
||||
"""Test parsing without environment variable expansion."""
|
||||
from praisonai.recipe.runtime import parse_runtime_config
|
||||
|
||||
data = {
|
||||
'job': {
|
||||
'webhook_url': '${WEBHOOK_URL}',
|
||||
}
|
||||
}
|
||||
config = parse_runtime_config(data, expand_env=False)
|
||||
|
||||
assert config.job.webhook_url == '${WEBHOOK_URL}'
|
||||
|
||||
|
||||
class TestBackgroundRuntimeConfig:
|
||||
"""Tests for BackgroundRuntimeConfig."""
|
||||
|
||||
def test_from_dict_with_all_fields(self):
|
||||
"""Test creating from dict with all fields."""
|
||||
from praisonai.recipe.runtime import BackgroundRuntimeConfig
|
||||
|
||||
data = {
|
||||
'enabled': True,
|
||||
'max_concurrent': 20,
|
||||
'cleanup_delay_sec': 7200,
|
||||
}
|
||||
config = BackgroundRuntimeConfig.from_dict(data)
|
||||
|
||||
assert config.enabled is True
|
||||
assert config.max_concurrent == 20
|
||||
assert config.cleanup_delay_sec == 7200
|
||||
|
||||
def test_from_dict_partial(self):
|
||||
"""Test creating from dict with partial fields."""
|
||||
from praisonai.recipe.runtime import BackgroundRuntimeConfig, DEFAULT_MAX_CONCURRENT
|
||||
|
||||
data = {'enabled': True}
|
||||
config = BackgroundRuntimeConfig.from_dict(data)
|
||||
|
||||
assert config.enabled is True
|
||||
assert config.max_concurrent == DEFAULT_MAX_CONCURRENT
|
||||
|
||||
|
||||
class TestJobRuntimeConfig:
|
||||
"""Tests for JobRuntimeConfig."""
|
||||
|
||||
def test_from_dict_with_all_fields(self):
|
||||
"""Test creating from dict with all fields."""
|
||||
from praisonai.recipe.runtime import JobRuntimeConfig
|
||||
|
||||
data = {
|
||||
'enabled': True,
|
||||
'timeout_sec': 1800,
|
||||
'webhook_url': 'https://example.com',
|
||||
'idempotency_scope': 'global',
|
||||
'events': ['completed'],
|
||||
}
|
||||
config = JobRuntimeConfig.from_dict(data)
|
||||
|
||||
assert config.enabled is True
|
||||
assert config.timeout_sec == 1800
|
||||
assert config.webhook_url == 'https://example.com'
|
||||
assert config.idempotency_scope == 'global'
|
||||
assert config.events == ['completed']
|
||||
|
||||
def test_default_events(self):
|
||||
"""Test default events list."""
|
||||
from praisonai.recipe.runtime import JobRuntimeConfig
|
||||
|
||||
config = JobRuntimeConfig.from_dict({})
|
||||
|
||||
assert config.events == ['completed', 'failed']
|
||||
|
||||
|
||||
class TestScheduleRuntimeConfig:
|
||||
"""Tests for ScheduleRuntimeConfig."""
|
||||
|
||||
def test_from_dict_with_all_fields(self):
|
||||
"""Test creating from dict with all fields."""
|
||||
from praisonai.recipe.runtime import ScheduleRuntimeConfig
|
||||
|
||||
data = {
|
||||
'enabled': True,
|
||||
'interval': '*/30m',
|
||||
'max_retries': 5,
|
||||
'run_immediately': True,
|
||||
'timeout_sec': 600,
|
||||
'max_cost_usd': 2.50,
|
||||
}
|
||||
config = ScheduleRuntimeConfig.from_dict(data)
|
||||
|
||||
assert config.enabled is True
|
||||
assert config.interval == '*/30m'
|
||||
assert config.max_retries == 5
|
||||
assert config.run_immediately is True
|
||||
assert config.timeout_sec == 600
|
||||
assert config.max_cost_usd == 2.50
|
||||
|
||||
def test_default_interval(self):
|
||||
"""Test default interval is hourly."""
|
||||
from praisonai.recipe.runtime import ScheduleRuntimeConfig
|
||||
|
||||
config = ScheduleRuntimeConfig.from_dict({})
|
||||
|
||||
assert config.interval == 'hourly'
|
||||
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
Unit tests for recipe registry module.
|
||||
|
||||
Tests LocalRegistry, HttpRegistry, and server components.
|
||||
"""
|
||||
|
||||
import json
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from praisonai.recipe.registry import (
|
||||
LocalRegistry,
|
||||
HttpRegistry,
|
||||
RemoteRegistry,
|
||||
get_registry,
|
||||
RegistryError,
|
||||
RecipeNotFoundError,
|
||||
RecipeExistsError,
|
||||
_calculate_checksum,
|
||||
_get_timestamp,
|
||||
_normalize_name,
|
||||
_validate_name,
|
||||
_validate_version,
|
||||
_atomic_write,
|
||||
_atomic_write_json,
|
||||
DEFAULT_REGISTRY_PATH,
|
||||
DEFAULT_REGISTRY_PORT,
|
||||
)
|
||||
|
||||
|
||||
class TestHelperFunctions:
|
||||
"""Test helper functions."""
|
||||
|
||||
def test_get_timestamp(self):
|
||||
"""Test timestamp generation."""
|
||||
ts = _get_timestamp()
|
||||
assert ts is not None
|
||||
assert "T" in ts # ISO format
|
||||
|
||||
def test_calculate_checksum(self, tmp_path):
|
||||
"""Test checksum calculation."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("hello world")
|
||||
|
||||
checksum = _calculate_checksum(test_file)
|
||||
assert len(checksum) == 64 # SHA256 hex
|
||||
|
||||
# Same content should give same checksum
|
||||
test_file2 = tmp_path / "test2.txt"
|
||||
test_file2.write_text("hello world")
|
||||
assert _calculate_checksum(test_file2) == checksum
|
||||
|
||||
def test_normalize_name(self):
|
||||
"""Test name normalization."""
|
||||
assert _normalize_name("My-Recipe") == "my-recipe"
|
||||
assert _normalize_name("my_recipe") == "my-recipe"
|
||||
assert _normalize_name("My.Recipe") == "my-recipe"
|
||||
assert _normalize_name("my--recipe") == "my-recipe"
|
||||
|
||||
def test_validate_name(self):
|
||||
"""Test name validation."""
|
||||
assert _validate_name("my-recipe") is True
|
||||
assert _validate_name("my_recipe") is True
|
||||
assert _validate_name("MyRecipe123") is True
|
||||
assert _validate_name("") is False
|
||||
assert _validate_name("-invalid") is False
|
||||
assert _validate_name("a" * 200) is False # Too long
|
||||
|
||||
def test_validate_version(self):
|
||||
"""Test version validation."""
|
||||
assert _validate_version("1.0.0") is True
|
||||
assert _validate_version("0.1.0") is True
|
||||
assert _validate_version("1.0.0-alpha") is True
|
||||
assert _validate_version("1.0.0.beta1") is True
|
||||
assert _validate_version("") is False
|
||||
assert _validate_version("1.0") is False # Missing patch
|
||||
assert _validate_version("invalid") is False
|
||||
|
||||
def test_atomic_write(self, tmp_path):
|
||||
"""Test atomic write."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
_atomic_write(test_file, b"hello world")
|
||||
|
||||
assert test_file.exists()
|
||||
assert test_file.read_bytes() == b"hello world"
|
||||
|
||||
def test_atomic_write_json(self, tmp_path):
|
||||
"""Test atomic JSON write."""
|
||||
test_file = tmp_path / "test.json"
|
||||
data = {"key": "value", "number": 42}
|
||||
_atomic_write_json(test_file, data)
|
||||
|
||||
assert test_file.exists()
|
||||
loaded = json.loads(test_file.read_text())
|
||||
assert loaded == data
|
||||
|
||||
|
||||
class TestLocalRegistry:
|
||||
"""Test LocalRegistry class."""
|
||||
|
||||
@pytest.fixture
|
||||
def registry(self, tmp_path):
|
||||
"""Create a temporary registry."""
|
||||
return LocalRegistry(tmp_path / "registry")
|
||||
|
||||
@pytest.fixture
|
||||
def sample_bundle(self, tmp_path):
|
||||
"""Create a sample .praison bundle."""
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
|
||||
manifest = {
|
||||
"name": "test-recipe",
|
||||
"version": "1.0.0",
|
||||
"description": "A test recipe",
|
||||
"tags": ["test", "sample"],
|
||||
"author": "test",
|
||||
"files": ["recipe.yaml"],
|
||||
}
|
||||
|
||||
(bundle_dir / "manifest.json").write_text(json.dumps(manifest))
|
||||
(bundle_dir / "recipe.yaml").write_text("name: test")
|
||||
|
||||
bundle_path = tmp_path / "test-recipe-1.0.0.praison"
|
||||
with tarfile.open(bundle_path, "w:gz") as tar:
|
||||
tar.add(bundle_dir / "manifest.json", arcname="manifest.json")
|
||||
tar.add(bundle_dir / "recipe.yaml", arcname="recipe.yaml")
|
||||
|
||||
return bundle_path
|
||||
|
||||
def test_init_creates_structure(self, registry):
|
||||
"""Test registry initialization creates directory structure."""
|
||||
assert registry.path.exists()
|
||||
assert registry.recipes_path.exists()
|
||||
assert registry.index_path.exists()
|
||||
|
||||
def test_publish_bundle(self, registry, sample_bundle):
|
||||
"""Test publishing a bundle."""
|
||||
result = registry.publish(sample_bundle)
|
||||
|
||||
assert result["name"] == "test-recipe"
|
||||
assert result["version"] == "1.0.0"
|
||||
assert "checksum" in result
|
||||
assert "published_at" in result
|
||||
|
||||
def test_publish_duplicate_fails(self, registry, sample_bundle):
|
||||
"""Test publishing duplicate version fails."""
|
||||
registry.publish(sample_bundle)
|
||||
|
||||
with pytest.raises(RecipeExistsError):
|
||||
registry.publish(sample_bundle)
|
||||
|
||||
def test_publish_force_overwrites(self, registry, sample_bundle):
|
||||
"""Test force publish overwrites existing."""
|
||||
registry.publish(sample_bundle)
|
||||
result = registry.publish(sample_bundle, force=True)
|
||||
|
||||
assert result["name"] == "test-recipe"
|
||||
|
||||
def test_publish_invalid_bundle(self, registry, tmp_path):
|
||||
"""Test publishing invalid bundle fails."""
|
||||
invalid_bundle = tmp_path / "invalid.praison"
|
||||
invalid_bundle.write_text("not a tarball")
|
||||
|
||||
with pytest.raises(RegistryError):
|
||||
registry.publish(invalid_bundle)
|
||||
|
||||
def test_pull_recipe(self, registry, sample_bundle, tmp_path):
|
||||
"""Test pulling a recipe."""
|
||||
registry.publish(sample_bundle)
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
result = registry.pull("test-recipe", output_dir=output_dir)
|
||||
|
||||
assert result["name"] == "test-recipe"
|
||||
assert result["version"] == "1.0.0"
|
||||
assert (output_dir / "test-recipe" / "manifest.json").exists()
|
||||
|
||||
def test_pull_specific_version(self, registry, sample_bundle, tmp_path):
|
||||
"""Test pulling specific version."""
|
||||
registry.publish(sample_bundle)
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
result = registry.pull("test-recipe", version="1.0.0", output_dir=output_dir)
|
||||
|
||||
assert result["version"] == "1.0.0"
|
||||
|
||||
def test_pull_not_found(self, registry, tmp_path):
|
||||
"""Test pulling non-existent recipe."""
|
||||
with pytest.raises(RecipeNotFoundError):
|
||||
registry.pull("nonexistent")
|
||||
|
||||
def test_list_recipes(self, registry, sample_bundle):
|
||||
"""Test listing recipes."""
|
||||
registry.publish(sample_bundle)
|
||||
|
||||
recipes = registry.list_recipes()
|
||||
|
||||
assert len(recipes) == 1
|
||||
assert recipes[0]["name"] == "test-recipe"
|
||||
|
||||
def test_list_recipes_with_tags(self, registry, sample_bundle):
|
||||
"""Test listing recipes filtered by tags."""
|
||||
registry.publish(sample_bundle)
|
||||
|
||||
# Should find with matching tag
|
||||
recipes = registry.list_recipes(tags=["test"])
|
||||
assert len(recipes) == 1
|
||||
|
||||
# Should not find with non-matching tag
|
||||
recipes = registry.list_recipes(tags=["nonexistent"])
|
||||
assert len(recipes) == 0
|
||||
|
||||
def test_search_recipes(self, registry, sample_bundle):
|
||||
"""Test searching recipes."""
|
||||
registry.publish(sample_bundle)
|
||||
|
||||
# Search by name
|
||||
results = registry.search("test")
|
||||
assert len(results) == 1
|
||||
|
||||
# Search by tag (search includes tags)
|
||||
results = registry.search("sample")
|
||||
assert len(results) == 1 # "sample" is in tags
|
||||
|
||||
# Search for non-existent term
|
||||
results = registry.search("nonexistent")
|
||||
assert len(results) == 0
|
||||
|
||||
def test_get_versions(self, registry, sample_bundle):
|
||||
"""Test getting versions."""
|
||||
registry.publish(sample_bundle)
|
||||
|
||||
versions = registry.get_versions("test-recipe")
|
||||
assert "1.0.0" in versions
|
||||
|
||||
def test_get_info(self, registry, sample_bundle):
|
||||
"""Test getting recipe info."""
|
||||
registry.publish(sample_bundle)
|
||||
|
||||
info = registry.get_info("test-recipe")
|
||||
assert info["name"] == "test-recipe"
|
||||
assert info["version"] == "1.0.0"
|
||||
|
||||
def test_delete_version(self, registry, sample_bundle):
|
||||
"""Test deleting specific version."""
|
||||
registry.publish(sample_bundle)
|
||||
|
||||
result = registry.delete("test-recipe", version="1.0.0")
|
||||
assert result is True
|
||||
|
||||
with pytest.raises(RecipeNotFoundError):
|
||||
registry.get_info("test-recipe")
|
||||
|
||||
def test_delete_all_versions(self, registry, sample_bundle):
|
||||
"""Test deleting all versions."""
|
||||
registry.publish(sample_bundle)
|
||||
|
||||
result = registry.delete("test-recipe")
|
||||
assert result is True
|
||||
|
||||
with pytest.raises(RecipeNotFoundError):
|
||||
registry.get_versions("test-recipe")
|
||||
|
||||
|
||||
class TestHttpRegistry:
|
||||
"""Test HttpRegistry class."""
|
||||
|
||||
@pytest.fixture
|
||||
def registry(self):
|
||||
"""Create an HttpRegistry instance."""
|
||||
return HttpRegistry("http://localhost:7777", token="test-token")
|
||||
|
||||
def test_init(self, registry):
|
||||
"""Test initialization."""
|
||||
assert registry.url == "http://localhost:7777"
|
||||
assert registry.token == "test-token"
|
||||
assert registry.timeout == 30
|
||||
|
||||
def test_get_headers(self, registry):
|
||||
"""Test header generation."""
|
||||
headers = registry._get_headers()
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
assert headers["Authorization"] == "Bearer test-token"
|
||||
|
||||
def test_get_headers_no_token(self):
|
||||
"""Test headers without token."""
|
||||
registry = HttpRegistry("http://localhost:7777")
|
||||
registry.token = None
|
||||
headers = registry._get_headers()
|
||||
assert "Authorization" not in headers
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_health_check(self, mock_urlopen, registry):
|
||||
"""Test health check."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = b'{"ok": true, "status": "healthy"}'
|
||||
mock_response.headers = {}
|
||||
mock_response.__enter__ = lambda s: mock_response
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
result = registry.health()
|
||||
assert result["ok"] is True
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_list_recipes(self, mock_urlopen, registry):
|
||||
"""Test listing recipes."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = b'{"recipes": [{"name": "test"}]}'
|
||||
mock_response.headers = {}
|
||||
mock_response.__enter__ = lambda s: mock_response
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
recipes = registry.list_recipes()
|
||||
assert len(recipes) == 1
|
||||
|
||||
def test_remote_registry_alias(self):
|
||||
"""Test RemoteRegistry is alias for HttpRegistry."""
|
||||
assert RemoteRegistry is HttpRegistry
|
||||
|
||||
|
||||
class TestGetRegistry:
|
||||
"""Test get_registry factory function."""
|
||||
|
||||
def test_default_returns_local(self):
|
||||
"""Test default returns LocalRegistry."""
|
||||
registry = get_registry()
|
||||
assert isinstance(registry, LocalRegistry)
|
||||
|
||||
def test_http_url_returns_http(self):
|
||||
"""Test HTTP URL returns HttpRegistry."""
|
||||
registry = get_registry("http://localhost:7777")
|
||||
assert isinstance(registry, HttpRegistry)
|
||||
|
||||
def test_https_url_returns_http(self):
|
||||
"""Test HTTPS URL returns HttpRegistry."""
|
||||
registry = get_registry("https://registry.example.com")
|
||||
assert isinstance(registry, HttpRegistry)
|
||||
|
||||
def test_path_returns_local(self, tmp_path):
|
||||
"""Test path returns LocalRegistry."""
|
||||
registry = get_registry(str(tmp_path))
|
||||
assert isinstance(registry, LocalRegistry)
|
||||
|
||||
def test_token_passed_to_http(self):
|
||||
"""Test token is passed to HttpRegistry."""
|
||||
registry = get_registry("http://localhost:7777", token="secret")
|
||||
assert registry.token == "secret"
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Test module constants."""
|
||||
|
||||
def test_default_registry_path(self):
|
||||
"""Test default registry path."""
|
||||
assert DEFAULT_REGISTRY_PATH == Path.home() / ".praison" / "registry"
|
||||
|
||||
def test_default_registry_port(self):
|
||||
"""Test default registry port."""
|
||||
assert DEFAULT_REGISTRY_PORT == 7777
|
||||
Reference in new issue
Block a user