chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
test_env/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# uv
|
||||
uv.lock
|
||||
@@ -0,0 +1,530 @@
|
||||
# Sim Python SDK
|
||||
|
||||
The official Python SDK for [Sim](https://sim.ai), allowing you to execute workflows programmatically from your Python applications.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install simstudio-sdk
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
import os
|
||||
from simstudio import SimStudioClient
|
||||
|
||||
# Initialize the client
|
||||
client = SimStudioClient(
|
||||
api_key=os.getenv("SIM_API_KEY", "your-api-key-here"),
|
||||
base_url="https://sim.ai" # optional, defaults to https://sim.ai
|
||||
)
|
||||
|
||||
# Execute a workflow
|
||||
try:
|
||||
result = client.execute_workflow("workflow-id")
|
||||
print("Workflow executed successfully:", result)
|
||||
except Exception as error:
|
||||
print("Workflow execution failed:", error)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### SimStudioClient
|
||||
|
||||
#### Constructor
|
||||
|
||||
```python
|
||||
SimStudioClient(api_key: str, base_url: str = "https://sim.ai")
|
||||
```
|
||||
|
||||
- `api_key` (str): Your Sim API key
|
||||
- `base_url` (str, optional): Base URL for the Sim API (defaults to `https://sim.ai`)
|
||||
|
||||
#### Methods
|
||||
|
||||
##### execute_workflow(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None, async_execution=None)
|
||||
|
||||
Execute a workflow with optional input data.
|
||||
|
||||
```python
|
||||
# With dict input (spread at root level of request body)
|
||||
result = client.execute_workflow("workflow-id", {"message": "Hello, world!"})
|
||||
|
||||
# With primitive input (wrapped as { input: value })
|
||||
result = client.execute_workflow("workflow-id", "NVDA")
|
||||
|
||||
# With options (keyword-only arguments)
|
||||
result = client.execute_workflow("workflow-id", {"message": "Hello"}, timeout=60.0)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `workflow_id` (str): The ID of the workflow to execute
|
||||
- `input` (any, optional): Input data to pass to the workflow. Dicts are spread at the root level, primitives/lists are wrapped in `{ input: value }`. File objects are automatically converted to base64.
|
||||
- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0)
|
||||
- `stream` (bool, keyword-only): Enable streaming responses
|
||||
- `selected_outputs` (list, keyword-only): Block outputs to stream (e.g., `["agent1.content"]`)
|
||||
- `async_execution` (bool, keyword-only): Execute asynchronously and return execution ID
|
||||
|
||||
**Returns:** `WorkflowExecutionResult` or `AsyncExecutionResult`
|
||||
|
||||
##### get_workflow_status(workflow_id)
|
||||
|
||||
Get the status of a workflow (deployment status, etc.).
|
||||
|
||||
```python
|
||||
status = client.get_workflow_status("workflow-id")
|
||||
print("Is deployed:", status.is_deployed)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `workflow_id` (str): The ID of the workflow
|
||||
|
||||
**Returns:** `WorkflowStatus`
|
||||
|
||||
##### validate_workflow(workflow_id)
|
||||
|
||||
Validate that a workflow is ready for execution.
|
||||
|
||||
```python
|
||||
is_ready = client.validate_workflow("workflow-id")
|
||||
if is_ready:
|
||||
# Workflow is deployed and ready
|
||||
pass
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `workflow_id` (str): The ID of the workflow
|
||||
|
||||
**Returns:** `bool`
|
||||
|
||||
##### execute_workflow_sync(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None)
|
||||
|
||||
Execute a workflow synchronously (ensures non-async mode).
|
||||
|
||||
```python
|
||||
result = client.execute_workflow_sync("workflow-id", {"data": "some input"}, timeout=60.0)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `workflow_id` (str): The ID of the workflow to execute
|
||||
- `input` (any, optional): Input data to pass to the workflow
|
||||
- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0)
|
||||
- `stream` (bool, keyword-only): Enable streaming responses
|
||||
- `selected_outputs` (list, keyword-only): Block outputs to stream (e.g., `["agent1.content"]`)
|
||||
|
||||
**Returns:** `WorkflowExecutionResult`
|
||||
|
||||
##### get_job_status(job_id)
|
||||
|
||||
Get the status of an async job.
|
||||
|
||||
```python
|
||||
status = client.get_job_status("job-id-from-async-execution")
|
||||
print("Job status:", status)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `job_id` (str): The job ID returned from async execution
|
||||
|
||||
**Returns:** `dict`
|
||||
|
||||
##### execute_with_retry(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None, async_execution=None, max_retries=3, initial_delay=1.0, max_delay=30.0, backoff_multiplier=2.0)
|
||||
|
||||
Execute a workflow with automatic retry on rate limit errors.
|
||||
|
||||
```python
|
||||
result = client.execute_with_retry(
|
||||
"workflow-id",
|
||||
{"message": "Hello"},
|
||||
timeout=30.0,
|
||||
max_retries=3,
|
||||
initial_delay=1.0,
|
||||
max_delay=30.0,
|
||||
backoff_multiplier=2.0
|
||||
)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `workflow_id` (str): The ID of the workflow to execute
|
||||
- `input` (any, optional): Input data to pass to the workflow
|
||||
- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0)
|
||||
- `stream` (bool, keyword-only): Enable streaming responses
|
||||
- `selected_outputs` (list, keyword-only): Block outputs to stream
|
||||
- `async_execution` (bool, keyword-only): Execute asynchronously
|
||||
- `max_retries` (int, keyword-only): Maximum retry attempts (default: 3)
|
||||
- `initial_delay` (float, keyword-only): Initial delay in seconds (default: 1.0)
|
||||
- `max_delay` (float, keyword-only): Maximum delay in seconds (default: 30.0)
|
||||
- `backoff_multiplier` (float, keyword-only): Backoff multiplier (default: 2.0)
|
||||
|
||||
**Returns:** `WorkflowExecutionResult` or `AsyncExecutionResult`
|
||||
|
||||
##### get_rate_limit_info()
|
||||
|
||||
Get current rate limit information from the last API response.
|
||||
|
||||
```python
|
||||
rate_info = client.get_rate_limit_info()
|
||||
if rate_info:
|
||||
print("Remaining requests:", rate_info.remaining)
|
||||
```
|
||||
|
||||
**Returns:** `RateLimitInfo` or `None`
|
||||
|
||||
##### get_usage_limits()
|
||||
|
||||
Get current usage limits and quota information.
|
||||
|
||||
```python
|
||||
limits = client.get_usage_limits()
|
||||
print("Current usage:", limits.usage)
|
||||
```
|
||||
|
||||
**Returns:** `UsageLimits`
|
||||
|
||||
##### set_api_key(api_key)
|
||||
|
||||
Update the API key.
|
||||
|
||||
```python
|
||||
client.set_api_key("new-api-key")
|
||||
```
|
||||
|
||||
##### set_base_url(base_url)
|
||||
|
||||
Update the base URL.
|
||||
|
||||
```python
|
||||
client.set_base_url("https://my-custom-domain.com")
|
||||
```
|
||||
|
||||
##### close()
|
||||
|
||||
Close the underlying HTTP session.
|
||||
|
||||
```python
|
||||
client.close()
|
||||
```
|
||||
|
||||
## Data Classes
|
||||
|
||||
### WorkflowExecutionResult
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class WorkflowExecutionResult:
|
||||
success: bool
|
||||
output: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
logs: Optional[list] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
trace_spans: Optional[list] = None
|
||||
total_duration: Optional[float] = None
|
||||
```
|
||||
|
||||
### WorkflowStatus
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class WorkflowStatus:
|
||||
is_deployed: bool
|
||||
deployed_at: Optional[str] = None
|
||||
needs_redeployment: bool = False
|
||||
```
|
||||
|
||||
### SimStudioError
|
||||
|
||||
```python
|
||||
class SimStudioError(Exception):
|
||||
def __init__(self, message: str, code: Optional[str] = None, status: Optional[int] = None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.status = status
|
||||
```
|
||||
|
||||
### AsyncExecutionResult
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class AsyncExecutionResult:
|
||||
success: bool
|
||||
job_id: str
|
||||
status_url: str
|
||||
execution_id: Optional[str] = None
|
||||
message: str = ""
|
||||
async_execution: bool = True
|
||||
```
|
||||
|
||||
### RateLimitInfo
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class RateLimitInfo:
|
||||
limit: int
|
||||
remaining: int
|
||||
reset: int
|
||||
retry_after: Optional[int] = None
|
||||
```
|
||||
|
||||
### UsageLimits
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class UsageLimits:
|
||||
success: bool
|
||||
rate_limit: Dict[str, Any]
|
||||
usage: Dict[str, Any]
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Workflow Execution
|
||||
|
||||
```python
|
||||
import os
|
||||
from simstudio import SimStudioClient
|
||||
|
||||
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
|
||||
|
||||
def run_workflow():
|
||||
try:
|
||||
# Check if workflow is ready
|
||||
is_ready = client.validate_workflow("my-workflow-id")
|
||||
if not is_ready:
|
||||
raise Exception("Workflow is not deployed or ready")
|
||||
|
||||
# Execute the workflow
|
||||
result = client.execute_workflow(
|
||||
"my-workflow-id",
|
||||
{
|
||||
"message": "Process this data",
|
||||
"user_id": "12345"
|
||||
}
|
||||
)
|
||||
|
||||
if result.success:
|
||||
print("Output:", result.output)
|
||||
print("Duration:", result.metadata.get("duration") if result.metadata else None)
|
||||
else:
|
||||
print("Workflow failed:", result.error)
|
||||
|
||||
except Exception as error:
|
||||
print("Error:", error)
|
||||
|
||||
run_workflow()
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```python
|
||||
from simstudio import SimStudioClient, SimStudioError
|
||||
import os
|
||||
|
||||
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
|
||||
|
||||
def execute_with_error_handling():
|
||||
try:
|
||||
result = client.execute_workflow("workflow-id")
|
||||
return result
|
||||
except SimStudioError as error:
|
||||
if error.code == "UNAUTHORIZED":
|
||||
print("Invalid API key")
|
||||
elif error.code == "TIMEOUT":
|
||||
print("Workflow execution timed out")
|
||||
elif error.code == "USAGE_LIMIT_EXCEEDED":
|
||||
print("Usage limit exceeded")
|
||||
elif error.code == "INVALID_JSON":
|
||||
print("Invalid JSON in request body")
|
||||
else:
|
||||
print(f"Workflow error: {error}")
|
||||
raise
|
||||
except Exception as error:
|
||||
print(f"Unexpected error: {error}")
|
||||
raise
|
||||
```
|
||||
|
||||
### Context Manager Usage
|
||||
|
||||
```python
|
||||
from simstudio import SimStudioClient
|
||||
import os
|
||||
|
||||
# Using context manager to automatically close the session
|
||||
with SimStudioClient(api_key=os.getenv("SIM_API_KEY")) as client:
|
||||
result = client.execute_workflow("workflow-id")
|
||||
print("Result:", result)
|
||||
# Session is automatically closed here
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
```python
|
||||
import os
|
||||
from simstudio import SimStudioClient
|
||||
|
||||
# Using environment variables
|
||||
client = SimStudioClient(
|
||||
api_key=os.getenv("SIM_API_KEY"),
|
||||
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
|
||||
)
|
||||
```
|
||||
|
||||
### File Upload
|
||||
|
||||
File objects are automatically detected and converted to base64 format. Include them in your input under the field name matching your workflow's API trigger input format:
|
||||
|
||||
The SDK converts file objects to this format:
|
||||
```python
|
||||
{
|
||||
'type': 'file',
|
||||
'data': 'data:mime/type;base64,base64data',
|
||||
'name': 'filename',
|
||||
'mime': 'mime/type'
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, you can manually provide files using the URL format:
|
||||
```python
|
||||
{
|
||||
'type': 'url',
|
||||
'data': 'https://example.com/file.pdf',
|
||||
'name': 'file.pdf',
|
||||
'mime': 'application/pdf'
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
from simstudio import SimStudioClient
|
||||
import os
|
||||
|
||||
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
|
||||
|
||||
# Upload a single file - include it under the field name from your API trigger
|
||||
with open('document.pdf', 'rb') as f:
|
||||
result = client.execute_workflow(
|
||||
'workflow-id',
|
||||
{
|
||||
'documents': [f], # Must match your workflow's "files" field name
|
||||
'instructions': 'Analyze this document'
|
||||
}
|
||||
)
|
||||
|
||||
# Upload multiple files
|
||||
with open('doc1.pdf', 'rb') as f1, open('doc2.pdf', 'rb') as f2:
|
||||
result = client.execute_workflow(
|
||||
'workflow-id',
|
||||
{
|
||||
'attachments': [f1, f2], # Must match your workflow's "files" field name
|
||||
'query': 'Compare these documents'
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Batch Workflow Execution
|
||||
|
||||
```python
|
||||
from simstudio import SimStudioClient
|
||||
import os
|
||||
|
||||
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
|
||||
|
||||
def execute_workflows_batch(workflow_data_pairs):
|
||||
"""Execute multiple workflows with different input data."""
|
||||
results = []
|
||||
|
||||
for workflow_id, workflow_input in workflow_data_pairs:
|
||||
try:
|
||||
# Validate workflow before execution
|
||||
if not client.validate_workflow(workflow_id):
|
||||
print(f"Skipping {workflow_id}: not deployed")
|
||||
continue
|
||||
|
||||
result = client.execute_workflow(workflow_id, workflow_input)
|
||||
results.append({
|
||||
"workflow_id": workflow_id,
|
||||
"success": result.success,
|
||||
"output": result.output,
|
||||
"error": result.error
|
||||
})
|
||||
|
||||
except Exception as error:
|
||||
results.append({
|
||||
"workflow_id": workflow_id,
|
||||
"success": False,
|
||||
"error": str(error)
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
# Example usage
|
||||
workflows = [
|
||||
("workflow-1", {"type": "analysis", "data": "sample1"}),
|
||||
("workflow-2", {"type": "processing", "data": "sample2"}),
|
||||
]
|
||||
|
||||
results = execute_workflows_batch(workflows)
|
||||
for result in results:
|
||||
print(f"Workflow {result['workflow_id']}: {'Success' if result['success'] else 'Failed'}")
|
||||
```
|
||||
|
||||
## Getting Your API Key
|
||||
|
||||
1. Log in to your [Sim](https://sim.ai) account
|
||||
2. Navigate to your workflow
|
||||
3. Click on "Deploy" to deploy your workflow
|
||||
4. Select or create an API key during the deployment process
|
||||
5. Copy the API key to use in your application
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
To run the tests locally:
|
||||
|
||||
1. Clone the repository and navigate to the Python SDK directory:
|
||||
```bash
|
||||
cd packages/python-sdk
|
||||
```
|
||||
|
||||
2. Create and activate a virtual environment:
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. Install the package in development mode with test dependencies:
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
4. Run the tests:
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
Run code quality checks:
|
||||
|
||||
```bash
|
||||
# Code formatting
|
||||
black simstudio/
|
||||
|
||||
# Linting
|
||||
flake8 simstudio/ --max-line-length=100
|
||||
|
||||
# Type checking
|
||||
mypy simstudio/
|
||||
|
||||
# Import sorting
|
||||
isort simstudio/
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+
|
||||
- requests >= 2.25.0
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic usage examples for the Sim Python SDK
|
||||
"""
|
||||
|
||||
import os
|
||||
from simstudio import SimStudioClient, SimStudioError
|
||||
|
||||
|
||||
def basic_example():
|
||||
"""Example 1: Basic workflow execution"""
|
||||
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
|
||||
|
||||
try:
|
||||
# Execute a workflow without input
|
||||
result = client.execute_workflow("your-workflow-id")
|
||||
|
||||
if result.success:
|
||||
print("✅ Workflow executed successfully!")
|
||||
print(f"Output: {result.output}")
|
||||
if result.metadata:
|
||||
print(f"Duration: {result.metadata.get('duration')} ms")
|
||||
else:
|
||||
print(f"❌ Workflow failed: {result.error}")
|
||||
|
||||
except SimStudioError as error:
|
||||
print(f"SDK Error: {error} (Code: {error.code})")
|
||||
except Exception as error:
|
||||
print(f"Unexpected error: {error}")
|
||||
|
||||
|
||||
def with_input_example():
|
||||
"""Example 2: Workflow execution with input data"""
|
||||
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
|
||||
|
||||
try:
|
||||
result = client.execute_workflow(
|
||||
"your-workflow-id",
|
||||
input_data={
|
||||
"message": "Hello from Python SDK!",
|
||||
"user_id": "12345",
|
||||
"data": {
|
||||
"type": "analysis",
|
||||
"parameters": {
|
||||
"include_metadata": True,
|
||||
"format": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
timeout=60.0 # 60 seconds
|
||||
)
|
||||
|
||||
if result.success:
|
||||
print("✅ Workflow executed successfully!")
|
||||
print(f"Output: {result.output}")
|
||||
if result.metadata:
|
||||
print(f"Duration: {result.metadata.get('duration')} ms")
|
||||
else:
|
||||
print(f"❌ Workflow failed: {result.error}")
|
||||
|
||||
except SimStudioError as error:
|
||||
print(f"SDK Error: {error} (Code: {error.code})")
|
||||
except Exception as error:
|
||||
print(f"Unexpected error: {error}")
|
||||
|
||||
|
||||
def status_example():
|
||||
"""Example 3: Workflow validation and status checking"""
|
||||
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
|
||||
|
||||
try:
|
||||
# Check if workflow is ready
|
||||
is_ready = client.validate_workflow("your-workflow-id")
|
||||
print(f"Workflow ready: {is_ready}")
|
||||
|
||||
# Get detailed status
|
||||
status = client.get_workflow_status("your-workflow-id")
|
||||
print(f"Status: {{\n"
|
||||
f" deployed: {status.is_deployed},\n"
|
||||
f" needs_redeployment: {status.needs_redeployment},\n"
|
||||
f" deployed_at: {status.deployed_at}\n"
|
||||
f"}}")
|
||||
|
||||
if status.is_deployed:
|
||||
# Execute the workflow
|
||||
result = client.execute_workflow("your-workflow-id")
|
||||
print(f"Result: {result}")
|
||||
|
||||
except Exception as error:
|
||||
print(f"Error: {error}")
|
||||
|
||||
|
||||
def context_manager_example():
|
||||
"""Example 4: Using context manager"""
|
||||
with SimStudioClient(api_key=os.getenv("SIM_API_KEY")) as client:
|
||||
try:
|
||||
result = client.execute_workflow("your-workflow-id")
|
||||
print(f"Result: {result}")
|
||||
except Exception as error:
|
||||
print(f"Error: {error}")
|
||||
# Session is automatically closed here
|
||||
|
||||
|
||||
def batch_execution_example():
|
||||
"""Example 5: Batch workflow execution"""
|
||||
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
|
||||
|
||||
workflows = [
|
||||
("workflow-1", {"type": "analysis", "data": "sample1"}),
|
||||
("workflow-2", {"type": "processing", "data": "sample2"}),
|
||||
("workflow-3", {"type": "validation", "data": "sample3"}),
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
for workflow_id, input_data in workflows:
|
||||
try:
|
||||
# Validate workflow before execution
|
||||
if not client.validate_workflow(workflow_id):
|
||||
print(f"⚠️ Skipping {workflow_id}: not deployed")
|
||||
continue
|
||||
|
||||
result = client.execute_workflow(workflow_id, input_data)
|
||||
results.append({
|
||||
"workflow_id": workflow_id,
|
||||
"success": result.success,
|
||||
"output": result.output,
|
||||
"error": result.error
|
||||
})
|
||||
|
||||
status = "✅ Success" if result.success else "❌ Failed"
|
||||
print(f"{status}: {workflow_id}")
|
||||
|
||||
except SimStudioError as error:
|
||||
results.append({
|
||||
"workflow_id": workflow_id,
|
||||
"success": False,
|
||||
"error": str(error)
|
||||
})
|
||||
print(f"❌ SDK Error in {workflow_id}: {error}")
|
||||
except Exception as error:
|
||||
results.append({
|
||||
"workflow_id": workflow_id,
|
||||
"success": False,
|
||||
"error": str(error)
|
||||
})
|
||||
print(f"❌ Unexpected error in {workflow_id}: {error}")
|
||||
|
||||
# Summary
|
||||
successful = sum(1 for r in results if r["success"])
|
||||
total = len(results)
|
||||
print(f"\n📊 Summary: {successful}/{total} workflows completed successfully")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def streaming_example():
|
||||
"""Example 6: Workflow execution with streaming"""
|
||||
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
|
||||
|
||||
try:
|
||||
result = client.execute_workflow(
|
||||
"your-workflow-id",
|
||||
input_data={"message": "Count to five"},
|
||||
stream=True,
|
||||
selected_outputs=["agent1.content"], # Use blockName.attribute format
|
||||
timeout=60.0
|
||||
)
|
||||
|
||||
if result.success:
|
||||
print("✅ Workflow executed successfully!")
|
||||
print(f"Output: {result.output}")
|
||||
if result.metadata:
|
||||
print(f"Duration: {result.metadata.get('duration')} ms")
|
||||
else:
|
||||
print(f"❌ Workflow failed: {result.error}")
|
||||
|
||||
except SimStudioError as error:
|
||||
print(f"SDK Error: {error} (Code: {error.code})")
|
||||
except Exception as error:
|
||||
print(f"Unexpected error: {error}")
|
||||
|
||||
|
||||
def error_handling_example():
|
||||
"""Example 7: Comprehensive error handling"""
|
||||
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
|
||||
|
||||
try:
|
||||
result = client.execute_workflow("your-workflow-id")
|
||||
|
||||
if result.success:
|
||||
print("✅ Workflow executed successfully!")
|
||||
print(f"Output: {result.output}")
|
||||
return result
|
||||
else:
|
||||
print(f"❌ Workflow failed: {result.error}")
|
||||
return result
|
||||
except SimStudioError as error:
|
||||
if error.code == "UNAUTHORIZED":
|
||||
print("❌ Invalid API key")
|
||||
elif error.code == "TIMEOUT":
|
||||
print("⏱️ Workflow execution timed out")
|
||||
elif error.code == "USAGE_LIMIT_EXCEEDED":
|
||||
print("💳 Usage limit exceeded")
|
||||
elif error.code == "INVALID_JSON":
|
||||
print("📝 Invalid JSON in request body")
|
||||
elif error.status == 404:
|
||||
print("🔍 Workflow not found")
|
||||
elif error.status == 403:
|
||||
print("🚫 Workflow is not deployed")
|
||||
else:
|
||||
print(f"⚠️ Workflow error: {error}")
|
||||
raise
|
||||
except Exception as error:
|
||||
print(f"💥 Unexpected error: {error}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🚀 Running Sim Python SDK Examples\n")
|
||||
|
||||
# Check if API key is set
|
||||
if not os.getenv("SIM_API_KEY"):
|
||||
print("❌ Please set SIM_API_KEY environment variable")
|
||||
exit(1)
|
||||
|
||||
try:
|
||||
print("1️⃣ Basic Example:")
|
||||
basic_example()
|
||||
print("\n✅ Basic example completed\n")
|
||||
|
||||
print("2️⃣ Input Example:")
|
||||
with_input_example()
|
||||
print("\n✅ Input example completed\n")
|
||||
|
||||
print("3️⃣ Status Example:")
|
||||
status_example()
|
||||
print("\n✅ Status example completed\n")
|
||||
|
||||
print("4️⃣ Context Manager Example:")
|
||||
context_manager_example()
|
||||
print("\n✅ Context manager example completed\n")
|
||||
|
||||
print("5️⃣ Batch Execution Example:")
|
||||
batch_execution_example()
|
||||
print("\n✅ Batch execution example completed\n")
|
||||
|
||||
print("6️⃣ Streaming Example:")
|
||||
streaming_example()
|
||||
print("\n✅ Streaming example completed\n")
|
||||
|
||||
print("7️⃣ Error Handling Example:")
|
||||
error_handling_example()
|
||||
print("\n✅ Error handling example completed\n")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n💥 Example failed: {e}")
|
||||
exit(1)
|
||||
|
||||
print("🎉 All examples completed successfully!")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Example: Upload files with workflow execution
|
||||
|
||||
This example demonstrates how to upload files when executing a workflow.
|
||||
Files are automatically detected and converted to base64 format.
|
||||
"""
|
||||
|
||||
from simstudio import SimStudioClient
|
||||
import os
|
||||
|
||||
|
||||
def main():
|
||||
# Initialize the client
|
||||
api_key = os.getenv('SIM_API_KEY')
|
||||
if not api_key:
|
||||
raise ValueError('SIM_API_KEY environment variable is required')
|
||||
|
||||
client = SimStudioClient(api_key=api_key)
|
||||
|
||||
# Example 1: Upload a single file
|
||||
# Include file under the field name from your workflow's API trigger input format
|
||||
print("Example 1: Upload a single file")
|
||||
with open('document.pdf', 'rb') as f:
|
||||
result = client.execute_workflow(
|
||||
workflow_id='your-workflow-id',
|
||||
input_data={
|
||||
'documents': [f], # Field name must match your API trigger's file input field
|
||||
'instructions': 'Analyze this document'
|
||||
}
|
||||
)
|
||||
|
||||
if result.success:
|
||||
print(f"Success! Output: {result.output}")
|
||||
else:
|
||||
print(f"Failed: {result.error}")
|
||||
|
||||
# Example 2: Upload multiple files
|
||||
print("\nExample 2: Upload multiple files")
|
||||
with open('document1.pdf', 'rb') as f1, open('document2.pdf', 'rb') as f2:
|
||||
result = client.execute_workflow(
|
||||
workflow_id='your-workflow-id',
|
||||
input_data={
|
||||
'attachments': [f1, f2], # Field name must match your API trigger's file input field
|
||||
'query': 'Compare these documents'
|
||||
}
|
||||
)
|
||||
|
||||
if result.success:
|
||||
print(f"Success! Output: {result.output}")
|
||||
else:
|
||||
print(f"Failed: {result.error}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,84 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "simstudio-sdk"
|
||||
version = "0.1.2"
|
||||
authors = [
|
||||
{name = "Sim", email = "help@sim.ai"},
|
||||
]
|
||||
description = "Sim SDK - Execute workflows programmatically"
|
||||
readme = "README.md"
|
||||
license = {text = "Apache-2.0"}
|
||||
requires-python = ">=3.8"
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
]
|
||||
keywords = ["simstudio", "ai", "workflow", "sdk", "api", "automation"]
|
||||
dependencies = [
|
||||
"requests>=2.25.0",
|
||||
"typing-extensions>=4.0.0; python_version<'3.10'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=6.0.0",
|
||||
"pytest-asyncio>=0.18.0",
|
||||
"black>=22.0.0",
|
||||
"flake8>=4.0.0",
|
||||
"mypy>=0.910",
|
||||
"isort>=5.0.0",
|
||||
"types-requests>=2.25.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://sim.ai"
|
||||
Documentation = "https://docs.sim.ai"
|
||||
Repository = "https://github.com/simstudioai/sim"
|
||||
"Bug Reports" = "https://github.com/simstudioai/sim/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["simstudio*"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ['py38', 'py39', 'py310', 'py311', 'py312']
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length = 100
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.8"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
warn_no_return = true
|
||||
warn_unreachable = true
|
||||
strict_equality = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
@@ -0,0 +1,51 @@
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
with open("README.md", "r", encoding="utf-8") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
setup(
|
||||
name="simstudio-sdk",
|
||||
version="0.1.1",
|
||||
author="Sim",
|
||||
author_email="help@sim.ai",
|
||||
description="Sim SDK - Execute workflows programmatically",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/simstudioai/sim",
|
||||
packages=find_packages(),
|
||||
classifiers=[
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
],
|
||||
python_requires=">=3.8",
|
||||
install_requires=[
|
||||
"requests>=2.25.0",
|
||||
"typing-extensions>=4.0.0; python_version<'3.10'",
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
"pytest>=6.0.0",
|
||||
"pytest-asyncio>=0.18.0",
|
||||
"black>=22.0.0",
|
||||
"flake8>=4.0.0",
|
||||
"mypy>=0.910",
|
||||
],
|
||||
"test": [
|
||||
"pytest>=6.0.0",
|
||||
],
|
||||
},
|
||||
keywords=["simstudio", "ai", "workflow", "sdk", "api", "automation"],
|
||||
project_urls={
|
||||
"Bug Reports": "https://github.com/simstudioai/sim/issues",
|
||||
"Source": "https://github.com/simstudioai/sim",
|
||||
"Documentation": "https://docs.sim.ai",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,568 @@
|
||||
"""
|
||||
Sim SDK for Python
|
||||
|
||||
Official Python SDK for Sim, allowing you to execute workflows programmatically.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from dataclasses import dataclass
|
||||
import time
|
||||
import random
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
__version__ = "0.1.2"
|
||||
__all__ = [
|
||||
"SimStudioClient",
|
||||
"SimStudioError",
|
||||
"WorkflowExecutionResult",
|
||||
"WorkflowStatus",
|
||||
"AsyncExecutionResult",
|
||||
"RateLimitInfo",
|
||||
"UsageLimits",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowExecutionResult:
|
||||
"""Result of a workflow execution."""
|
||||
success: bool
|
||||
output: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
logs: Optional[list] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
trace_spans: Optional[list] = None
|
||||
total_duration: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowStatus:
|
||||
"""Status of a workflow."""
|
||||
is_deployed: bool
|
||||
deployed_at: Optional[str] = None
|
||||
needs_redeployment: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class AsyncExecutionResult:
|
||||
"""Result of an async workflow execution."""
|
||||
success: bool
|
||||
job_id: str
|
||||
status_url: str
|
||||
execution_id: Optional[str] = None
|
||||
message: str = ""
|
||||
async_execution: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitInfo:
|
||||
"""Rate limit information from API response headers."""
|
||||
limit: int
|
||||
remaining: int
|
||||
reset: int
|
||||
retry_after: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsageLimits:
|
||||
"""Usage limits and quota information."""
|
||||
success: bool
|
||||
rate_limit: Dict[str, Any]
|
||||
usage: Dict[str, Any]
|
||||
|
||||
|
||||
class SimStudioError(Exception):
|
||||
"""Exception raised for Sim API errors."""
|
||||
|
||||
def __init__(self, message: str, code: Optional[str] = None, status: Optional[int] = None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
class SimStudioClient:
|
||||
"""
|
||||
Sim API client for executing workflows programmatically.
|
||||
|
||||
Args:
|
||||
api_key: Your Sim API key
|
||||
base_url: Base URL for the Sim API (defaults to https://sim.ai)
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str = "https://sim.ai"):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({
|
||||
'X-API-Key': self.api_key,
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
self._rate_limit_info: Optional[RateLimitInfo] = None
|
||||
|
||||
def _convert_files_to_base64(self, value: Any) -> Any:
|
||||
"""
|
||||
Convert file objects in input to API format (base64).
|
||||
Recursively processes nested dicts and lists.
|
||||
"""
|
||||
import base64
|
||||
|
||||
# Check if this is a file-like object
|
||||
if hasattr(value, 'read') and callable(value.read):
|
||||
# Save current position if seekable
|
||||
initial_pos = value.tell() if hasattr(value, 'tell') else None
|
||||
|
||||
# Read file bytes
|
||||
file_bytes = value.read()
|
||||
|
||||
# Restore position if seekable
|
||||
if initial_pos is not None and hasattr(value, 'seek'):
|
||||
value.seek(initial_pos)
|
||||
|
||||
# Encode to base64
|
||||
base64_data = base64.b64encode(file_bytes).decode('utf-8')
|
||||
|
||||
# Get file metadata
|
||||
filename = getattr(value, 'name', 'file')
|
||||
if isinstance(filename, str):
|
||||
filename = os.path.basename(filename)
|
||||
|
||||
content_type = getattr(value, 'content_type', 'application/octet-stream')
|
||||
|
||||
return {
|
||||
'type': 'file',
|
||||
'data': f'data:{content_type};base64,{base64_data}',
|
||||
'name': filename,
|
||||
'mime': content_type
|
||||
}
|
||||
|
||||
# Recursively process lists
|
||||
if isinstance(value, list):
|
||||
return [self._convert_files_to_base64(item) for item in value]
|
||||
|
||||
# Recursively process dicts
|
||||
if isinstance(value, dict):
|
||||
return {k: self._convert_files_to_base64(v) for k, v in value.items()}
|
||||
|
||||
return value
|
||||
|
||||
def execute_workflow(
|
||||
self,
|
||||
workflow_id: str,
|
||||
input: Optional[Any] = None,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
stream: Optional[bool] = None,
|
||||
selected_outputs: Optional[list] = None,
|
||||
async_execution: Optional[bool] = None
|
||||
) -> Union[WorkflowExecutionResult, AsyncExecutionResult]:
|
||||
"""
|
||||
Execute a workflow with optional input data.
|
||||
If async_execution is True, returns immediately with a task ID.
|
||||
|
||||
File objects in input will be automatically detected and converted to base64.
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to execute
|
||||
input: Input data to pass to the workflow. Can be a dict (spread at root level),
|
||||
primitive value (string, number, bool), or list (wrapped in 'input' field).
|
||||
File-like objects within dicts are automatically converted to base64.
|
||||
timeout: Timeout in seconds (default: 30.0)
|
||||
stream: Enable streaming responses (default: None)
|
||||
selected_outputs: Block outputs to stream (e.g., ["agent1.content"])
|
||||
async_execution: Execute asynchronously (default: None)
|
||||
|
||||
Returns:
|
||||
WorkflowExecutionResult or AsyncExecutionResult object
|
||||
|
||||
Raises:
|
||||
SimStudioError: If the workflow execution fails
|
||||
"""
|
||||
url = f"{self.base_url}/api/workflows/{workflow_id}/execute"
|
||||
|
||||
# Build headers - async execution uses X-Execution-Mode header
|
||||
headers = self._session.headers.copy()
|
||||
if async_execution:
|
||||
headers['X-Execution-Mode'] = 'async'
|
||||
|
||||
try:
|
||||
# Build JSON body - spread dict inputs at root level, wrap primitives/lists in 'input' field
|
||||
body = {}
|
||||
if input is not None:
|
||||
if isinstance(input, dict):
|
||||
# Dict input: spread at root level (matches curl/API behavior)
|
||||
body = input.copy()
|
||||
else:
|
||||
# Primitive or list input: wrap in 'input' field
|
||||
body = {'input': input}
|
||||
|
||||
# Convert any file objects in the input to base64 format
|
||||
body = self._convert_files_to_base64(body)
|
||||
|
||||
if stream is not None:
|
||||
body['stream'] = stream
|
||||
if selected_outputs is not None:
|
||||
body['selectedOutputs'] = selected_outputs
|
||||
|
||||
response = self._session.post(
|
||||
url,
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# Update rate limit info
|
||||
self._update_rate_limit_info(response)
|
||||
|
||||
# Handle rate limiting
|
||||
if response.status_code == 429:
|
||||
retry_after = self._rate_limit_info.retry_after if self._rate_limit_info else 1000
|
||||
raise SimStudioError(
|
||||
f'Rate limit exceeded. Retry after {retry_after}ms',
|
||||
'RATE_LIMIT_EXCEEDED',
|
||||
429
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_message = error_data.get('error', f'HTTP {response.status_code}: {response.reason}')
|
||||
error_code = error_data.get('code')
|
||||
except (ValueError, KeyError):
|
||||
error_message = f'HTTP {response.status_code}: {response.reason}'
|
||||
error_code = None
|
||||
|
||||
raise SimStudioError(error_message, error_code, response.status_code)
|
||||
|
||||
result_data = response.json()
|
||||
|
||||
# Check if this is an async execution response (202 status)
|
||||
if response.status_code == 202 and 'jobId' in result_data:
|
||||
return AsyncExecutionResult(
|
||||
success=result_data.get('success', True),
|
||||
job_id=result_data['jobId'],
|
||||
status_url=result_data['statusUrl'],
|
||||
execution_id=result_data.get('executionId'),
|
||||
message=result_data.get('message', ''),
|
||||
async_execution=result_data.get('async', True)
|
||||
)
|
||||
|
||||
return WorkflowExecutionResult(
|
||||
success=result_data['success'],
|
||||
output=result_data.get('output'),
|
||||
error=result_data.get('error'),
|
||||
logs=result_data.get('logs'),
|
||||
metadata=result_data.get('metadata'),
|
||||
trace_spans=result_data.get('traceSpans'),
|
||||
total_duration=result_data.get('totalDuration')
|
||||
)
|
||||
|
||||
except requests.Timeout:
|
||||
raise SimStudioError(f'Workflow execution timed out after {timeout} seconds', 'TIMEOUT')
|
||||
except requests.RequestException as e:
|
||||
raise SimStudioError(f'Failed to execute workflow: {str(e)}', 'EXECUTION_ERROR')
|
||||
|
||||
def get_workflow_status(self, workflow_id: str) -> WorkflowStatus:
|
||||
"""
|
||||
Get the status of a workflow (deployment status, etc.).
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow
|
||||
|
||||
Returns:
|
||||
WorkflowStatus object containing the workflow status
|
||||
|
||||
Raises:
|
||||
SimStudioError: If getting the status fails
|
||||
"""
|
||||
url = f"{self.base_url}/api/workflows/{workflow_id}/status"
|
||||
|
||||
try:
|
||||
response = self._session.get(url)
|
||||
|
||||
if not response.ok:
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_message = error_data.get('error', f'HTTP {response.status_code}: {response.reason}')
|
||||
error_code = error_data.get('code')
|
||||
except (ValueError, KeyError):
|
||||
error_message = f'HTTP {response.status_code}: {response.reason}'
|
||||
error_code = None
|
||||
|
||||
raise SimStudioError(error_message, error_code, response.status_code)
|
||||
|
||||
status_data = response.json()
|
||||
|
||||
return WorkflowStatus(
|
||||
is_deployed=status_data.get('isDeployed', False),
|
||||
deployed_at=status_data.get('deployedAt'),
|
||||
needs_redeployment=status_data.get('needsRedeployment', False)
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
raise SimStudioError(f'Failed to get workflow status: {str(e)}', 'STATUS_ERROR')
|
||||
|
||||
def validate_workflow(self, workflow_id: str) -> bool:
|
||||
"""
|
||||
Validate that a workflow is ready for execution.
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow
|
||||
|
||||
Returns:
|
||||
True if the workflow is deployed and ready, False otherwise
|
||||
"""
|
||||
try:
|
||||
status = self.get_workflow_status(workflow_id)
|
||||
return status.is_deployed
|
||||
except SimStudioError:
|
||||
return False
|
||||
|
||||
def execute_workflow_sync(
|
||||
self,
|
||||
workflow_id: str,
|
||||
input: Optional[Any] = None,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
stream: Optional[bool] = None,
|
||||
selected_outputs: Optional[list] = None
|
||||
) -> WorkflowExecutionResult:
|
||||
"""
|
||||
Execute a workflow synchronously (ensures non-async mode).
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to execute
|
||||
input: Input data to pass to the workflow (can include file-like objects)
|
||||
timeout: Timeout for the initial request in seconds
|
||||
stream: Enable streaming responses (default: None)
|
||||
selected_outputs: Block outputs to stream (e.g., ["agent1.content"])
|
||||
|
||||
Returns:
|
||||
WorkflowExecutionResult object containing the execution result
|
||||
|
||||
Raises:
|
||||
SimStudioError: If the workflow execution fails
|
||||
"""
|
||||
return self.execute_workflow(
|
||||
workflow_id,
|
||||
input,
|
||||
timeout=timeout,
|
||||
stream=stream,
|
||||
selected_outputs=selected_outputs,
|
||||
async_execution=False
|
||||
)
|
||||
|
||||
def set_api_key(self, api_key: str) -> None:
|
||||
"""
|
||||
Update the API key.
|
||||
|
||||
Args:
|
||||
api_key: New API key
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self._session.headers.update({'X-API-Key': api_key})
|
||||
|
||||
def set_base_url(self, base_url: str) -> None:
|
||||
"""
|
||||
Update the base URL.
|
||||
|
||||
Args:
|
||||
base_url: New base URL
|
||||
"""
|
||||
self.base_url = base_url.rstrip('/')
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying HTTP session."""
|
||||
self._session.close()
|
||||
|
||||
def get_job_status(self, job_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the status of an async job.
|
||||
|
||||
Args:
|
||||
job_id: The job ID returned from async execution
|
||||
|
||||
Returns:
|
||||
Dictionary containing the job status
|
||||
|
||||
Raises:
|
||||
SimStudioError: If getting the status fails
|
||||
"""
|
||||
url = f"{self.base_url}/api/jobs/{job_id}"
|
||||
|
||||
try:
|
||||
response = self._session.get(url)
|
||||
|
||||
self._update_rate_limit_info(response)
|
||||
|
||||
if not response.ok:
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_message = error_data.get('error', f'HTTP {response.status_code}: {response.reason}')
|
||||
error_code = error_data.get('code')
|
||||
except (ValueError, KeyError):
|
||||
error_message = f'HTTP {response.status_code}: {response.reason}'
|
||||
error_code = None
|
||||
|
||||
raise SimStudioError(error_message, error_code, response.status_code)
|
||||
|
||||
return response.json()
|
||||
|
||||
except requests.RequestException as e:
|
||||
raise SimStudioError(f'Failed to get job status: {str(e)}', 'STATUS_ERROR')
|
||||
|
||||
def execute_with_retry(
|
||||
self,
|
||||
workflow_id: str,
|
||||
input: Optional[Any] = None,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
stream: Optional[bool] = None,
|
||||
selected_outputs: Optional[list] = None,
|
||||
async_execution: Optional[bool] = None,
|
||||
max_retries: int = 3,
|
||||
initial_delay: float = 1.0,
|
||||
max_delay: float = 30.0,
|
||||
backoff_multiplier: float = 2.0
|
||||
) -> Union[WorkflowExecutionResult, AsyncExecutionResult]:
|
||||
"""
|
||||
Execute workflow with automatic retry on rate limit.
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to execute
|
||||
input: Input data to pass to the workflow (can include file-like objects)
|
||||
timeout: Timeout in seconds
|
||||
stream: Enable streaming responses
|
||||
selected_outputs: Block outputs to stream
|
||||
async_execution: Execute asynchronously
|
||||
max_retries: Maximum number of retries (default: 3)
|
||||
initial_delay: Initial delay in seconds (default: 1.0)
|
||||
max_delay: Maximum delay in seconds (default: 30.0)
|
||||
backoff_multiplier: Backoff multiplier (default: 2.0)
|
||||
|
||||
Returns:
|
||||
WorkflowExecutionResult or AsyncExecutionResult object
|
||||
|
||||
Raises:
|
||||
SimStudioError: If max retries exceeded or other error occurs
|
||||
"""
|
||||
last_error = None
|
||||
delay = initial_delay
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return self.execute_workflow(
|
||||
workflow_id,
|
||||
input,
|
||||
timeout=timeout,
|
||||
stream=stream,
|
||||
selected_outputs=selected_outputs,
|
||||
async_execution=async_execution
|
||||
)
|
||||
except SimStudioError as e:
|
||||
if e.code != 'RATE_LIMIT_EXCEEDED':
|
||||
raise
|
||||
|
||||
last_error = e
|
||||
|
||||
# Don't retry after last attempt
|
||||
if attempt == max_retries:
|
||||
break
|
||||
|
||||
# Use retry-after if provided, otherwise use exponential backoff
|
||||
wait_time = (
|
||||
self._rate_limit_info.retry_after / 1000
|
||||
if self._rate_limit_info and self._rate_limit_info.retry_after
|
||||
else min(delay, max_delay)
|
||||
)
|
||||
|
||||
# Add jitter (±25%)
|
||||
jitter = wait_time * (0.75 + random.random() * 0.5)
|
||||
|
||||
time.sleep(jitter)
|
||||
|
||||
# Exponential backoff for next attempt
|
||||
delay *= backoff_multiplier
|
||||
|
||||
raise last_error or SimStudioError('Max retries exceeded', 'MAX_RETRIES_EXCEEDED')
|
||||
|
||||
def get_rate_limit_info(self) -> Optional[RateLimitInfo]:
|
||||
"""
|
||||
Get current rate limit information.
|
||||
|
||||
Returns:
|
||||
RateLimitInfo object or None if no rate limit info available
|
||||
"""
|
||||
return self._rate_limit_info
|
||||
|
||||
def _update_rate_limit_info(self, response: requests.Response) -> None:
|
||||
"""
|
||||
Update rate limit info from response headers.
|
||||
|
||||
Args:
|
||||
response: The response object to extract headers from
|
||||
"""
|
||||
limit = response.headers.get('x-ratelimit-limit')
|
||||
remaining = response.headers.get('x-ratelimit-remaining')
|
||||
reset = response.headers.get('x-ratelimit-reset')
|
||||
retry_after = response.headers.get('retry-after')
|
||||
|
||||
if limit or remaining or reset:
|
||||
self._rate_limit_info = RateLimitInfo(
|
||||
limit=int(limit) if limit else 0,
|
||||
remaining=int(remaining) if remaining else 0,
|
||||
reset=int(reset) if reset else 0,
|
||||
retry_after=int(retry_after) * 1000 if retry_after else None
|
||||
)
|
||||
|
||||
def get_usage_limits(self) -> UsageLimits:
|
||||
"""
|
||||
Get current usage limits and quota information.
|
||||
|
||||
Returns:
|
||||
UsageLimits object containing usage and quota data
|
||||
|
||||
Raises:
|
||||
SimStudioError: If getting usage limits fails
|
||||
"""
|
||||
url = f"{self.base_url}/api/users/me/usage-limits"
|
||||
|
||||
try:
|
||||
response = self._session.get(url)
|
||||
|
||||
self._update_rate_limit_info(response)
|
||||
|
||||
if not response.ok:
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_message = error_data.get('error', f'HTTP {response.status_code}: {response.reason}')
|
||||
error_code = error_data.get('code')
|
||||
except (ValueError, KeyError):
|
||||
error_message = f'HTTP {response.status_code}: {response.reason}'
|
||||
error_code = None
|
||||
|
||||
raise SimStudioError(error_message, error_code, response.status_code)
|
||||
|
||||
data = response.json()
|
||||
|
||||
return UsageLimits(
|
||||
success=data.get('success', True),
|
||||
rate_limit=data.get('rateLimit', {}),
|
||||
usage=data.get('usage', {})
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
raise SimStudioError(f'Failed to get usage limits: {str(e)}', 'USAGE_ERROR')
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit."""
|
||||
self.close()
|
||||
|
||||
|
||||
# For backward compatibility
|
||||
Client = SimStudioClient
|
||||
@@ -0,0 +1,537 @@
|
||||
"""
|
||||
Tests for the Sim Python SDK
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from simstudio import SimStudioClient, SimStudioError, WorkflowExecutionResult, WorkflowStatus
|
||||
|
||||
|
||||
def test_simstudio_client_initialization():
|
||||
"""Test SimStudioClient initialization."""
|
||||
client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai")
|
||||
assert client.api_key == "test-api-key"
|
||||
assert client.base_url == "https://test.sim.ai"
|
||||
|
||||
|
||||
def test_simstudio_client_default_base_url():
|
||||
"""Test SimStudioClient with default base URL."""
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
assert client.api_key == "test-api-key"
|
||||
assert client.base_url == "https://sim.ai"
|
||||
|
||||
|
||||
def test_set_api_key():
|
||||
"""Test setting a new API key."""
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
client.set_api_key("new-api-key")
|
||||
assert client.api_key == "new-api-key"
|
||||
|
||||
|
||||
def test_set_base_url():
|
||||
"""Test setting a new base URL."""
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
client.set_base_url("https://new.sim.ai/")
|
||||
assert client.base_url == "https://new.sim.ai"
|
||||
|
||||
|
||||
def test_set_base_url_strips_trailing_slash():
|
||||
"""Test that base URL strips trailing slash."""
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
client.set_base_url("https://test.sim.ai/")
|
||||
assert client.base_url == "https://test.sim.ai"
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.get')
|
||||
def test_validate_workflow_returns_false_on_error(mock_get):
|
||||
"""Test that validate_workflow returns False when request fails."""
|
||||
mock_get.side_effect = SimStudioError("Network error")
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
result = client.validate_workflow("test-workflow-id")
|
||||
|
||||
assert result is False
|
||||
mock_get.assert_called_once_with("https://sim.ai/api/workflows/test-workflow-id/status")
|
||||
|
||||
|
||||
def test_simstudio_error():
|
||||
"""Test SimStudioError creation."""
|
||||
error = SimStudioError("Test error", "TEST_CODE", 400)
|
||||
assert str(error) == "Test error"
|
||||
assert error.code == "TEST_CODE"
|
||||
assert error.status == 400
|
||||
|
||||
|
||||
def test_workflow_execution_result():
|
||||
"""Test WorkflowExecutionResult data class."""
|
||||
result = WorkflowExecutionResult(
|
||||
success=True,
|
||||
output={"data": "test"},
|
||||
metadata={"duration": 1000}
|
||||
)
|
||||
assert result.success is True
|
||||
assert result.output == {"data": "test"}
|
||||
assert result.metadata == {"duration": 1000}
|
||||
|
||||
|
||||
def test_workflow_status():
|
||||
"""Test WorkflowStatus data class."""
|
||||
status = WorkflowStatus(
|
||||
is_deployed=True,
|
||||
deployed_at="2023-01-01T00:00:00Z",
|
||||
needs_redeployment=False
|
||||
)
|
||||
assert status.is_deployed is True
|
||||
assert status.deployed_at == "2023-01-01T00:00:00Z"
|
||||
assert status.needs_redeployment is False
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.close')
|
||||
def test_context_manager(mock_close):
|
||||
"""Test SimStudioClient as context manager."""
|
||||
with SimStudioClient(api_key="test-api-key") as client:
|
||||
assert client.api_key == "test-api-key"
|
||||
mock_close.assert_called_once()
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_async_execution_returns_job_id(mock_post):
|
||||
"""Test async execution returns AsyncExecutionResult."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.status_code = 202
|
||||
mock_response.json.return_value = {
|
||||
"success": True,
|
||||
"jobId": "job-123",
|
||||
"statusUrl": "https://test.sim.ai/api/jobs/job-123",
|
||||
"executionId": "execution-123",
|
||||
"message": "Workflow execution started",
|
||||
"async": True
|
||||
}
|
||||
mock_response.headers.get.return_value = None
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
result = client.execute_workflow(
|
||||
"workflow-id",
|
||||
{"message": "Hello"},
|
||||
async_execution=True
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.job_id == "job-123"
|
||||
assert result.status_url == "https://test.sim.ai/api/jobs/job-123"
|
||||
assert result.execution_id == "execution-123"
|
||||
assert result.async_execution is True
|
||||
|
||||
call_args = mock_post.call_args
|
||||
assert call_args[1]["headers"]["X-Execution-Mode"] == "async"
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_sync_execution_returns_result(mock_post):
|
||||
"""Test sync execution returns WorkflowExecutionResult."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"success": True,
|
||||
"output": {"result": "completed"},
|
||||
"logs": []
|
||||
}
|
||||
mock_response.headers.get.return_value = None
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
result = client.execute_workflow(
|
||||
"workflow-id",
|
||||
{"message": "Hello"},
|
||||
async_execution=False
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.output == {"result": "completed"}
|
||||
assert not hasattr(result, 'task_id')
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_async_header_not_set_when_false(mock_post):
|
||||
"""Test X-Execution-Mode header is not set when async_execution is None."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"success": True, "output": {}}
|
||||
mock_response.headers.get.return_value = None
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
client.execute_workflow("workflow-id", {"message": "Hello"})
|
||||
|
||||
call_args = mock_post.call_args
|
||||
assert "X-Execution-Mode" not in call_args[1]["headers"]
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.get')
|
||||
def test_get_job_status_success(mock_get):
|
||||
"""Test getting job status."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.json.return_value = {
|
||||
"success": True,
|
||||
"taskId": "task-123",
|
||||
"status": "completed",
|
||||
"metadata": {
|
||||
"startedAt": "2024-01-01T00:00:00Z",
|
||||
"completedAt": "2024-01-01T00:01:00Z",
|
||||
"duration": 60000
|
||||
},
|
||||
"output": {"result": "done"}
|
||||
}
|
||||
mock_response.headers.get.return_value = None
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai")
|
||||
result = client.get_job_status("task-123")
|
||||
|
||||
assert result["taskId"] == "task-123"
|
||||
assert result["status"] == "completed"
|
||||
assert result["output"]["result"] == "done"
|
||||
mock_get.assert_called_once_with("https://test.sim.ai/api/jobs/task-123")
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.get')
|
||||
def test_get_job_status_not_found(mock_get):
|
||||
"""Test job not found error."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = False
|
||||
mock_response.status_code = 404
|
||||
mock_response.reason = "Not Found"
|
||||
mock_response.json.return_value = {
|
||||
"error": "Job not found",
|
||||
"code": "JOB_NOT_FOUND"
|
||||
}
|
||||
mock_response.headers.get.return_value = None
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
|
||||
with pytest.raises(SimStudioError) as exc_info:
|
||||
client.get_job_status("invalid-task")
|
||||
assert "Job not found" in str(exc_info.value)
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
@patch('simstudio.time.sleep')
|
||||
def test_execute_with_retry_success_first_attempt(mock_sleep, mock_post):
|
||||
"""Test retry succeeds on first attempt."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"success": True,
|
||||
"output": {"result": "success"}
|
||||
}
|
||||
mock_response.headers.get.return_value = None
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
result = client.execute_with_retry("workflow-id", {"message": "test"})
|
||||
|
||||
assert result.success is True
|
||||
assert mock_post.call_count == 1
|
||||
assert mock_sleep.call_count == 0
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
@patch('simstudio.time.sleep')
|
||||
def test_execute_with_retry_retries_on_rate_limit(mock_sleep, mock_post):
|
||||
"""Test retry retries on rate limit error."""
|
||||
rate_limit_response = Mock()
|
||||
rate_limit_response.ok = False
|
||||
rate_limit_response.status_code = 429
|
||||
rate_limit_response.json.return_value = {
|
||||
"error": "Rate limit exceeded",
|
||||
"code": "RATE_LIMIT_EXCEEDED"
|
||||
}
|
||||
import time
|
||||
rate_limit_response.headers.get.side_effect = lambda h: {
|
||||
'retry-after': '1',
|
||||
'x-ratelimit-limit': '100',
|
||||
'x-ratelimit-remaining': '0',
|
||||
'x-ratelimit-reset': str(int(time.time()) + 60)
|
||||
}.get(h)
|
||||
|
||||
success_response = Mock()
|
||||
success_response.ok = True
|
||||
success_response.status_code = 200
|
||||
success_response.json.return_value = {
|
||||
"success": True,
|
||||
"output": {"result": "success"}
|
||||
}
|
||||
success_response.headers.get.return_value = None
|
||||
|
||||
mock_post.side_effect = [rate_limit_response, success_response]
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
result = client.execute_with_retry(
|
||||
"workflow-id",
|
||||
{"message": "test"},
|
||||
max_retries=3,
|
||||
initial_delay=0.01
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert mock_post.call_count == 2
|
||||
assert mock_sleep.call_count == 1
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
@patch('simstudio.time.sleep')
|
||||
def test_execute_with_retry_max_retries_exceeded(mock_sleep, mock_post):
|
||||
"""Test retry throws after max retries."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = False
|
||||
mock_response.status_code = 429
|
||||
mock_response.json.return_value = {
|
||||
"error": "Rate limit exceeded",
|
||||
"code": "RATE_LIMIT_EXCEEDED"
|
||||
}
|
||||
mock_response.headers.get.side_effect = lambda h: '1' if h == 'retry-after' else None
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
|
||||
with pytest.raises(SimStudioError) as exc_info:
|
||||
client.execute_with_retry(
|
||||
"workflow-id",
|
||||
{"message": "test"},
|
||||
max_retries=2,
|
||||
initial_delay=0.01
|
||||
)
|
||||
|
||||
assert "Rate limit exceeded" in str(exc_info.value)
|
||||
assert mock_post.call_count == 3 # Initial + 2 retries
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_execute_with_retry_no_retry_on_other_errors(mock_post):
|
||||
"""Test retry does not retry on non-rate-limit errors."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = False
|
||||
mock_response.status_code = 500
|
||||
mock_response.reason = "Internal Server Error"
|
||||
mock_response.json.return_value = {
|
||||
"error": "Server error",
|
||||
"code": "INTERNAL_ERROR"
|
||||
}
|
||||
mock_response.headers.get.return_value = None
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
|
||||
with pytest.raises(SimStudioError) as exc_info:
|
||||
client.execute_with_retry("workflow-id", {"message": "test"})
|
||||
|
||||
assert "Server error" in str(exc_info.value)
|
||||
assert mock_post.call_count == 1 # No retries
|
||||
|
||||
|
||||
def test_get_rate_limit_info_returns_none_initially():
|
||||
"""Test rate limit info is None before any API calls."""
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
info = client.get_rate_limit_info()
|
||||
assert info is None
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_get_rate_limit_info_after_api_call(mock_post):
|
||||
"""Test rate limit info is populated after API call."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"success": True, "output": {}}
|
||||
mock_response.headers.get.side_effect = lambda h: {
|
||||
'x-ratelimit-limit': '100',
|
||||
'x-ratelimit-remaining': '95',
|
||||
'x-ratelimit-reset': '1704067200'
|
||||
}.get(h)
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
client.execute_workflow("workflow-id", {})
|
||||
|
||||
info = client.get_rate_limit_info()
|
||||
assert info is not None
|
||||
assert info.limit == 100
|
||||
assert info.remaining == 95
|
||||
assert info.reset == 1704067200
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.get')
|
||||
def test_get_usage_limits_success(mock_get):
|
||||
"""Test getting usage limits."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.json.return_value = {
|
||||
"success": True,
|
||||
"rateLimit": {
|
||||
"sync": {
|
||||
"isLimited": False,
|
||||
"limit": 100,
|
||||
"remaining": 95,
|
||||
"resetAt": "2024-01-01T01:00:00Z"
|
||||
},
|
||||
"async": {
|
||||
"isLimited": False,
|
||||
"limit": 50,
|
||||
"remaining": 48,
|
||||
"resetAt": "2024-01-01T01:00:00Z"
|
||||
},
|
||||
"authType": "api"
|
||||
},
|
||||
"usage": {
|
||||
"currentPeriodCost": 1.23,
|
||||
"limit": 100.0,
|
||||
"plan": "pro"
|
||||
}
|
||||
}
|
||||
mock_response.headers.get.return_value = None
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai")
|
||||
result = client.get_usage_limits()
|
||||
|
||||
assert result.success is True
|
||||
assert result.rate_limit["sync"]["limit"] == 100
|
||||
assert result.rate_limit["async"]["limit"] == 50
|
||||
assert result.usage["currentPeriodCost"] == 1.23
|
||||
assert result.usage["plan"] == "pro"
|
||||
mock_get.assert_called_once_with("https://test.sim.ai/api/users/me/usage-limits")
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.get')
|
||||
def test_get_usage_limits_unauthorized(mock_get):
|
||||
"""Test usage limits with invalid API key."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = False
|
||||
mock_response.status_code = 401
|
||||
mock_response.reason = "Unauthorized"
|
||||
mock_response.json.return_value = {
|
||||
"error": "Invalid API key",
|
||||
"code": "UNAUTHORIZED"
|
||||
}
|
||||
mock_response.headers.get.return_value = None
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="invalid-key")
|
||||
|
||||
with pytest.raises(SimStudioError) as exc_info:
|
||||
client.get_usage_limits()
|
||||
assert "Invalid API key" in str(exc_info.value)
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_execute_workflow_with_stream_and_selected_outputs(mock_post):
|
||||
"""Test execution with stream and selectedOutputs parameters."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"success": True, "output": {}}
|
||||
mock_response.headers.get.return_value = None
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
client.execute_workflow(
|
||||
"workflow-id",
|
||||
{"message": "test"},
|
||||
stream=True,
|
||||
selected_outputs=["agent1.content", "agent2.content"]
|
||||
)
|
||||
|
||||
call_args = mock_post.call_args
|
||||
request_body = call_args[1]["json"]
|
||||
|
||||
assert request_body["message"] == "test"
|
||||
assert request_body["stream"] is True
|
||||
assert request_body["selectedOutputs"] == ["agent1.content", "agent2.content"]
|
||||
|
||||
|
||||
# Tests for primitive and list inputs
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_execute_workflow_with_string_input(mock_post):
|
||||
"""Test execution with primitive string input wraps in input field."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"success": True, "output": {}}
|
||||
mock_response.headers.get.return_value = None
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
client.execute_workflow("workflow-id", "NVDA")
|
||||
|
||||
call_args = mock_post.call_args
|
||||
request_body = call_args[1]["json"]
|
||||
|
||||
assert request_body["input"] == "NVDA"
|
||||
assert "0" not in request_body # Should not spread string characters
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_execute_workflow_with_number_input(mock_post):
|
||||
"""Test execution with primitive number input wraps in input field."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"success": True, "output": {}}
|
||||
mock_response.headers.get.return_value = None
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
client.execute_workflow("workflow-id", 42)
|
||||
|
||||
call_args = mock_post.call_args
|
||||
request_body = call_args[1]["json"]
|
||||
|
||||
assert request_body["input"] == 42
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_execute_workflow_with_list_input(mock_post):
|
||||
"""Test execution with list input wraps in input field."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"success": True, "output": {}}
|
||||
mock_response.headers.get.return_value = None
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
client.execute_workflow("workflow-id", ["NVDA", "AAPL", "GOOG"])
|
||||
|
||||
call_args = mock_post.call_args
|
||||
request_body = call_args[1]["json"]
|
||||
|
||||
assert request_body["input"] == ["NVDA", "AAPL", "GOOG"]
|
||||
assert "0" not in request_body # Should not spread list
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_execute_workflow_with_dict_input_spreads_at_root(mock_post):
|
||||
"""Test execution with dict input spreads at root level."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"success": True, "output": {}}
|
||||
mock_response.headers.get.return_value = None
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
client.execute_workflow("workflow-id", {"ticker": "NVDA", "quantity": 100})
|
||||
|
||||
call_args = mock_post.call_args
|
||||
request_body = call_args[1]["json"]
|
||||
|
||||
assert request_body["ticker"] == "NVDA"
|
||||
assert request_body["quantity"] == 100
|
||||
assert "input" not in request_body # Should not wrap in input field
|
||||
Reference in New Issue
Block a user