mirror of
https://github.com/FuzzingLabs/fuzzforge_ai.git
synced 2026-07-10 06:58:46 +02:00
Initial commit
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# FuzzForge SDK
|
||||
|
||||
A comprehensive Python SDK for the FuzzForge security testing workflow orchestration platform.
|
||||
|
||||
## Features
|
||||
|
||||
- **Complete API Coverage**: All FuzzForge API endpoints supported
|
||||
- **Async & Sync**: Both synchronous and asynchronous client methods
|
||||
- **Real-time Monitoring**: WebSocket and Server-Sent Events for live fuzzing updates
|
||||
- **Type Safety**: Full Pydantic model validation for all data structures
|
||||
- **Error Handling**: Comprehensive exception hierarchy with detailed error information
|
||||
- **Utility Functions**: Helper functions for path validation, SARIF processing, and more
|
||||
|
||||
## Installation
|
||||
|
||||
Install using uv (recommended):
|
||||
|
||||
```bash
|
||||
uv add fuzzforge-sdk
|
||||
```
|
||||
|
||||
Or with pip:
|
||||
|
||||
```bash
|
||||
pip install fuzzforge-sdk
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from fuzzforge_sdk import FuzzForgeClient
|
||||
from fuzzforge_sdk.utils import create_workflow_submission
|
||||
|
||||
# Initialize client
|
||||
client = FuzzForgeClient(base_url="http://localhost:8000")
|
||||
|
||||
# List available workflows
|
||||
workflows = client.list_workflows()
|
||||
|
||||
# Submit a workflow
|
||||
submission = create_workflow_submission(
|
||||
target_path="/path/to/your/project",
|
||||
volume_mode="ro",
|
||||
timeout=300
|
||||
)
|
||||
|
||||
response = client.submit_workflow("static-analysis", submission)
|
||||
|
||||
# Wait for completion and get results
|
||||
final_status = client.wait_for_completion(response.run_id)
|
||||
findings = client.get_run_findings(response.run_id)
|
||||
|
||||
client.close()
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
The `examples/` directory contains complete working examples:
|
||||
|
||||
- **`basic_workflow.py`**: Simple workflow submission and monitoring
|
||||
- **`fuzzing_monitor.py`**: Real-time fuzzing monitoring with WebSocket/SSE
|
||||
- **`batch_analysis.py`**: Batch analysis of multiple projects
|
||||
|
||||
## Development
|
||||
|
||||
Install with development dependencies:
|
||||
|
||||
```bash
|
||||
uv sync --extra dev
|
||||
```
|
||||
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
"""
|
||||
Basic workflow submission example.
|
||||
|
||||
This example demonstrates how to:
|
||||
1. Connect to FuzzForge API
|
||||
2. List available workflows
|
||||
3. Submit a workflow for analysis
|
||||
4. Monitor the run status
|
||||
5. Retrieve findings when complete
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fuzzforge_sdk import FuzzForgeClient, WorkflowSubmission
|
||||
from fuzzforge_sdk.utils import create_workflow_submission, format_sarif_summary, format_duration
|
||||
|
||||
|
||||
def main():
|
||||
"""Run basic workflow submission example."""
|
||||
# Initialize the client
|
||||
client = FuzzForgeClient(base_url="http://localhost:8000")
|
||||
|
||||
try:
|
||||
# Check API status
|
||||
print("🔗 Connecting to FuzzForge API...")
|
||||
status = client.get_api_status()
|
||||
print(f"✅ Connected to {status.name} v{status.version}")
|
||||
print(f"📊 {status.workflows_loaded} workflows loaded\n")
|
||||
|
||||
# List available workflows
|
||||
print("📋 Available workflows:")
|
||||
workflows = client.list_workflows()
|
||||
for workflow in workflows:
|
||||
print(f" • {workflow.name} v{workflow.version}")
|
||||
print(f" {workflow.description}")
|
||||
if workflow.tags:
|
||||
print(f" Tags: {', '.join(workflow.tags)}")
|
||||
print()
|
||||
|
||||
if not workflows:
|
||||
print("❌ No workflows available")
|
||||
return
|
||||
|
||||
# Select the first workflow for demo
|
||||
selected_workflow = workflows[0]
|
||||
print(f"🎯 Selected workflow: {selected_workflow.name}")
|
||||
|
||||
# Get workflow metadata
|
||||
metadata = client.get_workflow_metadata(selected_workflow.name)
|
||||
print(f"📝 Workflow metadata:")
|
||||
print(f" Author: {metadata.author}")
|
||||
print(f" Required modules: {metadata.required_modules}")
|
||||
print(f" Supported volume modes: {metadata.supported_volume_modes}")
|
||||
print()
|
||||
|
||||
# Prepare target path (use current directory as example)
|
||||
target_path = Path.cwd().absolute()
|
||||
print(f"🎯 Target path: {target_path}")
|
||||
|
||||
# Create workflow submission
|
||||
submission = create_workflow_submission(
|
||||
target_path=target_path,
|
||||
volume_mode="ro",
|
||||
timeout=300, # 5 minutes
|
||||
)
|
||||
|
||||
# Submit the workflow
|
||||
print(f"🚀 Submitting workflow '{selected_workflow.name}'...")
|
||||
response = client.submit_workflow(selected_workflow.name, submission)
|
||||
print(f"✅ Workflow submitted!")
|
||||
print(f" Run ID: {response.run_id}")
|
||||
print(f" Status: {response.status}")
|
||||
print()
|
||||
|
||||
# Monitor the run
|
||||
print("⏱️ Monitoring run progress...")
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
status = client.get_run_status(response.run_id)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
print(f" Status: {status.status} (elapsed: {format_duration(int(elapsed))})")
|
||||
|
||||
if status.is_completed:
|
||||
print("✅ Run completed successfully!")
|
||||
break
|
||||
elif status.is_failed:
|
||||
print("❌ Run failed!")
|
||||
print(f" Final status: {status.status}")
|
||||
return
|
||||
elif not status.is_running:
|
||||
print("⏸️ Run is not active")
|
||||
print(f" Current status: {status.status}")
|
||||
|
||||
# Wait before next check
|
||||
time.sleep(5)
|
||||
|
||||
print()
|
||||
|
||||
# Get findings
|
||||
print("📊 Retrieving findings...")
|
||||
try:
|
||||
findings = client.get_run_findings(response.run_id)
|
||||
print(f"✅ Findings retrieved for workflow: {findings.workflow}")
|
||||
|
||||
# Display SARIF summary
|
||||
sarif_summary = format_sarif_summary(findings.sarif)
|
||||
print(f"📈 {sarif_summary}")
|
||||
|
||||
# Display metadata
|
||||
if findings.metadata:
|
||||
print(f"🔍 Metadata:")
|
||||
for key, value in findings.metadata.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
print()
|
||||
|
||||
# Extract and display detailed findings
|
||||
from fuzzforge_sdk.utils import extract_sarif_results
|
||||
results = extract_sarif_results(findings.sarif)
|
||||
|
||||
if results:
|
||||
print("🔍 Detailed Findings:")
|
||||
print("=" * 60)
|
||||
|
||||
for i, result in enumerate(results, 1):
|
||||
print(f"\n📋 Finding #{i}")
|
||||
|
||||
# Rule information
|
||||
rule_id = result.get('ruleId', 'unknown')
|
||||
level = result.get('level', 'warning')
|
||||
message = result.get('message', {})
|
||||
|
||||
print(f" Rule ID: {rule_id}")
|
||||
print(f" Severity: {level.upper()}")
|
||||
|
||||
# Message
|
||||
if isinstance(message, dict):
|
||||
msg_text = message.get('text', 'No message')
|
||||
else:
|
||||
msg_text = str(message)
|
||||
print(f" Message: {msg_text}")
|
||||
|
||||
# Location information
|
||||
locations = result.get('locations', [])
|
||||
if locations:
|
||||
for loc in locations:
|
||||
physical_loc = loc.get('physicalLocation', {})
|
||||
artifact_loc = physical_loc.get('artifactLocation', {})
|
||||
region = physical_loc.get('region', {})
|
||||
|
||||
file_path = artifact_loc.get('uri', 'unknown file')
|
||||
start_line = region.get('startLine', 'unknown')
|
||||
start_col = region.get('startColumn', 'unknown')
|
||||
|
||||
print(f" Location: {file_path}:{start_line}:{start_col}")
|
||||
|
||||
# Show code snippet if available
|
||||
snippet = region.get('snippet', {})
|
||||
if snippet and isinstance(snippet, dict):
|
||||
snippet_text = snippet.get('text', '').strip()
|
||||
if snippet_text:
|
||||
print(f" Code: {snippet_text}")
|
||||
|
||||
# Additional properties
|
||||
properties = result.get('properties', {})
|
||||
if properties:
|
||||
print(f" Properties:")
|
||||
for prop_key, prop_value in properties.items():
|
||||
print(f" {prop_key}: {prop_value}")
|
||||
|
||||
print("-" * 40)
|
||||
|
||||
print(f"\n📁 Total findings: {len(results)}")
|
||||
|
||||
print("\n💾 Tip: Use save_sarif_to_file() to save findings to disk")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to retrieve findings: {e}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n⏹️ Interrupted by user")
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
async def async_main():
|
||||
"""Run basic workflow submission example (async version)."""
|
||||
# Initialize the async client
|
||||
async with FuzzForgeClient(base_url="http://localhost:8000") as client:
|
||||
try:
|
||||
# Check API status
|
||||
print("🔗 Connecting to FuzzForge API...")
|
||||
status = await client.aget_api_status()
|
||||
print(f"✅ Connected to {status.name} v{status.version}")
|
||||
print(f"📊 {status.workflows_loaded} workflows loaded\n")
|
||||
|
||||
# List available workflows
|
||||
print("📋 Available workflows:")
|
||||
workflows = await client.alist_workflows()
|
||||
for workflow in workflows:
|
||||
print(f" • {workflow.name} v{workflow.version}")
|
||||
print(f" {workflow.description}")
|
||||
if workflow.tags:
|
||||
print(f" Tags: {', '.join(workflow.tags)}")
|
||||
print()
|
||||
|
||||
if not workflows:
|
||||
print("❌ No workflows available")
|
||||
return
|
||||
|
||||
# Select the first workflow for demo
|
||||
selected_workflow = workflows[0]
|
||||
print(f"🎯 Selected workflow: {selected_workflow.name}")
|
||||
|
||||
# Prepare target path
|
||||
target_path = Path.cwd().absolute()
|
||||
submission = create_workflow_submission(
|
||||
target_path=target_path,
|
||||
volume_mode="ro",
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
# Submit the workflow
|
||||
print(f"🚀 Submitting workflow '{selected_workflow.name}'...")
|
||||
response = await client.asubmit_workflow(selected_workflow.name, submission)
|
||||
print(f"✅ Workflow submitted! Run ID: {response.run_id}")
|
||||
|
||||
# Wait for completion
|
||||
print("⏱️ Waiting for completion...")
|
||||
final_status = await client.await_for_completion(
|
||||
response.run_id,
|
||||
poll_interval=3.0,
|
||||
timeout=600.0 # 10 minutes max
|
||||
)
|
||||
print(f"✅ Run completed with status: {final_status.status}")
|
||||
|
||||
# Get findings
|
||||
findings = await client.aget_run_findings(response.run_id)
|
||||
sarif_summary = format_sarif_summary(findings.sarif)
|
||||
print(f"📈 {sarif_summary}")
|
||||
|
||||
# Extract and display detailed findings
|
||||
from fuzzforge_sdk.utils import extract_sarif_results
|
||||
results = extract_sarif_results(findings.sarif)
|
||||
|
||||
if results:
|
||||
print("\n🔍 Detailed Findings:")
|
||||
print("=" * 60)
|
||||
|
||||
for i, result in enumerate(results, 1):
|
||||
print(f"\n📋 Finding #{i}")
|
||||
rule_id = result.get('ruleId', 'unknown')
|
||||
level = result.get('level', 'warning')
|
||||
message = result.get('message', {})
|
||||
|
||||
print(f" Rule ID: {rule_id}")
|
||||
print(f" Severity: {level.upper()}")
|
||||
|
||||
if isinstance(message, dict):
|
||||
msg_text = message.get('text', 'No message')
|
||||
else:
|
||||
msg_text = str(message)
|
||||
print(f" Message: {msg_text}")
|
||||
|
||||
locations = result.get('locations', [])
|
||||
if locations:
|
||||
for loc in locations:
|
||||
physical_loc = loc.get('physicalLocation', {})
|
||||
artifact_loc = physical_loc.get('artifactLocation', {})
|
||||
region = physical_loc.get('region', {})
|
||||
|
||||
file_path = artifact_loc.get('uri', 'unknown file')
|
||||
start_line = region.get('startLine', 'unknown')
|
||||
start_col = region.get('startColumn', 'unknown')
|
||||
|
||||
print(f" Location: {file_path}:{start_line}:{start_col}")
|
||||
|
||||
print("-" * 40)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--async":
|
||||
print("🔄 Running async version...")
|
||||
asyncio.run(async_main())
|
||||
else:
|
||||
print("🔄 Running synchronous version...")
|
||||
print("💡 Use --async flag to run async version")
|
||||
main()
|
||||
@@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
"""
|
||||
Batch analysis example.
|
||||
|
||||
This example demonstrates how to:
|
||||
1. Analyze multiple projects or targets
|
||||
2. Run different workflows on the same target
|
||||
3. Collect and compare results
|
||||
4. Generate summary reports
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
import time
|
||||
|
||||
from fuzzforge_sdk import (
|
||||
FuzzForgeClient,
|
||||
WorkflowSubmission,
|
||||
WorkflowFindings,
|
||||
RunSubmissionResponse
|
||||
)
|
||||
from fuzzforge_sdk.utils import (
|
||||
create_workflow_submission,
|
||||
format_sarif_summary,
|
||||
count_sarif_severity_levels,
|
||||
save_sarif_to_file,
|
||||
get_project_files,
|
||||
estimate_analysis_time
|
||||
)
|
||||
|
||||
|
||||
class BatchAnalyzer:
|
||||
"""Batch analysis manager."""
|
||||
|
||||
def __init__(self, client: FuzzForgeClient):
|
||||
self.client = client
|
||||
self.results: List[Dict[str, Any]] = []
|
||||
|
||||
async def analyze_project(
|
||||
self,
|
||||
project_path: Path,
|
||||
workflows: List[str],
|
||||
output_dir: Path
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze a single project with multiple workflows.
|
||||
|
||||
Args:
|
||||
project_path: Path to project to analyze
|
||||
workflows: List of workflow names to run
|
||||
output_dir: Directory to save results
|
||||
|
||||
Returns:
|
||||
Analysis results summary
|
||||
"""
|
||||
print(f"\n📁 Analyzing project: {project_path.name}")
|
||||
print(f" Path: {project_path}")
|
||||
print(f" Workflows: {', '.join(workflows)}")
|
||||
|
||||
project_results = {
|
||||
"project_name": project_path.name,
|
||||
"project_path": str(project_path),
|
||||
"workflows": {},
|
||||
"summary": {},
|
||||
"start_time": time.time()
|
||||
}
|
||||
|
||||
# Get project info
|
||||
try:
|
||||
files = get_project_files(project_path)
|
||||
project_results["file_count"] = len(files)
|
||||
project_results["total_size"] = sum(f.stat().st_size for f in files if f.exists())
|
||||
print(f" Files: {len(files)}")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Could not analyze project structure: {e}")
|
||||
project_results["file_count"] = 0
|
||||
project_results["total_size"] = 0
|
||||
|
||||
# Create project output directory
|
||||
project_output_dir = output_dir / project_path.name
|
||||
project_output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Run each workflow
|
||||
for workflow_name in workflows:
|
||||
try:
|
||||
workflow_result = await self._run_workflow_on_project(
|
||||
project_path,
|
||||
workflow_name,
|
||||
project_output_dir
|
||||
)
|
||||
project_results["workflows"][workflow_name] = workflow_result
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Failed to run {workflow_name}: {e}")
|
||||
project_results["workflows"][workflow_name] = {
|
||||
"status": "failed",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
# Calculate summary
|
||||
project_results["end_time"] = time.time()
|
||||
project_results["duration"] = project_results["end_time"] - project_results["start_time"]
|
||||
project_results["summary"] = self._calculate_project_summary(project_results)
|
||||
|
||||
# Save project summary
|
||||
summary_file = project_output_dir / "analysis_summary.json"
|
||||
with open(summary_file, 'w') as f:
|
||||
json.dump(project_results, f, indent=2, default=str)
|
||||
|
||||
print(f" ✅ Analysis complete in {project_results['duration']:.1f}s")
|
||||
return project_results
|
||||
|
||||
async def _run_workflow_on_project(
|
||||
self,
|
||||
project_path: Path,
|
||||
workflow_name: str,
|
||||
output_dir: Path
|
||||
) -> Dict[str, Any]:
|
||||
"""Run a single workflow on a project."""
|
||||
print(f" 🔄 Running {workflow_name}...")
|
||||
|
||||
# Get workflow metadata for better parameter selection
|
||||
try:
|
||||
metadata = await self.client.aget_workflow_metadata(workflow_name)
|
||||
|
||||
# Determine appropriate timeout based on workflow type
|
||||
if "fuzzing" in metadata.tags:
|
||||
timeout = 1800 # 30 minutes for fuzzing
|
||||
volume_mode = "rw"
|
||||
elif "dynamic" in metadata.tags:
|
||||
timeout = 900 # 15 minutes for dynamic analysis
|
||||
volume_mode = "rw"
|
||||
else:
|
||||
timeout = 300 # 5 minutes for static analysis
|
||||
volume_mode = "ro"
|
||||
|
||||
except Exception:
|
||||
# Fallback settings
|
||||
timeout = 600
|
||||
volume_mode = "ro"
|
||||
|
||||
# Create submission
|
||||
submission = create_workflow_submission(
|
||||
target_path=project_path,
|
||||
volume_mode=volume_mode,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# Submit workflow
|
||||
start_time = time.time()
|
||||
response = await self.client.asubmit_workflow(workflow_name, submission)
|
||||
|
||||
# Wait for completion
|
||||
try:
|
||||
final_status = await self.client.await_for_completion(
|
||||
response.run_id,
|
||||
poll_interval=10.0,
|
||||
timeout=float(timeout + 300) # Add buffer for completion timeout
|
||||
)
|
||||
|
||||
end_time = time.time()
|
||||
duration = end_time - start_time
|
||||
|
||||
# Get findings if successful
|
||||
findings = None
|
||||
if final_status.is_completed and not final_status.is_failed:
|
||||
try:
|
||||
findings = await self.client.aget_run_findings(response.run_id)
|
||||
|
||||
# Save SARIF results
|
||||
sarif_file = output_dir / f"{workflow_name}_results.sarif.json"
|
||||
save_sarif_to_file(findings.sarif, sarif_file)
|
||||
|
||||
print(f" ✅ {workflow_name} completed: {format_sarif_summary(findings.sarif)}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Could not retrieve findings for {workflow_name}: {e}")
|
||||
|
||||
result = {
|
||||
"status": "completed" if final_status.is_completed else "failed",
|
||||
"run_id": response.run_id,
|
||||
"duration": duration,
|
||||
"final_status": final_status.status,
|
||||
"findings_summary": format_sarif_summary(findings.sarif) if findings else None,
|
||||
"severity_counts": count_sarif_severity_levels(findings.sarif) if findings else None
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
end_time = time.time()
|
||||
duration = end_time - start_time
|
||||
print(f" ❌ {workflow_name} failed after {duration:.1f}s: {e}")
|
||||
|
||||
return {
|
||||
"status": "failed",
|
||||
"run_id": response.run_id,
|
||||
"duration": duration,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def _calculate_project_summary(self, project_results: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Calculate summary statistics for a project analysis."""
|
||||
workflows = project_results["workflows"]
|
||||
|
||||
total_findings = {}
|
||||
successful_workflows = 0
|
||||
failed_workflows = 0
|
||||
|
||||
for workflow_name, workflow_result in workflows.items():
|
||||
if workflow_result["status"] == "completed":
|
||||
successful_workflows += 1
|
||||
|
||||
# Aggregate severity counts
|
||||
severity_counts = workflow_result.get("severity_counts", {})
|
||||
for severity, count in severity_counts.items():
|
||||
total_findings[severity] = total_findings.get(severity, 0) + count
|
||||
|
||||
else:
|
||||
failed_workflows += 1
|
||||
|
||||
return {
|
||||
"successful_workflows": successful_workflows,
|
||||
"failed_workflows": failed_workflows,
|
||||
"total_workflows": len(workflows),
|
||||
"total_findings": total_findings,
|
||||
"total_issues": sum(total_findings.values())
|
||||
}
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main batch analysis example."""
|
||||
# Configuration
|
||||
projects_to_analyze = [
|
||||
Path.cwd(), # Current directory
|
||||
# Add more project paths here
|
||||
# Path("/path/to/project1"),
|
||||
# Path("/path/to/project2"),
|
||||
]
|
||||
|
||||
workflows_to_run = [
|
||||
# "static-analysis",
|
||||
# "security-scan",
|
||||
# "dependency-check",
|
||||
# Add actual workflow names from your FuzzForge instance
|
||||
]
|
||||
|
||||
output_base_dir = Path("./analysis_results")
|
||||
|
||||
# Initialize client
|
||||
async with FuzzForgeClient(base_url="http://localhost:8000") as client:
|
||||
try:
|
||||
# Check API status
|
||||
print("🔗 Connecting to FuzzForge API...")
|
||||
status = await client.aget_api_status()
|
||||
print(f"✅ Connected to {status.name} v{status.version}")
|
||||
|
||||
# Get available workflows
|
||||
available_workflows = await client.alist_workflows()
|
||||
available_names = [w.name for w in available_workflows]
|
||||
|
||||
print(f"📋 Available workflows: {', '.join(available_names)}")
|
||||
|
||||
# Filter requested workflows to only include available ones
|
||||
valid_workflows = [w for w in workflows_to_run if w in available_names]
|
||||
|
||||
if not valid_workflows:
|
||||
print("⚠️ No valid workflows specified, using all available workflows")
|
||||
valid_workflows = available_names[:3] # Limit to first 3 for demo
|
||||
|
||||
print(f"🎯 Will run workflows: {', '.join(valid_workflows)}")
|
||||
|
||||
# Create output directory
|
||||
output_base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Initialize batch analyzer
|
||||
analyzer = BatchAnalyzer(client)
|
||||
|
||||
# Analyze each project
|
||||
batch_start_time = time.time()
|
||||
|
||||
for project_path in projects_to_analyze:
|
||||
if not project_path.exists() or not project_path.is_dir():
|
||||
print(f"⚠️ Skipping invalid project path: {project_path}")
|
||||
continue
|
||||
|
||||
project_result = await analyzer.analyze_project(
|
||||
project_path,
|
||||
valid_workflows,
|
||||
output_base_dir
|
||||
)
|
||||
analyzer.results.append(project_result)
|
||||
|
||||
batch_end_time = time.time()
|
||||
batch_duration = batch_end_time - batch_start_time
|
||||
|
||||
# Generate batch summary report
|
||||
print(f"\n📊 Batch Analysis Complete!")
|
||||
print(f" Total time: {batch_duration:.1f}s")
|
||||
print(f" Projects analyzed: {len(analyzer.results)}")
|
||||
|
||||
# Create overall summary
|
||||
batch_summary = {
|
||||
"start_time": batch_start_time,
|
||||
"end_time": batch_end_time,
|
||||
"duration": batch_duration,
|
||||
"projects": analyzer.results,
|
||||
"overall_stats": {}
|
||||
}
|
||||
|
||||
# Calculate overall statistics
|
||||
total_successful = sum(r["summary"]["successful_workflows"] for r in analyzer.results)
|
||||
total_failed = sum(r["summary"]["failed_workflows"] for r in analyzer.results)
|
||||
total_issues = sum(r["summary"]["total_issues"] for r in analyzer.results)
|
||||
|
||||
batch_summary["overall_stats"] = {
|
||||
"total_successful_runs": total_successful,
|
||||
"total_failed_runs": total_failed,
|
||||
"total_issues_found": total_issues
|
||||
}
|
||||
|
||||
print(f" Successful runs: {total_successful}")
|
||||
print(f" Failed runs: {total_failed}")
|
||||
print(f" Total issues found: {total_issues}")
|
||||
|
||||
# Save batch summary
|
||||
batch_summary_file = output_base_dir / "batch_summary.json"
|
||||
with open(batch_summary_file, 'w') as f:
|
||||
json.dump(batch_summary, f, indent=2, default=str)
|
||||
|
||||
print(f"\n💾 Results saved to: {output_base_dir}")
|
||||
print(f" Batch summary: {batch_summary_file}")
|
||||
|
||||
# Display project summaries
|
||||
print(f"\n📈 Project Summaries:")
|
||||
for result in analyzer.results:
|
||||
print(f" {result['project_name']}: " +
|
||||
f"{result['summary']['successful_workflows']}/{result['summary']['total_workflows']} workflows successful, " +
|
||||
f"{result['summary']['total_issues']} issues found")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Batch analysis failed: {e}")
|
||||
|
||||
|
||||
def create_sample_batch_config():
|
||||
"""Create a sample batch configuration file."""
|
||||
config = {
|
||||
"projects": [
|
||||
{
|
||||
"name": "my-web-app",
|
||||
"path": "/path/to/my-web-app",
|
||||
"workflows": ["static-analysis", "security-scan"],
|
||||
"parameters": {
|
||||
"timeout": 600
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "api-service",
|
||||
"path": "/path/to/api-service",
|
||||
"workflows": ["dependency-check", "fuzzing"],
|
||||
"parameters": {
|
||||
"timeout": 1800
|
||||
}
|
||||
}
|
||||
],
|
||||
"output_directory": "./batch_analysis_results",
|
||||
"concurrent_limit": 2,
|
||||
"retry_failed": True
|
||||
}
|
||||
|
||||
config_file = Path("batch_config.json")
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
print(f"📄 Sample batch configuration created: {config_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--create-config":
|
||||
create_sample_batch_config()
|
||||
else:
|
||||
print("🔄 Starting batch analysis...")
|
||||
print("💡 Use --create-config to generate sample configuration")
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
"""
|
||||
Real-time fuzzing monitoring example.
|
||||
|
||||
This example demonstrates how to:
|
||||
1. Submit a fuzzing workflow
|
||||
2. Monitor fuzzing progress in real-time using WebSocket or SSE
|
||||
3. Display live statistics and crash reports
|
||||
4. Handle real-time data updates
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from fuzzforge_sdk import FuzzForgeClient, WorkflowSubmission
|
||||
from fuzzforge_sdk.utils import (
|
||||
create_workflow_submission,
|
||||
create_resource_limits,
|
||||
format_duration,
|
||||
format_execution_rate
|
||||
)
|
||||
|
||||
|
||||
class FuzzingMonitor:
|
||||
"""Real-time fuzzing monitor with graceful shutdown."""
|
||||
|
||||
def __init__(self, client: FuzzForgeClient):
|
||||
self.client = client
|
||||
self.running = True
|
||||
self.run_id = None
|
||||
|
||||
def signal_handler(self, signum, frame):
|
||||
"""Handle shutdown signals gracefully."""
|
||||
print(f"\n🛑 Received signal {signum}, shutting down...")
|
||||
self.running = False
|
||||
|
||||
async def monitor_websocket(self, run_id: str):
|
||||
"""Monitor fuzzing via WebSocket."""
|
||||
print("🔌 Starting WebSocket monitoring...")
|
||||
|
||||
try:
|
||||
async for message in self.client.monitor_fuzzing_websocket(run_id):
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
if message.type == "stats_update":
|
||||
self.display_stats(message.data)
|
||||
elif message.type == "crash_report":
|
||||
self.display_crash(message.data)
|
||||
elif message.type == "heartbeat":
|
||||
print("💓 Heartbeat")
|
||||
else:
|
||||
print(f"📨 Received: {message.type}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n⏹️ Interrupted by user")
|
||||
except Exception as e:
|
||||
print(f"❌ WebSocket error: {e}")
|
||||
|
||||
def monitor_sse(self, run_id: str):
|
||||
"""Monitor fuzzing via Server-Sent Events."""
|
||||
print("📡 Starting SSE monitoring...")
|
||||
|
||||
try:
|
||||
for message in self.client.monitor_fuzzing_sse(run_id):
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
if message.type == "stats":
|
||||
self.display_stats(message.data)
|
||||
elif message.type == "crash":
|
||||
self.display_crash(message.data)
|
||||
else:
|
||||
print(f"📨 Received: {message.type}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n⏹️ Interrupted by user")
|
||||
except Exception as e:
|
||||
print(f"❌ SSE error: {e}")
|
||||
|
||||
def display_stats(self, stats_data):
|
||||
"""Display fuzzing statistics."""
|
||||
# Clear screen and move cursor to top
|
||||
print("\033[2J\033[H", end="")
|
||||
|
||||
print("🎯 FuzzForge Live Fuzzing Monitor")
|
||||
print("=" * 50)
|
||||
print(f"Run ID: {stats_data.get('run_id', 'unknown')}")
|
||||
print(f"Workflow: {stats_data.get('workflow', 'unknown')}")
|
||||
print()
|
||||
|
||||
# Statistics
|
||||
executions = stats_data.get('executions', 0)
|
||||
exec_per_sec = stats_data.get('executions_per_sec', 0.0)
|
||||
crashes = stats_data.get('crashes', 0)
|
||||
unique_crashes = stats_data.get('unique_crashes', 0)
|
||||
coverage = stats_data.get('coverage')
|
||||
corpus_size = stats_data.get('corpus_size', 0)
|
||||
elapsed_time = stats_data.get('elapsed_time', 0)
|
||||
|
||||
print(f"📊 Statistics:")
|
||||
print(f" Executions: {executions:,}")
|
||||
print(f" Rate: {format_execution_rate(exec_per_sec)}")
|
||||
print(f" Runtime: {format_duration(elapsed_time)}")
|
||||
print(f" Corpus size: {corpus_size:,}")
|
||||
|
||||
if coverage is not None:
|
||||
print(f" Coverage: {coverage:.1f}%")
|
||||
|
||||
print()
|
||||
print(f"💥 Crashes:")
|
||||
print(f" Total crashes: {crashes}")
|
||||
print(f" Unique crashes: {unique_crashes}")
|
||||
|
||||
last_crash = stats_data.get('last_crash_time')
|
||||
if last_crash:
|
||||
crash_time = datetime.fromisoformat(last_crash.replace('Z', '+00:00'))
|
||||
print(f" Last crash: {crash_time.strftime('%H:%M:%S')}")
|
||||
|
||||
print()
|
||||
print("Press Ctrl+C to stop monitoring")
|
||||
print("-" * 50)
|
||||
|
||||
def display_crash(self, crash_data):
|
||||
"""Display new crash report."""
|
||||
print("\n🚨 NEW CRASH DETECTED!")
|
||||
print(f" Crash ID: {crash_data.get('crash_id')}")
|
||||
print(f" Signal: {crash_data.get('signal', 'unknown')}")
|
||||
print(f" Type: {crash_data.get('crash_type', 'unknown')}")
|
||||
print(f" Severity: {crash_data.get('severity', 'unknown')}")
|
||||
|
||||
if crash_data.get('input_file'):
|
||||
print(f" Input file: {crash_data['input_file']}")
|
||||
|
||||
print("-" * 30)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main fuzzing monitoring example."""
|
||||
# Initialize client
|
||||
client = FuzzForgeClient(base_url="http://localhost:8000")
|
||||
monitor = FuzzingMonitor(client)
|
||||
|
||||
# Set up signal handlers
|
||||
signal.signal(signal.SIGINT, monitor.signal_handler)
|
||||
signal.signal(signal.SIGTERM, monitor.signal_handler)
|
||||
|
||||
try:
|
||||
# Check API status
|
||||
print("🔗 Connecting to FuzzForge API...")
|
||||
status = await client.aget_api_status()
|
||||
print(f"✅ Connected to {status.name} v{status.version}\n")
|
||||
|
||||
# List workflows and find fuzzing ones
|
||||
workflows = await client.alist_workflows()
|
||||
fuzzing_workflows = [w for w in workflows if "fuzz" in w.name.lower() or "fuzzing" in w.tags]
|
||||
|
||||
if not fuzzing_workflows:
|
||||
print("❌ No fuzzing workflows found")
|
||||
print("Available workflows:")
|
||||
for w in workflows:
|
||||
print(f" • {w.name} (tags: {w.tags})")
|
||||
return
|
||||
|
||||
# Select first fuzzing workflow
|
||||
selected_workflow = fuzzing_workflows[0]
|
||||
print(f"🎯 Selected fuzzing workflow: {selected_workflow.name}")
|
||||
|
||||
# Create submission with fuzzing-appropriate settings
|
||||
target_path = Path.cwd().absolute()
|
||||
|
||||
# Set longer timeout and resource limits for fuzzing
|
||||
resource_limits = create_resource_limits(
|
||||
cpu_limit="2", # 2 CPU cores
|
||||
memory_limit="4Gi", # 4GB memory
|
||||
cpu_request="1", # Guarantee 1 core
|
||||
memory_request="2Gi" # Guarantee 2GB
|
||||
)
|
||||
|
||||
submission = create_workflow_submission(
|
||||
target_path=target_path,
|
||||
volume_mode="rw", # Fuzzing may need to write files
|
||||
timeout=3600, # 1 hour timeout
|
||||
resource_limits=resource_limits,
|
||||
parameters={
|
||||
"max_len": 1024, # Maximum input length
|
||||
"timeout": 10, # Per-execution timeout
|
||||
"runs": 1000000, # Number of executions
|
||||
}
|
||||
)
|
||||
|
||||
print(f"🚀 Submitting fuzzing workflow...")
|
||||
response = await client.asubmit_workflow(selected_workflow.name, submission)
|
||||
monitor.run_id = response.run_id
|
||||
|
||||
print(f"✅ Fuzzing started!")
|
||||
print(f" Run ID: {response.run_id}")
|
||||
print(f" Initial status: {response.status}")
|
||||
print()
|
||||
|
||||
# Wait a moment for fuzzing to initialize
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# Get initial stats to verify fuzzing is tracked
|
||||
try:
|
||||
stats = await client.aget_fuzzing_stats(response.run_id)
|
||||
print(f"📊 Fuzzing tracking initialized for workflow: {stats.workflow}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Fuzzing tracking not available: {e}")
|
||||
print(" Monitoring will show run status updates only")
|
||||
|
||||
# Choose monitoring method
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--sse":
|
||||
print("📡 Using Server-Sent Events for monitoring...")
|
||||
monitor.monitor_sse(response.run_id)
|
||||
else:
|
||||
print("🔌 Using WebSocket for monitoring...")
|
||||
await monitor.monitor_websocket(response.run_id)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n⏹️ Interrupted by user")
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
finally:
|
||||
# Cleanup
|
||||
if monitor.run_id:
|
||||
try:
|
||||
print(f"\n🧹 Cleaning up fuzzing run {monitor.run_id}...")
|
||||
await client.acleanup_fuzzing_run(monitor.run_id)
|
||||
print("✅ Cleanup completed")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Cleanup failed: {e}")
|
||||
|
||||
await client.aclose()
|
||||
|
||||
|
||||
def sync_monitor_example():
|
||||
"""Example of synchronous SSE monitoring."""
|
||||
client = FuzzForgeClient(base_url="http://localhost:8000")
|
||||
|
||||
try:
|
||||
# This would require a pre-existing fuzzing run
|
||||
run_id = input("Enter fuzzing run ID to monitor: ").strip()
|
||||
if not run_id:
|
||||
print("❌ Run ID required")
|
||||
return
|
||||
|
||||
print(f"📡 Monitoring fuzzing run: {run_id}")
|
||||
print("Press Ctrl+C to stop")
|
||||
print()
|
||||
|
||||
monitor = FuzzingMonitor(client)
|
||||
monitor.monitor_sse(run_id)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n⏹️ Monitoring stopped")
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--sync":
|
||||
print("🔄 Running synchronous SSE monitoring...")
|
||||
sync_monitor_example()
|
||||
else:
|
||||
print("🔄 Running async WebSocket monitoring...")
|
||||
print("💡 Use --sse flag for Server-Sent Events")
|
||||
print("💡 Use --sync flag for synchronous monitoring")
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
"""
|
||||
Quick demo to save findings to a SARIF file.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from fuzzforge_sdk import FuzzForgeClient
|
||||
from fuzzforge_sdk.utils import create_workflow_submission, save_sarif_to_file, format_sarif_summary
|
||||
|
||||
def main():
|
||||
"""Save findings demo."""
|
||||
client = FuzzForgeClient(base_url="http://localhost:8000")
|
||||
|
||||
try:
|
||||
# List workflows
|
||||
workflows = client.list_workflows()
|
||||
if not workflows:
|
||||
print("❌ No workflows available")
|
||||
return
|
||||
|
||||
# Submit workflow
|
||||
workflow_name = workflows[0].name
|
||||
submission = create_workflow_submission(
|
||||
target_path=Path.cwd().absolute(),
|
||||
volume_mode="ro",
|
||||
timeout=300
|
||||
)
|
||||
|
||||
print(f"🚀 Submitting {workflow_name}...")
|
||||
response = client.submit_workflow(workflow_name, submission)
|
||||
|
||||
# Wait for completion
|
||||
print("⏱️ Waiting for completion...")
|
||||
final_status = client.wait_for_completion(response.run_id)
|
||||
|
||||
if final_status.is_completed:
|
||||
# Get findings
|
||||
findings = client.get_run_findings(response.run_id)
|
||||
summary = format_sarif_summary(findings.sarif)
|
||||
print(f"📈 {summary}")
|
||||
|
||||
# Save to file
|
||||
output_file = Path("fuzzforge_findings.sarif.json")
|
||||
save_sarif_to_file(findings.sarif, output_file)
|
||||
print(f"💾 Findings saved to: {output_file.absolute()}")
|
||||
|
||||
# Show file info
|
||||
print(f"📄 File size: {output_file.stat().st_size:,} bytes")
|
||||
|
||||
else:
|
||||
print(f"❌ Workflow failed: {final_status.status}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
"""
|
||||
Automated workflow testing example using FuzzForge SDK.
|
||||
|
||||
This example demonstrates how to:
|
||||
1. Use the WorkflowTester for automated testing
|
||||
2. Test all workflows against vulnerable test projects
|
||||
3. Generate comprehensive test reports
|
||||
4. Handle test failures and debugging
|
||||
|
||||
Usage:
|
||||
python test_all_workflows.py [--detailed] [--workflow WORKFLOW_NAME]
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fuzzforge_sdk import (
|
||||
FuzzForgeClient,
|
||||
WorkflowTester,
|
||||
format_test_summary,
|
||||
DEFAULT_TEST_CONFIG
|
||||
)
|
||||
from fuzzforge_sdk.exceptions import FuzzForgeError
|
||||
|
||||
|
||||
def setup_logging(verbose: bool = False):
|
||||
"""Setup logging configuration."""
|
||||
level = logging.DEBUG if verbose else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main test execution function."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Automated workflow testing for FuzzForge",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Test all workflows with detailed output
|
||||
python test_all_workflows.py --detailed
|
||||
|
||||
# Test only static analysis workflow
|
||||
python test_all_workflows.py --workflow static_analysis_scan
|
||||
|
||||
# Test with verbose logging
|
||||
python test_all_workflows.py --verbose --detailed
|
||||
|
||||
# Show available test configurations
|
||||
python test_all_workflows.py --show-config
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default="http://localhost:8000",
|
||||
help="FuzzForge API base URL (default: http://localhost:8000)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--test-projects-path",
|
||||
help="Path to test projects directory (auto-detected if not provided)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--workflow",
|
||||
help="Test only specific workflow"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--detailed",
|
||||
action="store_true",
|
||||
help="Show detailed test results"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--verbose", "-v",
|
||||
action="store_true",
|
||||
help="Enable verbose logging"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--show-config",
|
||||
action="store_true",
|
||||
help="Show test configurations and exit"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
help="Override default timeout for workflow tests (seconds)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Setup logging
|
||||
setup_logging(args.verbose)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Show configuration and exit if requested
|
||||
if args.show_config:
|
||||
print("🔧 FuzzForge Workflow Test Configurations:")
|
||||
print("=" * 60)
|
||||
for workflow_name, config in DEFAULT_TEST_CONFIG.items():
|
||||
print(f"📋 {workflow_name}:")
|
||||
print(f" Test Project: {config['test_project']}")
|
||||
print(f" Expected Findings: ≥{config['expected_min_findings']}")
|
||||
print(f" Timeout: {config['timeout']}s")
|
||||
print(f" Description: {config['description']}")
|
||||
print()
|
||||
return
|
||||
|
||||
try:
|
||||
# Initialize client and tester
|
||||
print(f"🔗 Connecting to FuzzForge API at {args.base_url}")
|
||||
client = FuzzForgeClient(base_url=args.base_url)
|
||||
|
||||
# Verify API connection
|
||||
status = client.get_api_status()
|
||||
print(f"✅ Connected to {status.name} v{status.version}")
|
||||
|
||||
# Initialize tester
|
||||
tester = WorkflowTester(
|
||||
client=client,
|
||||
test_projects_base_path=args.test_projects_path
|
||||
)
|
||||
|
||||
# Check if test projects path was found
|
||||
if not Path(tester.test_projects_base_path).exists():
|
||||
print(f"❌ Test projects path not found: {tester.test_projects_base_path}")
|
||||
print("Please ensure test projects are available or specify --test-projects-path")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"📁 Using test projects from: {tester.test_projects_base_path}")
|
||||
print()
|
||||
|
||||
# Run tests
|
||||
if args.workflow:
|
||||
# Test single workflow
|
||||
print(f"🧪 Testing workflow: {args.workflow}")
|
||||
print("-" * 40)
|
||||
|
||||
# Override timeout if specified
|
||||
kwargs = {}
|
||||
if args.timeout:
|
||||
kwargs['timeout'] = args.timeout
|
||||
|
||||
result = tester.test_workflow(args.workflow, **kwargs)
|
||||
|
||||
# Display result
|
||||
status_icon = "✅" if result.passed else "❌"
|
||||
print(f"{status_icon} {result.workflow_name}")
|
||||
print(f" Project: {Path(result.test_project_path).name}")
|
||||
print(f" Findings: {result.findings_count} (expected ≥{result.expected_min_findings})")
|
||||
print(f" Duration: {result.execution_time:.1f}s")
|
||||
|
||||
if result.error:
|
||||
print(f" ❌ Error: {result.error}")
|
||||
|
||||
if result.run_id:
|
||||
print(f" 🔍 Run ID: {result.run_id}")
|
||||
|
||||
print()
|
||||
|
||||
# Exit with appropriate code
|
||||
sys.exit(0 if result.passed else 1)
|
||||
|
||||
else:
|
||||
# Test all workflows
|
||||
print("🧪 Testing all available workflows...")
|
||||
print("-" * 40)
|
||||
|
||||
# Get available workflows
|
||||
workflows = client.list_workflows()
|
||||
print(f"Found {len(workflows)} workflows: {', '.join(w.name for w in workflows)}")
|
||||
print()
|
||||
|
||||
# Run comprehensive test suite
|
||||
summary = tester.test_all_workflows()
|
||||
|
||||
# Display results
|
||||
print(format_test_summary(summary, detailed=args.detailed))
|
||||
|
||||
# Exit with appropriate code
|
||||
sys.exit(0 if summary.failed == 0 else 1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n⚠️ Test execution interrupted by user")
|
||||
sys.exit(130)
|
||||
|
||||
except FuzzForgeError as e:
|
||||
logger.error(f"FuzzForge API error: {e}")
|
||||
print(f"❌ FuzzForge API error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error: {e}")
|
||||
print(f"❌ Unexpected error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def test_single_workflow_example():
|
||||
"""
|
||||
Example function showing how to test a single workflow programmatically.
|
||||
|
||||
This demonstrates the SDK usage for integration into other scripts.
|
||||
"""
|
||||
# Initialize client and tester
|
||||
client = FuzzForgeClient(base_url="http://localhost:8000")
|
||||
tester = WorkflowTester(client)
|
||||
|
||||
# Test a specific workflow
|
||||
result = tester.test_workflow(
|
||||
workflow_name="static_analysis_scan",
|
||||
expected_min_findings=3, # Override default expectation
|
||||
timeout=300 # 5 minute timeout
|
||||
)
|
||||
|
||||
# Handle results
|
||||
if result.passed:
|
||||
print(f"✅ Test passed! Found {result.findings_count} vulnerabilities")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Test failed: {result.error}")
|
||||
return False
|
||||
|
||||
|
||||
def validate_deployments_example():
|
||||
"""
|
||||
Example function showing how to validate workflow deployments.
|
||||
|
||||
Useful for health checks and deployment verification.
|
||||
"""
|
||||
client = FuzzForgeClient(base_url="http://localhost:8000")
|
||||
tester = WorkflowTester(client)
|
||||
|
||||
# Check all workflows are deployed
|
||||
workflows = client.list_workflows()
|
||||
|
||||
print("🔍 Validating workflow deployments:")
|
||||
all_valid = True
|
||||
|
||||
for workflow in workflows:
|
||||
is_valid = tester.validate_workflow_deployment(workflow.name)
|
||||
status_icon = "✅" if is_valid else "❌"
|
||||
print(f" {status_icon} {workflow.name}")
|
||||
|
||||
if not is_valid:
|
||||
all_valid = False
|
||||
|
||||
return all_valid
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,29 @@
|
||||
[project]
|
||||
name = "fuzzforge-sdk"
|
||||
version = "0.6.0"
|
||||
description = "Python SDK for FuzzForge security testing workflow orchestration platform"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
{ name = "Tanguy Duhamel", email = "tduhamel@fuzzinglabs.com" }
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"httpx>=0.27.0",
|
||||
"pydantic>=2.0.0",
|
||||
"websockets>=13.0",
|
||||
"sseclient-py>=1.8.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"pytest-mock>=3.14.0",
|
||||
"black>=24.0.0",
|
||||
"isort>=5.13.0",
|
||||
"mypy>=1.11.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.8.17,<0.9.0"]
|
||||
build-backend = "uv_build"
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
FuzzForge SDK - Python client for FuzzForge security testing platform
|
||||
|
||||
A comprehensive SDK for interacting with the FuzzForge API, providing
|
||||
workflow management, real-time fuzzing monitoring, and SARIF findings retrieval.
|
||||
"""
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
|
||||
from .client import FuzzForgeClient
|
||||
from .models import (
|
||||
WorkflowSubmission,
|
||||
WorkflowMetadata,
|
||||
WorkflowListItem,
|
||||
WorkflowStatus,
|
||||
WorkflowFindings,
|
||||
ResourceLimits,
|
||||
VolumeMount,
|
||||
FuzzingStats,
|
||||
CrashReport,
|
||||
RunSubmissionResponse,
|
||||
)
|
||||
from .exceptions import (
|
||||
FuzzForgeError,
|
||||
FuzzForgeHTTPError,
|
||||
WorkflowNotFoundError,
|
||||
RunNotFoundError,
|
||||
ValidationError,
|
||||
)
|
||||
from .testing import (
|
||||
WorkflowTester,
|
||||
TestResult,
|
||||
TestSummary,
|
||||
format_test_summary,
|
||||
DEFAULT_TEST_CONFIG,
|
||||
)
|
||||
|
||||
__version__ = "0.6.0"
|
||||
__all__ = [
|
||||
"FuzzForgeClient",
|
||||
"WorkflowSubmission",
|
||||
"WorkflowMetadata",
|
||||
"WorkflowListItem",
|
||||
"WorkflowStatus",
|
||||
"WorkflowFindings",
|
||||
"ResourceLimits",
|
||||
"VolumeMount",
|
||||
"FuzzingStats",
|
||||
"CrashReport",
|
||||
"RunSubmissionResponse",
|
||||
"FuzzForgeError",
|
||||
"FuzzForgeHTTPError",
|
||||
"WorkflowNotFoundError",
|
||||
"RunNotFoundError",
|
||||
"ValidationError",
|
||||
"WorkflowTester",
|
||||
"TestResult",
|
||||
"TestSummary",
|
||||
"format_test_summary",
|
||||
"DEFAULT_TEST_CONFIG",
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for the CLI (not implemented yet)"""
|
||||
print("FuzzForge SDK - Use as a library to interact with FuzzForge API")
|
||||
@@ -0,0 +1,536 @@
|
||||
"""
|
||||
Main client class for interacting with the FuzzForge API.
|
||||
|
||||
Provides both synchronous and asynchronous methods for all API endpoints,
|
||||
including real-time monitoring capabilities for fuzzing workflows.
|
||||
"""
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional, AsyncIterator, Iterator, Union
|
||||
from urllib.parse import urljoin, urlparse
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
try:
|
||||
from sseclient import SSEClient # type: ignore
|
||||
_HAVE_SSECLIENT = True
|
||||
except Exception: # pragma: no cover
|
||||
SSEClient = None # type: ignore
|
||||
_HAVE_SSECLIENT = False
|
||||
|
||||
try:
|
||||
import requests # type: ignore
|
||||
_HAVE_REQUESTS = True
|
||||
except Exception: # pragma: no cover
|
||||
requests = None # type: ignore
|
||||
_HAVE_REQUESTS = False
|
||||
|
||||
from .models import (
|
||||
APIStatus,
|
||||
WorkflowListItem,
|
||||
WorkflowMetadata,
|
||||
WorkflowParametersResponse,
|
||||
WorkflowSubmission,
|
||||
RunSubmissionResponse,
|
||||
WorkflowStatus,
|
||||
WorkflowFindings,
|
||||
FuzzingStats,
|
||||
CrashReport,
|
||||
WebSocketMessage,
|
||||
SSEMessage,
|
||||
)
|
||||
from .exceptions import (
|
||||
FuzzForgeError,
|
||||
FuzzForgeHTTPError,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
WebSocketError,
|
||||
SSEError,
|
||||
from_http_error,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FuzzForgeClient:
|
||||
"""
|
||||
Client for interacting with the FuzzForge API.
|
||||
|
||||
Provides methods for workflow management, run monitoring, and real-time
|
||||
fuzzing statistics. Supports both synchronous and asynchronous operations.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the FuzzForge API (e.g., "http://localhost:8000")
|
||||
timeout: Default timeout for HTTP requests in seconds
|
||||
verify_ssl: Whether to verify SSL certificates
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "http://localhost:8000",
|
||||
timeout: float = 30.0,
|
||||
verify_ssl: bool = True,
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.verify_ssl = verify_ssl
|
||||
|
||||
# Create HTTP clients
|
||||
self._client = httpx.Client(timeout=timeout, verify=verify_ssl)
|
||||
self._async_client = httpx.AsyncClient(timeout=timeout, verify=verify_ssl)
|
||||
|
||||
# WebSocket URL (convert http(s) to ws(s))
|
||||
parsed = urlparse(self.base_url)
|
||||
ws_scheme = "wss" if parsed.scheme == "https" else "ws"
|
||||
self._ws_base_url = f"{ws_scheme}://{parsed.netloc}"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.aclose()
|
||||
|
||||
def close(self):
|
||||
"""Close the HTTP client."""
|
||||
self._client.close()
|
||||
|
||||
async def aclose(self):
|
||||
"""Close the async HTTP client."""
|
||||
await self._async_client.aclose()
|
||||
|
||||
def _handle_response(self, response: httpx.Response) -> Dict[str, Any]:
|
||||
"""Handle HTTP response and raise appropriate exceptions."""
|
||||
try:
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise from_http_error(e.response.status_code, e.response.text, str(e.request.url))
|
||||
except httpx.RequestError as e:
|
||||
raise ConnectionError(f"Request failed: {e}")
|
||||
except json.JSONDecodeError as e:
|
||||
raise FuzzForgeError(f"Invalid JSON response: {e}")
|
||||
|
||||
async def _ahandle_response(self, response: httpx.Response) -> Dict[str, Any]:
|
||||
"""Handle async HTTP response and raise appropriate exceptions."""
|
||||
try:
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise from_http_error(e.response.status_code, e.response.text, str(e.request.url))
|
||||
except httpx.RequestError as e:
|
||||
raise ConnectionError(f"Request failed: {e}")
|
||||
except json.JSONDecodeError as e:
|
||||
raise FuzzForgeError(f"Invalid JSON response: {e}")
|
||||
|
||||
# Root API methods
|
||||
|
||||
def get_api_status(self) -> APIStatus:
|
||||
"""Get API status and information."""
|
||||
response = self._client.get(self.base_url)
|
||||
data = self._handle_response(response)
|
||||
return APIStatus(**data)
|
||||
|
||||
async def aget_api_status(self) -> APIStatus:
|
||||
"""Get API status and information (async)."""
|
||||
response = await self._async_client.get(self.base_url)
|
||||
data = await self._ahandle_response(response)
|
||||
return APIStatus(**data)
|
||||
|
||||
# Workflow management methods
|
||||
|
||||
def list_workflows(self) -> List[WorkflowListItem]:
|
||||
"""List all available workflows."""
|
||||
url = urljoin(self.base_url, "/workflows/")
|
||||
response = self._client.get(url)
|
||||
data = self._handle_response(response)
|
||||
return [WorkflowListItem(**item) for item in data]
|
||||
|
||||
async def alist_workflows(self) -> List[WorkflowListItem]:
|
||||
"""List all available workflows (async)."""
|
||||
url = urljoin(self.base_url, "/workflows/")
|
||||
response = await self._async_client.get(url)
|
||||
data = await self._ahandle_response(response)
|
||||
return [WorkflowListItem(**item) for item in data]
|
||||
|
||||
def get_workflow_metadata(self, workflow_name: str) -> WorkflowMetadata:
|
||||
"""Get complete metadata for a workflow."""
|
||||
url = urljoin(self.base_url, f"/workflows/{workflow_name}/metadata")
|
||||
response = self._client.get(url)
|
||||
data = self._handle_response(response)
|
||||
return WorkflowMetadata(**data)
|
||||
|
||||
async def aget_workflow_metadata(self, workflow_name: str) -> WorkflowMetadata:
|
||||
"""Get complete metadata for a workflow (async)."""
|
||||
url = urljoin(self.base_url, f"/workflows/{workflow_name}/metadata")
|
||||
response = await self._async_client.get(url)
|
||||
data = await self._ahandle_response(response)
|
||||
return WorkflowMetadata(**data)
|
||||
|
||||
def get_workflow_parameters(self, workflow_name: str) -> WorkflowParametersResponse:
|
||||
"""Get parameters schema for a workflow."""
|
||||
url = urljoin(self.base_url, f"/workflows/{workflow_name}/parameters")
|
||||
response = self._client.get(url)
|
||||
data = self._handle_response(response)
|
||||
return WorkflowParametersResponse(**data)
|
||||
|
||||
async def aget_workflow_parameters(self, workflow_name: str) -> WorkflowParametersResponse:
|
||||
"""Get parameters schema for a workflow (async)."""
|
||||
url = urljoin(self.base_url, f"/workflows/{workflow_name}/parameters")
|
||||
response = await self._async_client.get(url)
|
||||
data = await self._ahandle_response(response)
|
||||
return WorkflowParametersResponse(**data)
|
||||
|
||||
def get_metadata_schema(self) -> Dict[str, Any]:
|
||||
"""Get the JSON schema for workflow metadata files."""
|
||||
url = urljoin(self.base_url, "/workflows/metadata/schema")
|
||||
response = self._client.get(url)
|
||||
return self._handle_response(response)
|
||||
|
||||
async def aget_metadata_schema(self) -> Dict[str, Any]:
|
||||
"""Get the JSON schema for workflow metadata files (async)."""
|
||||
url = urljoin(self.base_url, "/workflows/metadata/schema")
|
||||
response = await self._async_client.get(url)
|
||||
return await self._ahandle_response(response)
|
||||
|
||||
def submit_workflow(
|
||||
self,
|
||||
workflow_name: str,
|
||||
submission: WorkflowSubmission
|
||||
) -> RunSubmissionResponse:
|
||||
"""Submit a workflow for execution."""
|
||||
url = urljoin(self.base_url, f"/workflows/{workflow_name}/submit")
|
||||
response = self._client.post(url, json=submission.model_dump())
|
||||
data = self._handle_response(response)
|
||||
return RunSubmissionResponse(**data)
|
||||
|
||||
async def asubmit_workflow(
|
||||
self,
|
||||
workflow_name: str,
|
||||
submission: WorkflowSubmission
|
||||
) -> RunSubmissionResponse:
|
||||
"""Submit a workflow for execution (async)."""
|
||||
url = urljoin(self.base_url, f"/workflows/{workflow_name}/submit")
|
||||
response = await self._async_client.post(url, json=submission.model_dump())
|
||||
data = await self._ahandle_response(response)
|
||||
return RunSubmissionResponse(**data)
|
||||
|
||||
# Run management methods
|
||||
|
||||
def get_run_status(self, run_id: str) -> WorkflowStatus:
|
||||
"""Get the status of a workflow run."""
|
||||
url = urljoin(self.base_url, f"/runs/{run_id}/status")
|
||||
response = self._client.get(url)
|
||||
data = self._handle_response(response)
|
||||
return WorkflowStatus(**data)
|
||||
|
||||
async def aget_run_status(self, run_id: str) -> WorkflowStatus:
|
||||
"""Get the status of a workflow run (async)."""
|
||||
url = urljoin(self.base_url, f"/runs/{run_id}/status")
|
||||
response = await self._async_client.get(url)
|
||||
data = await self._ahandle_response(response)
|
||||
return WorkflowStatus(**data)
|
||||
|
||||
def get_run_findings(self, run_id: str) -> WorkflowFindings:
|
||||
"""Get findings from a completed workflow run."""
|
||||
url = urljoin(self.base_url, f"/runs/{run_id}/findings")
|
||||
response = self._client.get(url)
|
||||
data = self._handle_response(response)
|
||||
return WorkflowFindings(**data)
|
||||
|
||||
async def aget_run_findings(self, run_id: str) -> WorkflowFindings:
|
||||
"""Get findings from a completed workflow run (async)."""
|
||||
url = urljoin(self.base_url, f"/runs/{run_id}/findings")
|
||||
response = await self._async_client.get(url)
|
||||
data = await self._ahandle_response(response)
|
||||
return WorkflowFindings(**data)
|
||||
|
||||
def get_workflow_findings(self, workflow_name: str, run_id: str) -> WorkflowFindings:
|
||||
"""Get findings for a specific workflow run (alternative endpoint)."""
|
||||
url = urljoin(self.base_url, f"/runs/{workflow_name}/findings/{run_id}")
|
||||
response = self._client.get(url)
|
||||
data = self._handle_response(response)
|
||||
return WorkflowFindings(**data)
|
||||
|
||||
async def aget_workflow_findings(self, workflow_name: str, run_id: str) -> WorkflowFindings:
|
||||
"""Get findings for a specific workflow run (alternative endpoint, async)."""
|
||||
url = urljoin(self.base_url, f"/runs/{workflow_name}/findings/{run_id}")
|
||||
response = await self._async_client.get(url)
|
||||
data = await self._ahandle_response(response)
|
||||
return WorkflowFindings(**data)
|
||||
|
||||
# Fuzzing methods
|
||||
|
||||
def get_fuzzing_stats(self, run_id: str) -> FuzzingStats:
|
||||
"""Get current fuzzing statistics for a run."""
|
||||
url = urljoin(self.base_url, f"/fuzzing/{run_id}/stats")
|
||||
response = self._client.get(url)
|
||||
data = self._handle_response(response)
|
||||
return FuzzingStats(**data)
|
||||
|
||||
async def aget_fuzzing_stats(self, run_id: str) -> FuzzingStats:
|
||||
"""Get current fuzzing statistics for a run (async)."""
|
||||
url = urljoin(self.base_url, f"/fuzzing/{run_id}/stats")
|
||||
response = await self._async_client.get(url)
|
||||
data = await self._ahandle_response(response)
|
||||
return FuzzingStats(**data)
|
||||
|
||||
def get_crash_reports(self, run_id: str) -> List[CrashReport]:
|
||||
"""Get crash reports for a fuzzing run."""
|
||||
url = urljoin(self.base_url, f"/fuzzing/{run_id}/crashes")
|
||||
response = self._client.get(url)
|
||||
data = self._handle_response(response)
|
||||
return [CrashReport(**crash) for crash in data]
|
||||
|
||||
async def aget_crash_reports(self, run_id: str) -> List[CrashReport]:
|
||||
"""Get crash reports for a fuzzing run (async)."""
|
||||
url = urljoin(self.base_url, f"/fuzzing/{run_id}/crashes")
|
||||
response = await self._async_client.get(url)
|
||||
data = await self._ahandle_response(response)
|
||||
return [CrashReport(**crash) for crash in data]
|
||||
|
||||
def cleanup_fuzzing_run(self, run_id: str) -> Dict[str, Any]:
|
||||
"""Clean up fuzzing run data."""
|
||||
url = urljoin(self.base_url, f"/fuzzing/{run_id}")
|
||||
response = self._client.delete(url)
|
||||
return self._handle_response(response)
|
||||
|
||||
async def acleanup_fuzzing_run(self, run_id: str) -> Dict[str, Any]:
|
||||
"""Clean up fuzzing run data (async)."""
|
||||
url = urljoin(self.base_url, f"/fuzzing/{run_id}")
|
||||
response = await self._async_client.delete(url)
|
||||
return await self._ahandle_response(response)
|
||||
|
||||
# Real-time monitoring methods
|
||||
|
||||
async def monitor_fuzzing_websocket(self, run_id: str) -> AsyncIterator[WebSocketMessage]:
|
||||
"""
|
||||
Monitor fuzzing progress via WebSocket for real-time updates.
|
||||
|
||||
Args:
|
||||
run_id: The fuzzing run ID to monitor
|
||||
|
||||
Yields:
|
||||
WebSocketMessage objects with real-time updates
|
||||
|
||||
Raises:
|
||||
WebSocketError: If WebSocket connection fails
|
||||
"""
|
||||
url = f"{self._ws_base_url}/fuzzing/{run_id}/live"
|
||||
|
||||
try:
|
||||
async with websockets.connect(
|
||||
url,
|
||||
timeout=self.timeout,
|
||||
ping_interval=20,
|
||||
ping_timeout=10
|
||||
) as websocket:
|
||||
while True:
|
||||
try:
|
||||
# Send periodic ping to keep connection alive
|
||||
await websocket.ping()
|
||||
|
||||
# Receive message with timeout
|
||||
message = await asyncio.wait_for(
|
||||
websocket.recv(),
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
if message == "pong":
|
||||
continue
|
||||
|
||||
data = json.loads(message)
|
||||
yield WebSocketMessage(**data)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"WebSocket timeout for run {run_id}")
|
||||
break
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON from WebSocket: {e}")
|
||||
continue
|
||||
|
||||
except websockets.exceptions.WebSocketException as e:
|
||||
raise WebSocketError(f"WebSocket connection failed: {e}")
|
||||
except Exception as e:
|
||||
raise WebSocketError(f"WebSocket error: {e}")
|
||||
|
||||
def monitor_fuzzing_sse(self, run_id: str) -> Iterator[SSEMessage]:
|
||||
"""
|
||||
Monitor fuzzing progress via Server-Sent Events.
|
||||
|
||||
Args:
|
||||
run_id: The fuzzing run ID to monitor
|
||||
|
||||
Yields:
|
||||
SSEMessage objects with real-time updates
|
||||
|
||||
Raises:
|
||||
SSEError: If SSE connection fails
|
||||
"""
|
||||
url = urljoin(self.base_url, f"/fuzzing/{run_id}/stream")
|
||||
|
||||
# Prefer requests+sseclient if requests is available; otherwise manually parse SSE via httpx
|
||||
if _HAVE_REQUESTS:
|
||||
try:
|
||||
with requests.Session() as sess:
|
||||
with sess.get(
|
||||
url,
|
||||
headers={"Accept": "text/event-stream"},
|
||||
stream=True,
|
||||
timeout=None,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
client = SSEClient(resp)
|
||||
for event in client.events():
|
||||
if not event.data:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(event.data)
|
||||
yield SSEMessage(**data)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON from SSE: {e}")
|
||||
continue
|
||||
except requests.HTTPError as e: # type: ignore[attr-defined]
|
||||
status = getattr(e.response, 'status_code', 0)
|
||||
url_txt = getattr(e.request, 'url', url)
|
||||
raise from_http_error(status, str(e), str(url_txt))
|
||||
except Exception as e: # pragma: no cover
|
||||
raise SSEError(f"SSE connection failed: {e}")
|
||||
else:
|
||||
# Manual SSE parse over httpx streaming
|
||||
try:
|
||||
with self._client.stream("GET", url, headers={"Accept": "text/event-stream"}, timeout=None) as resp:
|
||||
resp.raise_for_status()
|
||||
buffer = ""
|
||||
for raw_line in resp.iter_lines():
|
||||
# httpx delivers bytes or str depending on backend; coerce to str
|
||||
line = raw_line.decode("utf-8", errors="ignore") if isinstance(raw_line, (bytes, bytearray)) else str(raw_line)
|
||||
if line == "":
|
||||
# End of event
|
||||
if buffer:
|
||||
try:
|
||||
data = json.loads(buffer)
|
||||
yield SSEMessage(**data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
buffer = ""
|
||||
continue
|
||||
if line.startswith(":"):
|
||||
# Comment/heartbeat
|
||||
continue
|
||||
if line.startswith("data:"):
|
||||
payload = line[5:].lstrip()
|
||||
# Accumulate multi-line data fields
|
||||
if buffer:
|
||||
buffer += payload
|
||||
else:
|
||||
buffer = payload
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise from_http_error(e.response.status_code, e.response.text, str(e.request.url))
|
||||
except Exception as e: # pragma: no cover
|
||||
raise SSEError(f"SSE error: {e}")
|
||||
|
||||
# Utility methods
|
||||
|
||||
def wait_for_completion(
|
||||
self,
|
||||
run_id: str,
|
||||
poll_interval: float = 5.0,
|
||||
timeout: Optional[float] = None
|
||||
) -> WorkflowStatus:
|
||||
"""
|
||||
Wait for a workflow run to complete.
|
||||
|
||||
Args:
|
||||
run_id: The run ID to monitor
|
||||
poll_interval: How often to check status (seconds)
|
||||
timeout: Maximum time to wait (seconds), None for no timeout
|
||||
|
||||
Returns:
|
||||
Final WorkflowStatus when completed
|
||||
|
||||
Raises:
|
||||
TimeoutError: If timeout is reached
|
||||
FuzzForgeHTTPError: If run fails or other API errors
|
||||
"""
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
status = self.get_run_status(run_id)
|
||||
|
||||
if status.is_completed:
|
||||
return status
|
||||
elif status.is_failed:
|
||||
raise FuzzForgeHTTPError(
|
||||
f"Run {run_id} failed with status: {status.status}",
|
||||
500,
|
||||
details={"run_id": run_id, "status": status.status}
|
||||
)
|
||||
|
||||
# Check timeout
|
||||
if timeout and (time.time() - start_time) > timeout:
|
||||
raise TimeoutError(f"Timeout waiting for run {run_id} to complete")
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
async def await_for_completion(
|
||||
self,
|
||||
run_id: str,
|
||||
poll_interval: float = 5.0,
|
||||
timeout: Optional[float] = None
|
||||
) -> WorkflowStatus:
|
||||
"""
|
||||
Wait for a workflow run to complete (async).
|
||||
|
||||
Args:
|
||||
run_id: The run ID to monitor
|
||||
poll_interval: How often to check status (seconds)
|
||||
timeout: Maximum time to wait (seconds), None for no timeout
|
||||
|
||||
Returns:
|
||||
Final WorkflowStatus when completed
|
||||
|
||||
Raises:
|
||||
TimeoutError: If timeout is reached
|
||||
FuzzForgeHTTPError: If run fails or other API errors
|
||||
"""
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
while True:
|
||||
status = await self.aget_run_status(run_id)
|
||||
|
||||
if status.is_completed:
|
||||
return status
|
||||
elif status.is_failed:
|
||||
raise FuzzForgeHTTPError(
|
||||
f"Run {run_id} failed with status: {status.status}",
|
||||
500,
|
||||
details={"run_id": run_id, "status": status.status}
|
||||
)
|
||||
|
||||
# Check timeout
|
||||
if timeout and (asyncio.get_event_loop().time() - start_time) > timeout:
|
||||
raise TimeoutError(f"Timeout waiting for run {run_id} to complete")
|
||||
|
||||
await asyncio.sleep(poll_interval)
|
||||
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Docker log integration for enhanced error reporting.
|
||||
|
||||
This module provides functionality to fetch and parse Docker container logs
|
||||
to provide better context for deployment and workflow execution errors.
|
||||
"""
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import json
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContainerLogEntry:
|
||||
"""A single log entry from a container."""
|
||||
timestamp: datetime
|
||||
level: str
|
||||
message: str
|
||||
stream: str # 'stdout' or 'stderr'
|
||||
raw: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContainerDiagnostics:
|
||||
"""Complete diagnostics for a container."""
|
||||
container_id: Optional[str]
|
||||
status: str
|
||||
exit_code: Optional[int]
|
||||
error: Optional[str]
|
||||
logs: List[ContainerLogEntry]
|
||||
resource_usage: Dict[str, Any]
|
||||
volume_mounts: List[Dict[str, str]]
|
||||
|
||||
|
||||
class DockerLogIntegration:
|
||||
"""
|
||||
Integration with Docker to fetch container logs and diagnostics.
|
||||
|
||||
This class provides methods to fetch container logs, parse common error
|
||||
patterns, and extract meaningful diagnostic information from Docker
|
||||
containers related to FuzzForge workflow execution.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.docker_available = self._check_docker_availability()
|
||||
|
||||
# Common error patterns in container logs
|
||||
self.error_patterns = {
|
||||
'permission_denied': [
|
||||
r'permission denied',
|
||||
r'operation not permitted',
|
||||
r'cannot access.*permission denied'
|
||||
],
|
||||
'out_of_memory': [
|
||||
r'out of memory',
|
||||
r'oom killed',
|
||||
r'cannot allocate memory'
|
||||
],
|
||||
'image_pull_failed': [
|
||||
r'failed to pull image',
|
||||
r'pull access denied',
|
||||
r'image not found'
|
||||
],
|
||||
'volume_mount_failed': [
|
||||
r'invalid mount config',
|
||||
r'mount denied',
|
||||
r'no such file or directory.*mount'
|
||||
],
|
||||
'network_error': [
|
||||
r'network is unreachable',
|
||||
r'connection refused',
|
||||
r'timeout.*connect'
|
||||
],
|
||||
'prefect_error': [
|
||||
r'prefect.*error',
|
||||
r'flow run failed',
|
||||
r'task.*failed'
|
||||
]
|
||||
}
|
||||
|
||||
def _check_docker_availability(self) -> bool:
|
||||
"""Check if Docker is available and accessible."""
|
||||
try:
|
||||
result = subprocess.run(['docker', 'version', '--format', 'json'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
def get_container_logs(self, container_name_or_id: str, tail: int = 100) -> List[ContainerLogEntry]:
|
||||
"""
|
||||
Fetch logs from a Docker container.
|
||||
|
||||
Args:
|
||||
container_name_or_id: Container name or ID
|
||||
tail: Number of log lines to retrieve
|
||||
|
||||
Returns:
|
||||
List of parsed log entries
|
||||
"""
|
||||
if not self.docker_available:
|
||||
logger.warning("Docker not available, cannot fetch container logs")
|
||||
return []
|
||||
|
||||
try:
|
||||
cmd = ['docker', 'logs', '--timestamps', '--tail', str(tail), container_name_or_id]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Failed to fetch logs for container {container_name_or_id}: {result.stderr}")
|
||||
return []
|
||||
|
||||
return self._parse_docker_logs(result.stdout + result.stderr)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"Timeout fetching logs for container {container_name_or_id}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching container logs: {e}")
|
||||
return []
|
||||
|
||||
def _parse_docker_logs(self, raw_logs: str) -> List[ContainerLogEntry]:
|
||||
"""Parse raw Docker logs into structured entries."""
|
||||
entries = []
|
||||
|
||||
for line in raw_logs.strip().split('\n'):
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
entry = self._parse_log_line(line)
|
||||
if entry:
|
||||
entries.append(entry)
|
||||
|
||||
return entries
|
||||
|
||||
def _parse_log_line(self, line: str) -> Optional[ContainerLogEntry]:
|
||||
"""Parse a single log line with timestamp."""
|
||||
# Docker log format: 2023-10-01T12:00:00.000000000Z message
|
||||
timestamp_match = re.match(r'^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)', line)
|
||||
|
||||
if timestamp_match:
|
||||
timestamp_str, message = timestamp_match.groups()
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
else:
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
message = line
|
||||
|
||||
# Determine log level from message content
|
||||
level = self._extract_log_level(message)
|
||||
|
||||
# Determine stream (simplified - Docker doesn't clearly separate in combined output)
|
||||
stream = 'stderr' if any(keyword in message.lower() for keyword in ['error', 'failed', 'exception']) else 'stdout'
|
||||
|
||||
return ContainerLogEntry(
|
||||
timestamp=timestamp,
|
||||
level=level,
|
||||
message=message.strip(),
|
||||
stream=stream,
|
||||
raw=line
|
||||
)
|
||||
|
||||
def _extract_log_level(self, message: str) -> str:
|
||||
"""Extract log level from message content."""
|
||||
message_lower = message.lower()
|
||||
|
||||
if any(keyword in message_lower for keyword in ['error', 'failed', 'exception', 'fatal']):
|
||||
return 'ERROR'
|
||||
elif any(keyword in message_lower for keyword in ['warning', 'warn']):
|
||||
return 'WARNING'
|
||||
elif any(keyword in message_lower for keyword in ['info', 'information']):
|
||||
return 'INFO'
|
||||
elif any(keyword in message_lower for keyword in ['debug']):
|
||||
return 'DEBUG'
|
||||
else:
|
||||
return 'INFO'
|
||||
|
||||
def get_container_diagnostics(self, container_name_or_id: str) -> ContainerDiagnostics:
|
||||
"""
|
||||
Get complete diagnostics for a container including logs, status, and resource usage.
|
||||
|
||||
Args:
|
||||
container_name_or_id: Container name or ID
|
||||
|
||||
Returns:
|
||||
Complete container diagnostics
|
||||
"""
|
||||
if not self.docker_available:
|
||||
return ContainerDiagnostics(
|
||||
container_id=None,
|
||||
status="unknown",
|
||||
exit_code=None,
|
||||
error="Docker not available",
|
||||
logs=[],
|
||||
resource_usage={},
|
||||
volume_mounts=[]
|
||||
)
|
||||
|
||||
# Get container inspect data
|
||||
inspect_data = self._get_container_inspect(container_name_or_id)
|
||||
|
||||
# Get logs
|
||||
logs = self.get_container_logs(container_name_or_id)
|
||||
|
||||
# Extract key information
|
||||
if inspect_data:
|
||||
state = inspect_data.get('State', {})
|
||||
config = inspect_data.get('Config', {})
|
||||
host_config = inspect_data.get('HostConfig', {})
|
||||
|
||||
status = state.get('Status', 'unknown')
|
||||
exit_code = state.get('ExitCode')
|
||||
error = state.get('Error', '')
|
||||
|
||||
# Get volume mounts
|
||||
mounts = inspect_data.get('Mounts', [])
|
||||
volume_mounts = [
|
||||
{
|
||||
'source': mount.get('Source', ''),
|
||||
'destination': mount.get('Destination', ''),
|
||||
'mode': mount.get('Mode', ''),
|
||||
'type': mount.get('Type', '')
|
||||
}
|
||||
for mount in mounts
|
||||
]
|
||||
|
||||
# Get resource limits
|
||||
resource_usage = {
|
||||
'memory_limit': host_config.get('Memory', 0),
|
||||
'cpu_limit': host_config.get('CpuQuota', 0),
|
||||
'cpu_period': host_config.get('CpuPeriod', 0)
|
||||
}
|
||||
|
||||
else:
|
||||
status = "not_found"
|
||||
exit_code = None
|
||||
error = f"Container {container_name_or_id} not found"
|
||||
volume_mounts = []
|
||||
resource_usage = {}
|
||||
|
||||
return ContainerDiagnostics(
|
||||
container_id=container_name_or_id,
|
||||
status=status,
|
||||
exit_code=exit_code,
|
||||
error=error,
|
||||
logs=logs,
|
||||
resource_usage=resource_usage,
|
||||
volume_mounts=volume_mounts
|
||||
)
|
||||
|
||||
def _get_container_inspect(self, container_name_or_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get container inspection data."""
|
||||
try:
|
||||
cmd = ['docker', 'inspect', container_name_or_id]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
return data[0] if data else None
|
||||
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception) as e:
|
||||
logger.debug(f"Failed to inspect container {container_name_or_id}: {e}")
|
||||
return None
|
||||
|
||||
def analyze_error_patterns(self, logs: List[ContainerLogEntry]) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Analyze logs for common error patterns.
|
||||
|
||||
Args:
|
||||
logs: List of log entries to analyze
|
||||
|
||||
Returns:
|
||||
Dictionary mapping error types to matching log messages
|
||||
"""
|
||||
detected_errors = {}
|
||||
|
||||
for error_type, patterns in self.error_patterns.items():
|
||||
matches = []
|
||||
|
||||
for log_entry in logs:
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, log_entry.message, re.IGNORECASE):
|
||||
matches.append(log_entry.message)
|
||||
break # Don't match the same message multiple times
|
||||
|
||||
if matches:
|
||||
detected_errors[error_type] = matches
|
||||
|
||||
return detected_errors
|
||||
|
||||
def get_container_names_by_label(self, label_filter: str) -> List[str]:
|
||||
"""
|
||||
Get container names that match a specific label filter.
|
||||
|
||||
Args:
|
||||
label_filter: Label filter (e.g., "prefect.flow-run-id=12345")
|
||||
|
||||
Returns:
|
||||
List of container names
|
||||
"""
|
||||
if not self.docker_available:
|
||||
return []
|
||||
|
||||
try:
|
||||
cmd = ['docker', 'ps', '-a', '--filter', f'label={label_filter}', '--format', '{{.Names}}']
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
return [name.strip() for name in result.stdout.strip().split('\n') if name.strip()]
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get containers by label {label_filter}: {e}")
|
||||
return []
|
||||
|
||||
def suggest_fixes(self, error_analysis: Dict[str, List[str]]) -> List[str]:
|
||||
"""
|
||||
Suggest fixes based on detected error patterns.
|
||||
|
||||
Args:
|
||||
error_analysis: Result from analyze_error_patterns()
|
||||
|
||||
Returns:
|
||||
List of suggested fixes
|
||||
"""
|
||||
suggestions = []
|
||||
|
||||
if 'permission_denied' in error_analysis:
|
||||
suggestions.extend([
|
||||
"Check file permissions on the target path",
|
||||
"Ensure the Docker daemon has access to the mounted volumes",
|
||||
"Try running with elevated privileges or adjust volume ownership"
|
||||
])
|
||||
|
||||
if 'out_of_memory' in error_analysis:
|
||||
suggestions.extend([
|
||||
"Increase memory limits for the workflow",
|
||||
"Check if the target files are too large for available memory",
|
||||
"Consider using streaming processing for large datasets"
|
||||
])
|
||||
|
||||
if 'image_pull_failed' in error_analysis:
|
||||
suggestions.extend([
|
||||
"Check network connectivity to Docker registry",
|
||||
"Verify image name and tag are correct",
|
||||
"Ensure Docker registry credentials are configured"
|
||||
])
|
||||
|
||||
if 'volume_mount_failed' in error_analysis:
|
||||
suggestions.extend([
|
||||
"Verify the target path exists and is accessible",
|
||||
"Check volume mount syntax and permissions",
|
||||
"Ensure the path is not already in use by another process"
|
||||
])
|
||||
|
||||
if 'network_error' in error_analysis:
|
||||
suggestions.extend([
|
||||
"Check network connectivity",
|
||||
"Verify backend services are running (docker-compose up -d)",
|
||||
"Check firewall settings and port availability"
|
||||
])
|
||||
|
||||
if 'prefect_error' in error_analysis:
|
||||
suggestions.extend([
|
||||
"Check Prefect server connectivity",
|
||||
"Verify workflow deployment is successful",
|
||||
"Review workflow-specific parameters and requirements"
|
||||
])
|
||||
|
||||
if not suggestions:
|
||||
suggestions.append("Review the container logs above for specific error details")
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
# Global instance for easy access
|
||||
docker_integration = DockerLogIntegration()
|
||||
@@ -0,0 +1,553 @@
|
||||
"""
|
||||
Enhanced exceptions for FuzzForge SDK with rich context and Docker integration.
|
||||
|
||||
Provides comprehensive error information including container logs, diagnostics,
|
||||
and actionable suggestions for troubleshooting.
|
||||
"""
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Optional, Dict, Any, List, Union
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
from .docker_logs import docker_integration, ContainerDiagnostics
|
||||
|
||||
|
||||
@dataclass
|
||||
class ErrorContext:
|
||||
"""Rich context information for errors."""
|
||||
url: Optional[str] = None
|
||||
request_method: Optional[str] = None
|
||||
request_data: Optional[Dict[str, Any]] = None
|
||||
response_data: Optional[Dict[str, Any]] = None
|
||||
container_diagnostics: Optional[ContainerDiagnostics] = None
|
||||
suggested_fixes: List[str] = None
|
||||
error_patterns: Dict[str, List[str]] = None
|
||||
related_run_id: Optional[str] = None
|
||||
workflow_name: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.suggested_fixes is None:
|
||||
self.suggested_fixes = []
|
||||
if self.error_patterns is None:
|
||||
self.error_patterns = {}
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class FuzzForgeError(Exception):
|
||||
"""Base exception for all FuzzForge SDK errors with rich context."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
context: Optional[ErrorContext] = None,
|
||||
original_exception: Optional[Exception] = None
|
||||
):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.context = context or ErrorContext()
|
||||
self.original_exception = original_exception
|
||||
|
||||
# Auto-populate container diagnostics if we have a run ID
|
||||
if self.context.related_run_id and not self.context.container_diagnostics:
|
||||
self._fetch_container_diagnostics()
|
||||
|
||||
def _fetch_container_diagnostics(self):
|
||||
"""Fetch container diagnostics for the related run."""
|
||||
if not self.context.related_run_id:
|
||||
return
|
||||
|
||||
try:
|
||||
# Try to find containers by Prefect run ID label
|
||||
label_filter = f"prefect.flow-run-id={self.context.related_run_id}"
|
||||
container_names = docker_integration.get_container_names_by_label(label_filter)
|
||||
|
||||
if container_names:
|
||||
# Use the most recent container
|
||||
container_name = container_names[0]
|
||||
diagnostics = docker_integration.get_container_diagnostics(container_name)
|
||||
|
||||
# Analyze error patterns in logs
|
||||
if diagnostics.logs:
|
||||
error_analysis = docker_integration.analyze_error_patterns(diagnostics.logs)
|
||||
suggestions = docker_integration.suggest_fixes(error_analysis)
|
||||
|
||||
self.context.container_diagnostics = diagnostics
|
||||
self.context.error_patterns = error_analysis
|
||||
self.context.suggested_fixes.extend(suggestions)
|
||||
|
||||
except Exception:
|
||||
# Don't fail the main error because of diagnostics issues
|
||||
pass
|
||||
|
||||
def get_summary(self) -> str:
|
||||
"""Get a summary of the error with key details."""
|
||||
parts = [self.message]
|
||||
|
||||
if self.context.container_diagnostics:
|
||||
diag = self.context.container_diagnostics
|
||||
if diag.status != 'running':
|
||||
parts.append(f"Container status: {diag.status}")
|
||||
if diag.exit_code is not None:
|
||||
parts.append(f"Exit code: {diag.exit_code}")
|
||||
|
||||
if self.context.error_patterns:
|
||||
detected = list(self.context.error_patterns.keys())
|
||||
parts.append(f"Detected issues: {', '.join(detected)}")
|
||||
|
||||
return " | ".join(parts)
|
||||
|
||||
def get_detailed_info(self) -> Dict[str, Any]:
|
||||
"""Get detailed error information for rich display."""
|
||||
info = {
|
||||
"message": self.message,
|
||||
"type": self.__class__.__name__,
|
||||
}
|
||||
|
||||
if self.context:
|
||||
info.update(self.context.to_dict())
|
||||
|
||||
return info
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.get_summary()
|
||||
|
||||
|
||||
class FuzzForgeHTTPError(FuzzForgeError):
|
||||
"""HTTP-related errors with enhanced context."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int,
|
||||
response_text: Optional[str] = None,
|
||||
context: Optional[ErrorContext] = None,
|
||||
original_exception: Optional[Exception] = None
|
||||
):
|
||||
if context is None:
|
||||
context = ErrorContext()
|
||||
|
||||
# Parse response data if it's JSON
|
||||
if response_text:
|
||||
try:
|
||||
context.response_data = json.loads(response_text)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
context.response_data = {"raw": response_text}
|
||||
|
||||
super().__init__(message, context, original_exception)
|
||||
self.status_code = status_code
|
||||
self.response_text = response_text
|
||||
|
||||
def get_summary(self) -> str:
|
||||
base = f"HTTP {self.status_code}: {self.message}"
|
||||
|
||||
if self.context.container_diagnostics:
|
||||
diag = self.context.container_diagnostics
|
||||
if diag.exit_code is not None and diag.exit_code != 0:
|
||||
base += f" (Container exit code: {diag.exit_code})"
|
||||
|
||||
return base
|
||||
|
||||
|
||||
class DeploymentError(FuzzForgeHTTPError):
|
||||
"""Enhanced deployment errors with container diagnostics."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow_name: str,
|
||||
message: str,
|
||||
deployment_id: Optional[str] = None,
|
||||
container_name: Optional[str] = None,
|
||||
status_code: int = 500,
|
||||
response_text: Optional[str] = None,
|
||||
context: Optional[ErrorContext] = None
|
||||
):
|
||||
if context is None:
|
||||
context = ErrorContext()
|
||||
|
||||
context.workflow_name = workflow_name
|
||||
|
||||
# If we have a container name, get its diagnostics immediately
|
||||
if container_name:
|
||||
try:
|
||||
diagnostics = docker_integration.get_container_diagnostics(container_name)
|
||||
context.container_diagnostics = diagnostics
|
||||
|
||||
# Analyze logs for error patterns
|
||||
if diagnostics.logs:
|
||||
error_analysis = docker_integration.analyze_error_patterns(diagnostics.logs)
|
||||
suggestions = docker_integration.suggest_fixes(error_analysis)
|
||||
|
||||
context.error_patterns = error_analysis
|
||||
context.suggested_fixes.extend(suggestions)
|
||||
|
||||
except Exception:
|
||||
# Don't fail on diagnostics
|
||||
pass
|
||||
|
||||
full_message = f"Deployment failed for workflow '{workflow_name}': {message}"
|
||||
super().__init__(full_message, status_code, response_text, context)
|
||||
self.workflow_name = workflow_name
|
||||
self.deployment_id = deployment_id
|
||||
|
||||
|
||||
class WorkflowExecutionError(FuzzForgeHTTPError):
|
||||
"""Enhanced workflow execution errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow_name: str,
|
||||
run_id: str,
|
||||
message: str,
|
||||
status_code: int = 400,
|
||||
response_text: Optional[str] = None,
|
||||
context: Optional[ErrorContext] = None
|
||||
):
|
||||
if context is None:
|
||||
context = ErrorContext()
|
||||
|
||||
context.workflow_name = workflow_name
|
||||
context.related_run_id = run_id
|
||||
|
||||
full_message = f"Workflow '{workflow_name}' execution failed (run: {run_id}): {message}"
|
||||
super().__init__(full_message, status_code, response_text, context)
|
||||
self.workflow_name = workflow_name
|
||||
self.run_id = run_id
|
||||
|
||||
|
||||
class WorkflowNotFoundError(FuzzForgeHTTPError):
|
||||
"""Enhanced workflow not found error."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow_name: str,
|
||||
available_workflows: List[str] = None,
|
||||
context: Optional[ErrorContext] = None
|
||||
):
|
||||
if context is None:
|
||||
context = ErrorContext()
|
||||
|
||||
context.workflow_name = workflow_name
|
||||
|
||||
if available_workflows:
|
||||
context.suggested_fixes = [
|
||||
f"Available workflows: {', '.join(available_workflows)}",
|
||||
"Use 'fuzzforge workflows list' to see all available workflows",
|
||||
"Check workflow name spelling and case sensitivity"
|
||||
]
|
||||
|
||||
message = f"Workflow not found: {workflow_name}"
|
||||
super().__init__(message, 404, context=context)
|
||||
self.workflow_name = workflow_name
|
||||
self.available_workflows = available_workflows or []
|
||||
|
||||
|
||||
class RunNotFoundError(FuzzForgeHTTPError):
|
||||
"""Enhanced run not found error."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
run_id: str,
|
||||
context: Optional[ErrorContext] = None
|
||||
):
|
||||
if context is None:
|
||||
context = ErrorContext()
|
||||
|
||||
context.related_run_id = run_id
|
||||
context.suggested_fixes = [
|
||||
"Use 'fuzzforge runs list' to see available runs",
|
||||
"Check if the run ID is correct and complete",
|
||||
"Ensure the run hasn't been deleted or expired"
|
||||
]
|
||||
|
||||
message = f"Run not found: {run_id}"
|
||||
super().__init__(message, 404, context=context)
|
||||
self.run_id = run_id
|
||||
|
||||
|
||||
class ContainerError(FuzzForgeError):
|
||||
"""Enhanced container-specific errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
container_name: str,
|
||||
message: str,
|
||||
exit_code: Optional[int] = None,
|
||||
context: Optional[ErrorContext] = None
|
||||
):
|
||||
if context is None:
|
||||
context = ErrorContext()
|
||||
|
||||
# Immediately fetch container diagnostics
|
||||
try:
|
||||
diagnostics = docker_integration.get_container_diagnostics(container_name)
|
||||
context.container_diagnostics = diagnostics
|
||||
|
||||
# Analyze logs for patterns
|
||||
if diagnostics.logs:
|
||||
error_analysis = docker_integration.analyze_error_patterns(diagnostics.logs)
|
||||
suggestions = docker_integration.suggest_fixes(error_analysis)
|
||||
|
||||
context.error_patterns = error_analysis
|
||||
context.suggested_fixes.extend(suggestions)
|
||||
|
||||
except Exception:
|
||||
# Don't fail on diagnostics
|
||||
pass
|
||||
|
||||
full_message = f"Container error ({container_name}): {message}"
|
||||
if exit_code is not None:
|
||||
full_message += f" (exit code: {exit_code})"
|
||||
|
||||
super().__init__(full_message, context)
|
||||
self.container_name = container_name
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
class VolumeError(FuzzForgeError):
|
||||
"""Volume mount related errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
volume_path: str,
|
||||
message: str,
|
||||
context: Optional[ErrorContext] = None
|
||||
):
|
||||
if context is None:
|
||||
context = ErrorContext()
|
||||
|
||||
context.suggested_fixes = [
|
||||
"Check if the volume path exists and is accessible",
|
||||
"Verify file permissions (Docker needs read access)",
|
||||
"Ensure the path is not in use by another process",
|
||||
"Try using an absolute path instead of relative path",
|
||||
"Check if SELinux or AppArmor is blocking access"
|
||||
]
|
||||
|
||||
full_message = f"Volume error ({volume_path}): {message}"
|
||||
super().__init__(full_message, context)
|
||||
self.volume_path = volume_path
|
||||
|
||||
|
||||
class ResourceLimitError(FuzzForgeError):
|
||||
"""Resource limit related errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource_type: str,
|
||||
message: str,
|
||||
current_usage: Optional[Dict[str, Any]] = None,
|
||||
context: Optional[ErrorContext] = None
|
||||
):
|
||||
if context is None:
|
||||
context = ErrorContext()
|
||||
|
||||
context.suggested_fixes = [
|
||||
f"Increase {resource_type} limits in workflow configuration",
|
||||
"Check system resource availability",
|
||||
"Consider using a smaller dataset or batch size",
|
||||
"Monitor resource usage during execution"
|
||||
]
|
||||
|
||||
full_message = f"{resource_type.title()} limit error: {message}"
|
||||
super().__init__(full_message, context)
|
||||
self.resource_type = resource_type
|
||||
self.current_usage = current_usage or {}
|
||||
|
||||
|
||||
class ValidationError(FuzzForgeError):
|
||||
"""Enhanced data validation errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
field_name: str,
|
||||
message: str,
|
||||
provided_value: Any = None,
|
||||
expected_format: Optional[str] = None,
|
||||
context: Optional[ErrorContext] = None
|
||||
):
|
||||
if context is None:
|
||||
context = ErrorContext()
|
||||
|
||||
if expected_format:
|
||||
context.suggested_fixes = [
|
||||
f"Expected format: {expected_format}",
|
||||
f"Provided value: {provided_value}",
|
||||
"Check parameter documentation for valid values"
|
||||
]
|
||||
|
||||
full_message = f"Validation error for '{field_name}': {message}"
|
||||
super().__init__(full_message, context)
|
||||
self.field_name = field_name
|
||||
self.provided_value = provided_value
|
||||
self.expected_format = expected_format
|
||||
|
||||
|
||||
class ConnectionError(FuzzForgeError):
|
||||
"""Enhanced connection errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str,
|
||||
message: str,
|
||||
context: Optional[ErrorContext] = None
|
||||
):
|
||||
if context is None:
|
||||
context = ErrorContext()
|
||||
|
||||
context.suggested_fixes = [
|
||||
"Check if FuzzForge backend is running (docker-compose up -d)",
|
||||
"Verify the API endpoint URL is correct",
|
||||
"Check network connectivity and firewall settings",
|
||||
"Ensure all required services are healthy",
|
||||
"Try restarting the FuzzForge services"
|
||||
]
|
||||
|
||||
full_message = f"Connection error to {endpoint}: {message}"
|
||||
super().__init__(full_message, context)
|
||||
self.endpoint = endpoint
|
||||
|
||||
|
||||
class TimeoutError(FuzzForgeError):
|
||||
"""Enhanced timeout errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
operation: str,
|
||||
timeout_seconds: int,
|
||||
context: Optional[ErrorContext] = None
|
||||
):
|
||||
if context is None:
|
||||
context = ErrorContext()
|
||||
|
||||
context.suggested_fixes = [
|
||||
f"Increase timeout value (current: {timeout_seconds}s)",
|
||||
"Check if the operation is resource-intensive",
|
||||
"Verify backend services are responsive",
|
||||
"Consider breaking down large operations into smaller chunks"
|
||||
]
|
||||
|
||||
full_message = f"Timeout error for {operation} after {timeout_seconds} seconds"
|
||||
super().__init__(full_message, context)
|
||||
self.operation = operation
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
|
||||
class WebSocketError(FuzzForgeError):
|
||||
"""WebSocket-related errors."""
|
||||
|
||||
|
||||
class SSEError(FuzzForgeError):
|
||||
"""Server-Sent Events related errors."""
|
||||
|
||||
|
||||
def from_http_error(status_code: int, response_text: str, url: str) -> FuzzForgeHTTPError:
|
||||
"""
|
||||
Create appropriate exception from HTTP error response with enhanced context.
|
||||
|
||||
Args:
|
||||
status_code: HTTP status code
|
||||
response_text: Response body text
|
||||
url: Request URL that failed
|
||||
|
||||
Returns:
|
||||
Appropriate FuzzForgeError subclass with rich context
|
||||
"""
|
||||
context = ErrorContext(url=url, response_data={"raw": response_text})
|
||||
|
||||
# Try to parse JSON response for more context
|
||||
try:
|
||||
response_data = json.loads(response_text)
|
||||
context.response_data = response_data
|
||||
|
||||
# Extract additional context from structured error responses
|
||||
if isinstance(response_data, dict):
|
||||
if "run_id" in response_data:
|
||||
context.related_run_id = response_data["run_id"]
|
||||
if "workflow" in response_data:
|
||||
context.workflow_name = response_data["workflow"]
|
||||
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# Unable to parse JSON, use raw text
|
||||
pass
|
||||
|
||||
# Handle specific error types based on URL patterns and status codes
|
||||
if status_code == 404:
|
||||
if "/workflows/" in url and "/submit" not in url:
|
||||
# Extract workflow name from URL
|
||||
parts = url.split("/workflows/")
|
||||
if len(parts) > 1:
|
||||
workflow_name = parts[1].split("/")[0]
|
||||
return WorkflowNotFoundError(workflow_name, context=context)
|
||||
|
||||
elif "/runs/" in url:
|
||||
# Extract run ID from URL
|
||||
parts = url.split("/runs/")
|
||||
if len(parts) > 1:
|
||||
run_id = parts[1].split("/")[0]
|
||||
return RunNotFoundError(run_id, context)
|
||||
|
||||
elif status_code == 400:
|
||||
# Check for specific error patterns in response
|
||||
if "deployment" in response_text.lower() and "not found" in response_text.lower():
|
||||
# Extract workflow name if possible
|
||||
workflow_match = re.search(r"workflow['\"]?\s*[:\-]?\s*['\"]?(\w+)", response_text, re.IGNORECASE)
|
||||
workflow_name = workflow_match.group(1) if workflow_match else "unknown"
|
||||
|
||||
return DeploymentError(
|
||||
workflow_name=workflow_name,
|
||||
message="Deployment not found",
|
||||
status_code=status_code,
|
||||
response_text=response_text,
|
||||
context=context
|
||||
)
|
||||
|
||||
elif "volume" in response_text.lower() or "mount" in response_text.lower():
|
||||
return VolumeError(
|
||||
volume_path="unknown",
|
||||
message=response_text,
|
||||
context=context
|
||||
)
|
||||
|
||||
elif "memory" in response_text.lower() or "resource" in response_text.lower():
|
||||
return ResourceLimitError(
|
||||
resource_type="memory",
|
||||
message=response_text,
|
||||
context=context
|
||||
)
|
||||
|
||||
elif status_code == 500:
|
||||
# Server errors might be deployment or execution issues
|
||||
if "deployment" in response_text.lower() or "container" in response_text.lower():
|
||||
workflow_match = re.search(r"workflow['\"]?\s*[:\-]?\s*['\"]?(\w+)", response_text, re.IGNORECASE)
|
||||
workflow_name = workflow_match.group(1) if workflow_match else "unknown"
|
||||
|
||||
return DeploymentError(
|
||||
workflow_name=workflow_name,
|
||||
message=response_text,
|
||||
status_code=status_code,
|
||||
response_text=response_text,
|
||||
context=context
|
||||
)
|
||||
|
||||
# Generic HTTP error with enhanced context
|
||||
return FuzzForgeHTTPError(
|
||||
message=f"HTTP request failed: {response_text}",
|
||||
status_code=status_code,
|
||||
response_text=response_text,
|
||||
context=context
|
||||
)
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Pydantic models for FuzzForge API data structures.
|
||||
|
||||
These models mirror the backend API models and provide type-safe data validation
|
||||
and serialization for all API requests and responses.
|
||||
"""
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Dict, Any, Optional, Literal, List, Union
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ResourceLimits(BaseModel):
|
||||
"""Resource limits for workflow execution"""
|
||||
cpu_limit: Optional[str] = Field(None, description="CPU limit (e.g., '2' for 2 cores, '500m' for 0.5 cores)")
|
||||
memory_limit: Optional[str] = Field(None, description="Memory limit (e.g., '1Gi', '512Mi')")
|
||||
cpu_request: Optional[str] = Field(None, description="CPU request (guaranteed)")
|
||||
memory_request: Optional[str] = Field(None, description="Memory request (guaranteed)")
|
||||
|
||||
|
||||
class VolumeMount(BaseModel):
|
||||
"""Volume mount specification"""
|
||||
host_path: str = Field(..., description="Host path to mount")
|
||||
container_path: str = Field(..., description="Container path for mount")
|
||||
mode: Literal["ro", "rw"] = Field(default="ro", description="Mount mode")
|
||||
|
||||
@validator("host_path")
|
||||
def validate_host_path(cls, v):
|
||||
"""Validate that the host path is absolute"""
|
||||
path = Path(v)
|
||||
if not path.is_absolute():
|
||||
raise ValueError(f"Host path must be absolute: {v}")
|
||||
return str(path)
|
||||
|
||||
@validator("container_path")
|
||||
def validate_container_path(cls, v):
|
||||
"""Validate that the container path is absolute"""
|
||||
if not v.startswith('/'):
|
||||
raise ValueError(f"Container path must be absolute: {v}")
|
||||
return v
|
||||
|
||||
|
||||
class WorkflowSubmission(BaseModel):
|
||||
"""Submit a workflow with configurable settings"""
|
||||
target_path: str = Field(..., description="Absolute path to analyze")
|
||||
volume_mode: Literal["ro", "rw"] = Field(
|
||||
default="ro",
|
||||
description="Volume mount mode: read-only (ro) or read-write (rw)"
|
||||
)
|
||||
parameters: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Workflow-specific parameters"
|
||||
)
|
||||
timeout: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Timeout in seconds (None for workflow default)",
|
||||
ge=1,
|
||||
le=604800 # Max 7 days
|
||||
)
|
||||
resource_limits: Optional[ResourceLimits] = Field(
|
||||
None,
|
||||
description="Resource limits for workflow container"
|
||||
)
|
||||
additional_volumes: List[VolumeMount] = Field(
|
||||
default_factory=list,
|
||||
description="Additional volume mounts"
|
||||
)
|
||||
|
||||
@validator("target_path")
|
||||
def validate_path(cls, v):
|
||||
"""Validate that the target path is absolute"""
|
||||
path = Path(v)
|
||||
if not path.is_absolute():
|
||||
raise ValueError(f"Path must be absolute: {v}")
|
||||
return str(path)
|
||||
|
||||
|
||||
class WorkflowListItem(BaseModel):
|
||||
"""Summary information for a workflow in list views"""
|
||||
name: str = Field(..., description="Workflow name")
|
||||
version: str = Field(..., description="Semantic version")
|
||||
description: str = Field(..., description="Workflow description")
|
||||
author: Optional[str] = Field(None, description="Workflow author")
|
||||
tags: List[str] = Field(default_factory=list, description="Workflow tags")
|
||||
|
||||
|
||||
class WorkflowMetadata(BaseModel):
|
||||
"""Complete metadata for a workflow"""
|
||||
name: str = Field(..., description="Workflow name")
|
||||
version: str = Field(..., description="Semantic version")
|
||||
description: str = Field(..., description="Workflow description")
|
||||
author: Optional[str] = Field(None, description="Workflow author")
|
||||
tags: List[str] = Field(default_factory=list, description="Workflow tags")
|
||||
parameters: Dict[str, Any] = Field(..., description="Parameters schema")
|
||||
default_parameters: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Default parameter values"
|
||||
)
|
||||
required_modules: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Required module names"
|
||||
)
|
||||
supported_volume_modes: List[Literal["ro", "rw"]] = Field(
|
||||
default=["ro", "rw"],
|
||||
description="Supported volume mount modes"
|
||||
)
|
||||
has_custom_docker: bool = Field(
|
||||
default=False,
|
||||
description="Whether workflow has custom Dockerfile"
|
||||
)
|
||||
|
||||
|
||||
class WorkflowParametersResponse(BaseModel):
|
||||
"""Response for workflow parameters endpoint"""
|
||||
parameters: Dict[str, Any] = Field(..., description="Parameters schema")
|
||||
defaults: Dict[str, Any] = Field(default_factory=dict, description="Default values")
|
||||
required: List[str] = Field(default_factory=list, description="Required parameter names")
|
||||
|
||||
|
||||
class RunSubmissionResponse(BaseModel):
|
||||
"""Response after submitting a workflow"""
|
||||
run_id: str = Field(..., description="Unique run identifier")
|
||||
status: str = Field(..., description="Initial status")
|
||||
workflow: str = Field(..., description="Workflow name")
|
||||
message: str = Field(default="Workflow submitted successfully")
|
||||
|
||||
|
||||
class WorkflowStatus(BaseModel):
|
||||
"""Status of a workflow run"""
|
||||
run_id: str = Field(..., description="Unique run identifier")
|
||||
workflow: str = Field(..., description="Workflow name")
|
||||
status: str = Field(..., description="Current status")
|
||||
is_completed: bool = Field(..., description="Whether the run is completed")
|
||||
is_failed: bool = Field(..., description="Whether the run failed")
|
||||
is_running: bool = Field(..., description="Whether the run is currently running")
|
||||
created_at: datetime = Field(..., description="Run creation time")
|
||||
updated_at: datetime = Field(..., description="Last update time")
|
||||
|
||||
|
||||
class WorkflowFindings(BaseModel):
|
||||
"""Findings from a workflow execution in SARIF format"""
|
||||
workflow: str = Field(..., description="Workflow name")
|
||||
run_id: str = Field(..., description="Unique run identifier")
|
||||
sarif: Dict[str, Any] = Field(..., description="SARIF formatted findings")
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
|
||||
|
||||
|
||||
class FuzzingStats(BaseModel):
|
||||
"""Real-time fuzzing statistics"""
|
||||
run_id: str = Field(..., description="Unique run identifier")
|
||||
workflow: str = Field(..., description="Workflow name")
|
||||
executions: int = Field(default=0, description="Total executions")
|
||||
executions_per_sec: float = Field(default=0.0, description="Current execution rate")
|
||||
crashes: int = Field(default=0, description="Total crashes found")
|
||||
unique_crashes: int = Field(default=0, description="Unique crashes")
|
||||
coverage: Optional[float] = Field(None, description="Code coverage percentage")
|
||||
corpus_size: int = Field(default=0, description="Current corpus size")
|
||||
elapsed_time: int = Field(default=0, description="Elapsed time in seconds")
|
||||
last_crash_time: Optional[datetime] = Field(None, description="Time of last crash")
|
||||
|
||||
|
||||
class CrashReport(BaseModel):
|
||||
"""Individual crash report from fuzzing"""
|
||||
run_id: str = Field(..., description="Run identifier")
|
||||
crash_id: str = Field(..., description="Unique crash identifier")
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
signal: Optional[str] = Field(None, description="Crash signal (SIGSEGV, etc.)")
|
||||
crash_type: Optional[str] = Field(None, description="Type of crash")
|
||||
stack_trace: Optional[str] = Field(None, description="Stack trace")
|
||||
input_file: Optional[str] = Field(None, description="Path to crashing input")
|
||||
reproducer: Optional[str] = Field(None, description="Minimized reproducer")
|
||||
severity: str = Field(default="medium", description="Crash severity")
|
||||
exploitability: Optional[str] = Field(None, description="Exploitability assessment")
|
||||
|
||||
|
||||
class APIStatus(BaseModel):
|
||||
"""API root endpoint response"""
|
||||
name: str = Field(..., description="API name")
|
||||
version: str = Field(..., description="API version")
|
||||
status: str = Field(..., description="API status")
|
||||
workflows_loaded: int = Field(..., description="Number of loaded workflows")
|
||||
|
||||
|
||||
class WebSocketMessage(BaseModel):
|
||||
"""WebSocket message format for real-time updates"""
|
||||
type: str = Field(..., description="Message type")
|
||||
data: Dict[str, Any] = Field(..., description="Message payload")
|
||||
|
||||
|
||||
class SSEMessage(BaseModel):
|
||||
"""Server-Sent Event message format"""
|
||||
type: str = Field(..., description="Event type")
|
||||
data: Union[FuzzingStats, CrashReport, Dict[str, Any]] = Field(..., description="Event data")
|
||||
@@ -0,0 +1,415 @@
|
||||
"""
|
||||
Automated testing utilities for FuzzForge workflows.
|
||||
|
||||
This module provides high-level testing capabilities for validating
|
||||
workflow functionality, performance, and expected results.
|
||||
"""
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from .client import FuzzForgeClient
|
||||
from .models import WorkflowSubmission
|
||||
from .utils import validate_absolute_path, create_workflow_submission
|
||||
from .exceptions import FuzzForgeError, ValidationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
"""Result of a single workflow test."""
|
||||
workflow_name: str
|
||||
test_project_path: str
|
||||
passed: bool
|
||||
run_id: Optional[str] = None
|
||||
findings_count: int = 0
|
||||
execution_time: float = 0.0
|
||||
error: Optional[str] = None
|
||||
expected_min_findings: int = 0
|
||||
details: Dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.details is None:
|
||||
self.details = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestSummary:
|
||||
"""Summary of multiple workflow tests."""
|
||||
total: int
|
||||
passed: int
|
||||
failed: int
|
||||
tests: List[TestResult]
|
||||
start_time: datetime
|
||||
end_time: Optional[datetime] = None
|
||||
total_duration: float = 0.0
|
||||
|
||||
@property
|
||||
def failed_tests(self) -> List[TestResult]:
|
||||
"""Get list of failed tests."""
|
||||
return [test for test in self.tests if not test.passed]
|
||||
|
||||
@property
|
||||
def success_rate(self) -> float:
|
||||
"""Get success rate as percentage."""
|
||||
if self.total == 0:
|
||||
return 0.0
|
||||
return (self.passed / self.total) * 100
|
||||
|
||||
|
||||
# Default test configurations for each workflow
|
||||
DEFAULT_TEST_CONFIG = {
|
||||
"static_analysis_scan": {
|
||||
"test_project": "static_analysis_vulnerable",
|
||||
"expected_min_findings": 3, # Expect at least 3 findings
|
||||
"timeout": 300, # 5 minutes
|
||||
"description": "Tests OpenGrep and Bandit static analysis tools"
|
||||
},
|
||||
"secret_detection_scan": {
|
||||
"test_project": "secret_detection_vulnerable",
|
||||
"expected_min_findings": 5, # Expect at least 5 secrets
|
||||
"timeout": 180, # 3 minutes
|
||||
"description": "Tests TruffleHog and Gitleaks secret detection"
|
||||
},
|
||||
"infrastructure_scan": {
|
||||
"test_project": "infrastructure_vulnerable",
|
||||
"expected_min_findings": 8, # Expect at least 8 IaC issues
|
||||
"timeout": 240, # 4 minutes
|
||||
"description": "Tests Checkov, Hadolint, and other IaC security tools"
|
||||
},
|
||||
"penetration_testing_scan": {
|
||||
"test_project": "penetration_testing_vulnerable",
|
||||
"expected_min_findings": 4, # Expect at least 4 vulnerabilities
|
||||
"timeout": 420, # 7 minutes (needs time to start services)
|
||||
"description": "Tests Nuclei penetration testing tools"
|
||||
},
|
||||
"security_assessment": {
|
||||
"test_project": "security_assessment_comprehensive",
|
||||
"expected_min_findings": 10, # Expect at least 10 mixed findings
|
||||
"timeout": 600, # 10 minutes (comprehensive scan)
|
||||
"description": "Comprehensive security assessment across all categories"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class WorkflowTester:
|
||||
"""
|
||||
High-level testing utilities for FuzzForge workflows.
|
||||
|
||||
This class provides methods to easily test individual workflows or
|
||||
run comprehensive test suites against all available workflows.
|
||||
"""
|
||||
|
||||
def __init__(self, client: FuzzForgeClient, test_projects_base_path: Optional[str] = None):
|
||||
"""
|
||||
Initialize the workflow tester.
|
||||
|
||||
Args:
|
||||
client: FuzzForge client instance
|
||||
test_projects_base_path: Base path to test projects directory
|
||||
"""
|
||||
self.client = client
|
||||
self.test_projects_base_path = test_projects_base_path
|
||||
|
||||
if not test_projects_base_path:
|
||||
# Try to auto-detect test projects path
|
||||
current_dir = Path.cwd()
|
||||
candidates = [
|
||||
current_dir / "test_projects",
|
||||
current_dir.parent / "test_projects",
|
||||
current_dir / ".." / "test_projects",
|
||||
Path("/app/test_projects"), # Inside Docker container (last resort)
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.exists() and candidate.is_dir():
|
||||
self.test_projects_base_path = str(candidate.resolve())
|
||||
logger.info(f"Auto-detected test projects at: {self.test_projects_base_path}")
|
||||
break
|
||||
|
||||
if not self.test_projects_base_path:
|
||||
logger.warning("Could not auto-detect test projects path. Please specify explicitly.")
|
||||
self.test_projects_base_path = str(current_dir / "test_projects")
|
||||
|
||||
def test_workflow(
|
||||
self,
|
||||
workflow_name: str,
|
||||
test_project_path: Optional[str] = None,
|
||||
expected_min_findings: Optional[int] = None,
|
||||
timeout: int = 300,
|
||||
**workflow_params
|
||||
) -> TestResult:
|
||||
"""
|
||||
Test a single workflow against a test project.
|
||||
|
||||
Args:
|
||||
workflow_name: Name of the workflow to test
|
||||
test_project_path: Path to test project (or relative to base path)
|
||||
expected_min_findings: Minimum expected findings for test to pass
|
||||
timeout: Timeout in seconds
|
||||
**workflow_params: Additional workflow parameters
|
||||
|
||||
Returns:
|
||||
TestResult with test outcome and details
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Get test configuration
|
||||
config = DEFAULT_TEST_CONFIG.get(workflow_name, {})
|
||||
if expected_min_findings is None:
|
||||
expected_min_findings = config.get("expected_min_findings", 0)
|
||||
if timeout == 300: # Use config timeout if default
|
||||
timeout = config.get("timeout", 300)
|
||||
|
||||
# Resolve test project path
|
||||
if test_project_path is None:
|
||||
test_project_name = config.get("test_project")
|
||||
if not test_project_name:
|
||||
raise ValidationError(f"No test project configured for workflow: {workflow_name}")
|
||||
test_project_path = str(Path(self.test_projects_base_path) / test_project_name)
|
||||
elif not Path(test_project_path).is_absolute():
|
||||
test_project_path = str(Path(self.test_projects_base_path) / test_project_path)
|
||||
|
||||
# Validate path exists
|
||||
test_path = validate_absolute_path(test_project_path)
|
||||
|
||||
logger.info(f"Testing workflow '{workflow_name}' with project: {test_path}")
|
||||
|
||||
# Create workflow submission
|
||||
submission = create_workflow_submission(
|
||||
target_path=str(test_path),
|
||||
volume_mode="ro",
|
||||
**workflow_params
|
||||
)
|
||||
|
||||
# Submit workflow
|
||||
response = self.client.submit_workflow(workflow_name, submission)
|
||||
run_id = response.run_id
|
||||
|
||||
logger.info(f"Workflow submitted with run_id: {run_id}")
|
||||
|
||||
# Wait for completion
|
||||
final_status = self.client.wait_for_completion(
|
||||
run_id=run_id,
|
||||
timeout=timeout,
|
||||
poll_interval=5
|
||||
)
|
||||
|
||||
# Get findings
|
||||
findings = self.client.get_run_findings(run_id)
|
||||
findings_count = 0
|
||||
|
||||
# Count findings from SARIF data if available
|
||||
if hasattr(findings, 'sarif') and findings.sarif:
|
||||
findings_count = findings.sarif.get('total_findings', 0)
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
# Determine if test passed
|
||||
passed = (
|
||||
final_status.is_completed and
|
||||
not final_status.is_failed and
|
||||
findings_count >= expected_min_findings
|
||||
)
|
||||
|
||||
result = TestResult(
|
||||
workflow_name=workflow_name,
|
||||
test_project_path=test_project_path,
|
||||
passed=passed,
|
||||
run_id=run_id,
|
||||
findings_count=findings_count,
|
||||
execution_time=execution_time,
|
||||
expected_min_findings=expected_min_findings,
|
||||
details={
|
||||
"status": final_status.status,
|
||||
"sarif_summary": getattr(findings, 'sarif', {}),
|
||||
"config_used": config.get("description", ""),
|
||||
"timeout_used": timeout
|
||||
}
|
||||
)
|
||||
|
||||
if not passed:
|
||||
if final_status.is_failed:
|
||||
result.error = f"Workflow execution failed with status: {final_status.status}"
|
||||
elif findings_count < expected_min_findings:
|
||||
result.error = f"Found {findings_count} findings, expected at least {expected_min_findings}"
|
||||
|
||||
logger.info(f"Test {'PASSED' if passed else 'FAILED'}: {workflow_name}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
execution_time = time.time() - start_time
|
||||
error_msg = f"Test execution failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
|
||||
return TestResult(
|
||||
workflow_name=workflow_name,
|
||||
test_project_path=test_project_path or "unknown",
|
||||
passed=False,
|
||||
execution_time=execution_time,
|
||||
expected_min_findings=expected_min_findings or 0,
|
||||
error=error_msg
|
||||
)
|
||||
|
||||
def test_all_workflows(
|
||||
self,
|
||||
workflows: Optional[List[str]] = None,
|
||||
parallel: bool = False
|
||||
) -> TestSummary:
|
||||
"""
|
||||
Test all available workflows.
|
||||
|
||||
Args:
|
||||
workflows: List of specific workflows to test (defaults to all available)
|
||||
parallel: Whether to run tests in parallel (not yet implemented)
|
||||
|
||||
Returns:
|
||||
TestSummary with results from all tests
|
||||
"""
|
||||
start_time = datetime.now()
|
||||
|
||||
try:
|
||||
# Get available workflows if not specified
|
||||
if workflows is None:
|
||||
workflow_list = self.client.list_workflows()
|
||||
workflows = [w.name for w in workflow_list]
|
||||
logger.info(f"Testing {len(workflows)} workflows: {', '.join(workflows)}")
|
||||
|
||||
results = []
|
||||
|
||||
# Test each workflow
|
||||
for workflow_name in workflows:
|
||||
logger.info(f"Testing workflow: {workflow_name}")
|
||||
result = self.test_workflow(workflow_name)
|
||||
results.append(result)
|
||||
|
||||
end_time = datetime.now()
|
||||
total_duration = (end_time - start_time).total_seconds()
|
||||
|
||||
passed = len([r for r in results if r.passed])
|
||||
failed = len(results) - passed
|
||||
|
||||
summary = TestSummary(
|
||||
total=len(results),
|
||||
passed=passed,
|
||||
failed=failed,
|
||||
tests=results,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
total_duration=total_duration
|
||||
)
|
||||
|
||||
logger.info(f"Testing complete: {passed}/{len(results)} workflows passed")
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test suite execution failed: {e}")
|
||||
end_time = datetime.now()
|
||||
return TestSummary(
|
||||
total=0,
|
||||
passed=0,
|
||||
failed=1,
|
||||
tests=[],
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
total_duration=(end_time - start_time).total_seconds()
|
||||
)
|
||||
|
||||
def validate_workflow_deployment(self, workflow_name: str) -> bool:
|
||||
"""
|
||||
Validate that a workflow is properly deployed and available.
|
||||
|
||||
Args:
|
||||
workflow_name: Name of the workflow to validate
|
||||
|
||||
Returns:
|
||||
True if workflow is available, False otherwise
|
||||
"""
|
||||
try:
|
||||
workflows = self.client.list_workflows()
|
||||
available_workflows = [w.name for w in workflows]
|
||||
return workflow_name in available_workflows
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to validate workflow deployment: {e}")
|
||||
return False
|
||||
|
||||
def get_test_project_path(self, project_name: str) -> str:
|
||||
"""
|
||||
Get the full path to a test project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the test project
|
||||
|
||||
Returns:
|
||||
Full path to the test project
|
||||
"""
|
||||
return str(Path(self.test_projects_base_path) / project_name)
|
||||
|
||||
|
||||
def format_test_summary(summary: TestSummary, detailed: bool = False) -> str:
|
||||
"""
|
||||
Format a test summary for display.
|
||||
|
||||
Args:
|
||||
summary: Test summary to format
|
||||
detailed: Whether to include detailed results
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
lines = []
|
||||
|
||||
# Header
|
||||
lines.append("=" * 60)
|
||||
lines.append("FuzzForge Workflow Test Results")
|
||||
lines.append("=" * 60)
|
||||
|
||||
# Summary stats
|
||||
lines.append(f"Total Tests: {summary.total}")
|
||||
lines.append(f"Passed: {summary.passed} ✅")
|
||||
lines.append(f"Failed: {summary.failed} ❌")
|
||||
lines.append(f"Success Rate: {summary.success_rate:.1f}%")
|
||||
lines.append(f"Total Duration: {summary.total_duration:.1f}s")
|
||||
lines.append("")
|
||||
|
||||
if detailed and summary.tests:
|
||||
lines.append("Detailed Results:")
|
||||
lines.append("-" * 40)
|
||||
|
||||
for test in summary.tests:
|
||||
status_icon = "✅" if test.passed else "❌"
|
||||
lines.append(f"{status_icon} {test.workflow_name}")
|
||||
lines.append(f" Project: {Path(test.test_project_path).name}")
|
||||
lines.append(f" Findings: {test.findings_count} (expected ≥{test.expected_min_findings})")
|
||||
lines.append(f" Duration: {test.execution_time:.1f}s")
|
||||
|
||||
if test.error:
|
||||
lines.append(f" Error: {test.error}")
|
||||
lines.append("")
|
||||
|
||||
# Failed tests summary
|
||||
if summary.failed_tests:
|
||||
lines.append("Failed Tests:")
|
||||
lines.append("-" * 40)
|
||||
for test in summary.failed_tests:
|
||||
lines.append(f"❌ {test.workflow_name}: {test.error or 'Unknown error'}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,432 @@
|
||||
"""
|
||||
Utility functions for the FuzzForge SDK.
|
||||
|
||||
Provides helper functions for path validation, SARIF processing,
|
||||
volume mount creation, and other common operations.
|
||||
"""
|
||||
# Copyright (c) 2025 FuzzingLabs
|
||||
#
|
||||
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
|
||||
# at the root of this repository for details.
|
||||
#
|
||||
# After the Change Date (four years from publication), this version of the
|
||||
# Licensed Work will be made available under the Apache License, Version 2.0.
|
||||
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Additional attribution and requirements are provided in the NOTICE file.
|
||||
|
||||
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from datetime import datetime
|
||||
|
||||
from .models import VolumeMount, ResourceLimits, WorkflowSubmission
|
||||
from .exceptions import ValidationError
|
||||
|
||||
|
||||
def validate_absolute_path(path: Union[str, Path]) -> Path:
|
||||
"""
|
||||
Validate that a path is absolute and exists.
|
||||
|
||||
Args:
|
||||
path: Path to validate
|
||||
|
||||
Returns:
|
||||
Validated Path object
|
||||
|
||||
Raises:
|
||||
ValidationError: If path is not absolute or doesn't exist
|
||||
"""
|
||||
path_obj = Path(path)
|
||||
|
||||
if not path_obj.is_absolute():
|
||||
raise ValidationError(f"Path must be absolute: {path}")
|
||||
|
||||
if not path_obj.exists():
|
||||
raise ValidationError(f"Path does not exist: {path}")
|
||||
|
||||
return path_obj
|
||||
|
||||
|
||||
def create_volume_mount(
|
||||
host_path: Union[str, Path],
|
||||
container_path: str,
|
||||
mode: str = "ro"
|
||||
) -> VolumeMount:
|
||||
"""
|
||||
Create a volume mount with path validation.
|
||||
|
||||
Args:
|
||||
host_path: Host path to mount (must exist)
|
||||
container_path: Container path for the mount
|
||||
mode: Mount mode ("ro" or "rw")
|
||||
|
||||
Returns:
|
||||
VolumeMount object
|
||||
|
||||
Raises:
|
||||
ValidationError: If paths are invalid
|
||||
"""
|
||||
# Validate host path exists and is absolute
|
||||
validated_host_path = validate_absolute_path(host_path)
|
||||
|
||||
# Validate container path is absolute
|
||||
if not container_path.startswith('/'):
|
||||
raise ValidationError(f"Container path must be absolute: {container_path}")
|
||||
|
||||
# Validate mode
|
||||
if mode not in ["ro", "rw"]:
|
||||
raise ValidationError(f"Mode must be 'ro' or 'rw': {mode}")
|
||||
|
||||
return VolumeMount(
|
||||
host_path=str(validated_host_path),
|
||||
container_path=container_path,
|
||||
mode=mode # type: ignore
|
||||
)
|
||||
|
||||
|
||||
def create_resource_limits(
|
||||
cpu_limit: Optional[str] = None,
|
||||
memory_limit: Optional[str] = None,
|
||||
cpu_request: Optional[str] = None,
|
||||
memory_request: Optional[str] = None
|
||||
) -> ResourceLimits:
|
||||
"""
|
||||
Create resource limits with validation.
|
||||
|
||||
Args:
|
||||
cpu_limit: CPU limit (e.g., "2", "500m")
|
||||
memory_limit: Memory limit (e.g., "1Gi", "512Mi")
|
||||
cpu_request: CPU request (guaranteed)
|
||||
memory_request: Memory request (guaranteed)
|
||||
|
||||
Returns:
|
||||
ResourceLimits object
|
||||
|
||||
Raises:
|
||||
ValidationError: If resource specifications are invalid
|
||||
"""
|
||||
# Basic validation for CPU limits
|
||||
if cpu_limit is not None:
|
||||
if not (cpu_limit.endswith('m') or cpu_limit.isdigit()):
|
||||
raise ValidationError(f"Invalid CPU limit format: {cpu_limit}")
|
||||
|
||||
if cpu_request is not None:
|
||||
if not (cpu_request.endswith('m') or cpu_request.isdigit()):
|
||||
raise ValidationError(f"Invalid CPU request format: {cpu_request}")
|
||||
|
||||
# Basic validation for memory limits
|
||||
memory_suffixes = ['Ki', 'Mi', 'Gi', 'Ti', 'K', 'M', 'G', 'T']
|
||||
|
||||
if memory_limit is not None:
|
||||
if not any(memory_limit.endswith(suffix) for suffix in memory_suffixes):
|
||||
if not memory_limit.isdigit():
|
||||
raise ValidationError(f"Invalid memory limit format: {memory_limit}")
|
||||
|
||||
if memory_request is not None:
|
||||
if not any(memory_request.endswith(suffix) for suffix in memory_suffixes):
|
||||
if not memory_request.isdigit():
|
||||
raise ValidationError(f"Invalid memory request format: {memory_request}")
|
||||
|
||||
return ResourceLimits(
|
||||
cpu_limit=cpu_limit,
|
||||
memory_limit=memory_limit,
|
||||
cpu_request=cpu_request,
|
||||
memory_request=memory_request
|
||||
)
|
||||
|
||||
|
||||
def create_workflow_submission(
|
||||
target_path: Union[str, Path],
|
||||
volume_mode: str = "ro",
|
||||
parameters: Optional[Dict[str, Any]] = None,
|
||||
timeout: Optional[int] = None,
|
||||
resource_limits: Optional[ResourceLimits] = None,
|
||||
additional_volumes: Optional[List[VolumeMount]] = None
|
||||
) -> WorkflowSubmission:
|
||||
"""
|
||||
Create a workflow submission with path validation.
|
||||
|
||||
Args:
|
||||
target_path: Path to analyze (must exist)
|
||||
volume_mode: Mount mode for target path
|
||||
parameters: Workflow-specific parameters
|
||||
timeout: Execution timeout in seconds
|
||||
resource_limits: Resource limits for the container
|
||||
additional_volumes: Additional volume mounts
|
||||
|
||||
Returns:
|
||||
WorkflowSubmission object
|
||||
|
||||
Raises:
|
||||
ValidationError: If parameters are invalid
|
||||
"""
|
||||
# Validate target path
|
||||
validated_target_path = validate_absolute_path(target_path)
|
||||
|
||||
# Validate volume mode
|
||||
if volume_mode not in ["ro", "rw"]:
|
||||
raise ValidationError(f"Volume mode must be 'ro' or 'rw': {volume_mode}")
|
||||
|
||||
# Validate timeout
|
||||
if timeout is not None:
|
||||
if timeout < 1 or timeout > 604800: # Max 7 days
|
||||
raise ValidationError(f"Timeout must be between 1 and 604800 seconds: {timeout}")
|
||||
|
||||
return WorkflowSubmission(
|
||||
target_path=str(validated_target_path),
|
||||
volume_mode=volume_mode, # type: ignore
|
||||
parameters=parameters or {},
|
||||
timeout=timeout,
|
||||
resource_limits=resource_limits,
|
||||
additional_volumes=additional_volumes or []
|
||||
)
|
||||
|
||||
|
||||
def extract_sarif_results(sarif_data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Extract results from SARIF format findings.
|
||||
|
||||
Args:
|
||||
sarif_data: SARIF formatted data
|
||||
|
||||
Returns:
|
||||
List of result objects from SARIF
|
||||
|
||||
Raises:
|
||||
ValidationError: If SARIF data is malformed
|
||||
"""
|
||||
if not isinstance(sarif_data, dict):
|
||||
raise ValidationError("SARIF data must be a dictionary")
|
||||
|
||||
runs = sarif_data.get("runs", [])
|
||||
if not isinstance(runs, list):
|
||||
raise ValidationError("SARIF runs must be a list")
|
||||
|
||||
results = []
|
||||
for run in runs:
|
||||
if not isinstance(run, dict):
|
||||
continue
|
||||
|
||||
run_results = run.get("results", [])
|
||||
if isinstance(run_results, list):
|
||||
results.extend(run_results)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def count_sarif_severity_levels(sarif_data: Dict[str, Any]) -> Dict[str, int]:
|
||||
"""
|
||||
Count findings by severity level in SARIF data.
|
||||
|
||||
Args:
|
||||
sarif_data: SARIF formatted data
|
||||
|
||||
Returns:
|
||||
Dictionary mapping severity levels to counts
|
||||
"""
|
||||
results = extract_sarif_results(sarif_data)
|
||||
severity_counts = {"error": 0, "warning": 0, "note": 0, "info": 0}
|
||||
|
||||
for result in results:
|
||||
level = result.get("level", "warning")
|
||||
if level in severity_counts:
|
||||
severity_counts[level] += 1
|
||||
else:
|
||||
# Default unknown levels to warning
|
||||
severity_counts["warning"] += 1
|
||||
|
||||
return severity_counts
|
||||
|
||||
|
||||
def format_sarif_summary(sarif_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Create a human-readable summary of SARIF findings.
|
||||
|
||||
Args:
|
||||
sarif_data: SARIF formatted data
|
||||
|
||||
Returns:
|
||||
Formatted summary string
|
||||
"""
|
||||
severity_counts = count_sarif_severity_levels(sarif_data)
|
||||
total_findings = sum(severity_counts.values())
|
||||
|
||||
if total_findings == 0:
|
||||
return "No findings detected."
|
||||
|
||||
summary_parts = [f"Total findings: {total_findings}"]
|
||||
|
||||
for level, count in severity_counts.items():
|
||||
if count > 0:
|
||||
summary_parts.append(f"{level.title()}: {count}")
|
||||
|
||||
return " | ".join(summary_parts)
|
||||
|
||||
|
||||
def save_sarif_to_file(sarif_data: Dict[str, Any], file_path: Union[str, Path]) -> None:
|
||||
"""
|
||||
Save SARIF data to a JSON file.
|
||||
|
||||
Args:
|
||||
sarif_data: SARIF formatted data
|
||||
file_path: Path to save the file
|
||||
|
||||
Raises:
|
||||
ValidationError: If file cannot be written
|
||||
"""
|
||||
try:
|
||||
path_obj = Path(file_path)
|
||||
# Create parent directories if they don't exist
|
||||
path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(path_obj, 'w', encoding='utf-8') as f:
|
||||
json.dump(sarif_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
except (OSError, json.JSONEncodeError) as e:
|
||||
raise ValidationError(f"Failed to save SARIF file: {e}")
|
||||
|
||||
|
||||
def format_duration(seconds: int) -> str:
|
||||
"""
|
||||
Format duration in seconds to human-readable string.
|
||||
|
||||
Args:
|
||||
seconds: Duration in seconds
|
||||
|
||||
Returns:
|
||||
Formatted duration string
|
||||
"""
|
||||
if seconds < 60:
|
||||
return f"{seconds}s"
|
||||
elif seconds < 3600:
|
||||
minutes, secs = divmod(seconds, 60)
|
||||
return f"{minutes}m {secs}s"
|
||||
else:
|
||||
hours, remainder = divmod(seconds, 3600)
|
||||
minutes, secs = divmod(remainder, 60)
|
||||
return f"{hours}h {minutes}m {secs}s"
|
||||
|
||||
|
||||
def format_execution_rate(executions_per_sec: float) -> str:
|
||||
"""
|
||||
Format execution rate for display.
|
||||
|
||||
Args:
|
||||
executions_per_sec: Executions per second
|
||||
|
||||
Returns:
|
||||
Formatted rate string
|
||||
"""
|
||||
if executions_per_sec < 1:
|
||||
return f"{executions_per_sec:.2f} exec/s"
|
||||
elif executions_per_sec < 1000:
|
||||
return f"{executions_per_sec:.1f} exec/s"
|
||||
else:
|
||||
return f"{executions_per_sec/1000:.1f}k exec/s"
|
||||
|
||||
|
||||
def format_memory_size(size_bytes: int) -> str:
|
||||
"""
|
||||
Format memory size in bytes to human-readable string.
|
||||
|
||||
Args:
|
||||
size_bytes: Size in bytes
|
||||
|
||||
Returns:
|
||||
Formatted size string
|
||||
"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.1f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
return f"{size_bytes:.1f} PB"
|
||||
|
||||
|
||||
def get_project_files(
|
||||
project_path: Union[str, Path],
|
||||
extensions: Optional[List[str]] = None,
|
||||
exclude_dirs: Optional[List[str]] = None
|
||||
) -> List[Path]:
|
||||
"""
|
||||
Get list of files in a project directory.
|
||||
|
||||
Args:
|
||||
project_path: Path to project directory
|
||||
extensions: List of file extensions to include (e.g., ['.py', '.js'])
|
||||
exclude_dirs: List of directory names to exclude (e.g., ['.git', 'node_modules'])
|
||||
|
||||
Returns:
|
||||
List of file paths
|
||||
|
||||
Raises:
|
||||
ValidationError: If project path is invalid
|
||||
"""
|
||||
project_path_obj = validate_absolute_path(project_path)
|
||||
|
||||
if not project_path_obj.is_dir():
|
||||
raise ValidationError(f"Project path must be a directory: {project_path}")
|
||||
|
||||
exclude_dirs = exclude_dirs or ['.git', '__pycache__', 'node_modules', '.pytest_cache']
|
||||
extensions = extensions or []
|
||||
|
||||
files = []
|
||||
|
||||
for root, dirs, filenames in os.walk(project_path_obj):
|
||||
# Remove excluded directories from search
|
||||
dirs[:] = [d for d in dirs if d not in exclude_dirs]
|
||||
|
||||
root_path = Path(root)
|
||||
|
||||
for filename in filenames:
|
||||
file_path = root_path / filename
|
||||
|
||||
# Filter by extensions if specified
|
||||
if extensions and not any(filename.endswith(ext) for ext in extensions):
|
||||
continue
|
||||
|
||||
files.append(file_path)
|
||||
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def estimate_analysis_time(
|
||||
project_path: Union[str, Path],
|
||||
workflow_type: str = "static"
|
||||
) -> int:
|
||||
"""
|
||||
Estimate analysis time based on project size and workflow type.
|
||||
|
||||
Args:
|
||||
project_path: Path to project directory
|
||||
workflow_type: Type of workflow ("static", "dynamic", "fuzzing")
|
||||
|
||||
Returns:
|
||||
Estimated time in seconds
|
||||
|
||||
Raises:
|
||||
ValidationError: If project path is invalid
|
||||
"""
|
||||
files = get_project_files(project_path)
|
||||
total_size = sum(f.stat().st_size for f in files if f.exists())
|
||||
|
||||
# Base estimates (very rough)
|
||||
if workflow_type == "static":
|
||||
# ~1MB per second for static analysis
|
||||
base_time = max(30, total_size // (1024 * 1024))
|
||||
elif workflow_type == "dynamic":
|
||||
# Dynamic analysis is slower
|
||||
base_time = max(60, total_size // (512 * 1024))
|
||||
elif workflow_type == "fuzzing":
|
||||
# Fuzzing can run for hours/days
|
||||
base_time = 3600 # Default to 1 hour
|
||||
else:
|
||||
# Unknown workflow type
|
||||
base_time = max(60, total_size // (1024 * 1024))
|
||||
|
||||
# Factor in number of files
|
||||
file_factor = max(1, len(files) // 100)
|
||||
|
||||
return base_time * file_factor
|
||||
Generated
+478
@@ -0,0 +1,478 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "25.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372, upload-time = "2025-01-29T05:37:11.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865, upload-time = "2025-01-29T05:37:14.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699, upload-time = "2025-01-29T04:18:17.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028, upload-time = "2025-01-29T04:18:51.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload-time = "2025-01-29T05:37:16.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload-time = "2025-01-29T05:37:18.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload-time = "2025-01-29T04:18:33.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload-time = "2025-01-29T04:19:12.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload-time = "2025-01-29T05:37:20.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload-time = "2025-01-29T05:37:22.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload-time = "2025-01-29T04:18:58.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload-time = "2025-01-29T04:19:27.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.8.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fuzzforge-sdk"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "sseclient-py" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "black" },
|
||||
{ name = "isort" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "black", marker = "extra == 'dev'", specifier = ">=24.0.0" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "isort", marker = "extra == 'dev'", specifier = ">=5.13.0" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.11.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.14.0" },
|
||||
{ name = "sseclient-py", specifier = ">=1.8.0" },
|
||||
{ name = "websockets", specifier = ">=13.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "isort"
|
||||
version = "6.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955, upload-time = "2025-02-26T21:13:16.955Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186, upload-time = "2025-02-26T21:13:14.911Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.18.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/a3/931e09fc02d7ba96da65266884da4e4a8806adcdb8a57faaacc6edf1d538/mypy-1.18.1.tar.gz", hash = "sha256:9e988c64ad3ac5987f43f5154f884747faf62141b7f842e87465b45299eea5a9", size = 3448447, upload-time = "2025-09-11T23:00:47.067Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/28/47709d5d9e7068b26c0d5189c8137c8783e81065ad1102b505214a08b548/mypy-1.18.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6c903857b3e28fc5489e54042684a9509039ea0aedb2a619469438b544ae1961", size = 12734635, upload-time = "2025-09-11T23:00:24.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/12/ee5c243e52497d0e59316854041cf3b3130131b92266d0764aca4dec3c00/mypy-1.18.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2a0c8392c19934c2b6c65566d3a6abdc6b51d5da7f5d04e43f0eb627d6eeee65", size = 11817287, upload-time = "2025-09-11T22:59:07.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/bd/2aeb950151005fe708ab59725afed7c4aeeb96daf844f86a05d4b8ac34f8/mypy-1.18.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f85eb7efa2ec73ef63fc23b8af89c2fe5bf2a4ad985ed2d3ff28c1bb3c317c92", size = 12430464, upload-time = "2025-09-11T22:58:48.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e8/7a20407aafb488acb5734ad7fb5e8c2ef78d292ca2674335350fa8ebef67/mypy-1.18.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82ace21edf7ba8af31c3308a61dc72df30500f4dbb26f99ac36b4b80809d7e94", size = 13164555, upload-time = "2025-09-11T23:00:13.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/c9/5f39065252e033b60f397096f538fb57c1d9fd70a7a490f314df20dd9d64/mypy-1.18.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a2dfd53dfe632f1ef5d161150a4b1f2d0786746ae02950eb3ac108964ee2975a", size = 13359222, upload-time = "2025-09-11T23:00:33.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/b6/d54111ef3c1e55992cd2ec9b8b6ce9c72a407423e93132cae209f7e7ba60/mypy-1.18.1-cp311-cp311-win_amd64.whl", hash = "sha256:320f0ad4205eefcb0e1a72428dde0ad10be73da9f92e793c36228e8ebf7298c0", size = 9760441, upload-time = "2025-09-11T23:00:44.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/14/1c3f54d606cb88a55d1567153ef3a8bc7b74702f2ff5eb64d0994f9e49cb/mypy-1.18.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:502cde8896be8e638588b90fdcb4c5d5b8c1b004dfc63fd5604a973547367bb9", size = 12911082, upload-time = "2025-09-11T23:00:41.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/83/235606c8b6d50a8eba99773add907ce1d41c068edb523f81eb0d01603a83/mypy-1.18.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7509549b5e41be279afc1228242d0e397f1af2919a8f2877ad542b199dc4083e", size = 11919107, upload-time = "2025-09-11T22:58:40.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/25/4e2ce00f8d15b99d0c68a2536ad63e9eac033f723439ef80290ec32c1ff5/mypy-1.18.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5956ecaabb3a245e3f34100172abca1507be687377fe20e24d6a7557e07080e2", size = 12472551, upload-time = "2025-09-11T22:58:37.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/bb/92642a9350fc339dd9dcefcf6862d171b52294af107d521dce075f32f298/mypy-1.18.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8750ceb014a96c9890421c83f0db53b0f3b8633e2864c6f9bc0a8e93951ed18d", size = 13340554, upload-time = "2025-09-11T22:59:38.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/ee/38d01db91c198fb6350025d28f9719ecf3c8f2c55a0094bfbf3ef478cc9a/mypy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fb89ea08ff41adf59476b235293679a6eb53a7b9400f6256272fb6029bec3ce5", size = 13530933, upload-time = "2025-09-11T22:59:20.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/8d/6d991ae631f80d58edbf9d7066e3f2a96e479dca955d9a968cd6e90850a3/mypy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:2657654d82fcd2a87e02a33e0d23001789a554059bbf34702d623dafe353eabf", size = 9828426, upload-time = "2025-09-11T23:00:21.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ec/ef4a7260e1460a3071628a9277a7579e7da1b071bc134ebe909323f2fbc7/mypy-1.18.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d70d2b5baf9b9a20bc9c730015615ae3243ef47fb4a58ad7b31c3e0a59b5ef1f", size = 12918671, upload-time = "2025-09-11T22:58:29.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/82/0ea6c3953f16223f0b8eda40c1aeac6bd266d15f4902556ae6e91f6fca4c/mypy-1.18.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8367e33506300f07a43012fc546402f283c3f8bcff1dc338636affb710154ce", size = 11913023, upload-time = "2025-09-11T23:00:29.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/ef/5e2057e692c2690fc27b3ed0a4dbde4388330c32e2576a23f0302bc8358d/mypy-1.18.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:913f668ec50c3337b89df22f973c1c8f0b29ee9e290a8b7fe01cc1ef7446d42e", size = 12473355, upload-time = "2025-09-11T23:00:04.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/43/b7e429fc4be10e390a167b0cd1810d41cb4e4add4ae50bab96faff695a3b/mypy-1.18.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a0e70b87eb27b33209fa4792b051c6947976f6ab829daa83819df5f58330c71", size = 13346944, upload-time = "2025-09-11T22:58:23.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/4e/899dba0bfe36bbd5b7c52e597de4cf47b5053d337b6d201a30e3798e77a6/mypy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c378d946e8a60be6b6ede48c878d145546fb42aad61df998c056ec151bf6c746", size = 13512574, upload-time = "2025-09-11T22:59:52.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/f8/7661021a5b0e501b76440454d786b0f01bb05d5c4b125fcbda02023d0250/mypy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:2cd2c1e0f3a7465f22731987fff6fc427e3dcbb4ca5f7db5bbeaff2ff9a31f6d", size = 9837684, upload-time = "2025-09-11T22:58:44.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/87/7b173981466219eccc64c107cf8e5ab9eb39cc304b4c07df8e7881533e4f/mypy-1.18.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ba24603c58e34dd5b096dfad792d87b304fc6470cbb1c22fd64e7ebd17edcc61", size = 12900265, upload-time = "2025-09-11T22:59:03.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/cc/b10e65bae75b18a5ac8f81b1e8e5867677e418f0dd2c83b8e2de9ba96ebd/mypy-1.18.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ed36662fb92ae4cb3cacc682ec6656208f323bbc23d4b08d091eecfc0863d4b5", size = 11942890, upload-time = "2025-09-11T23:00:00.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/d4/aeefa07c44d09f4c2102e525e2031bc066d12e5351f66b8a83719671004d/mypy-1.18.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:040ecc95e026f71a9ad7956fea2724466602b561e6a25c2e5584160d3833aaa8", size = 12472291, upload-time = "2025-09-11T22:59:43.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/07/711e78668ff8e365f8c19735594ea95938bff3639a4c46a905e3ed8ff2d6/mypy-1.18.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:937e3ed86cb731276706e46e03512547e43c391a13f363e08d0fee49a7c38a0d", size = 13318610, upload-time = "2025-09-11T23:00:17.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/85/df3b2d39339c31d360ce299b418c55e8194ef3205284739b64962f6074e7/mypy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1f95cc4f01c0f1701ca3b0355792bccec13ecb2ec1c469e5b85a6ef398398b1d", size = 13513697, upload-time = "2025-09-11T22:58:59.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/df/462866163c99ea73bb28f0eb4d415c087e30de5d36ee0f5429d42e28689b/mypy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:e4f16c0019d48941220ac60b893615be2f63afedaba6a0801bdcd041b96991ce", size = 9985739, upload-time = "2025-09-11T22:58:51.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/1d/4b97d3089b48ef3d904c9ca69fab044475bd03245d878f5f0b3ea1daf7ce/mypy-1.18.1-py3-none-any.whl", hash = "sha256:b76a4de66a0ac01da1be14ecc8ae88ddea33b8380284a9e3eae39d57ebcbe26e", size = 2352212, upload-time = "2025-09-11T22:59:26.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "0.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-mock"
|
||||
version = "3.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sseclient-py"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/ed/3df5ab8bb0c12f86c28d0cadb11ed1de44a92ed35ce7ff4fd5518a809325/sseclient-py-1.8.0.tar.gz", hash = "sha256:c547c5c1a7633230a38dc599a21a2dc638f9b5c297286b48b46b935c71fac3e8", size = 7791, upload-time = "2023-09-01T19:39:20.45Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/58/97655efdfeb5b4eeab85b1fc5d3fa1023661246c2ab2a26ea8e47402d4f2/sseclient_py-1.8.0-py2.py3-none-any.whl", hash = "sha256:4ecca6dc0b9f963f8384e9d7fd529bf93dd7d708144c4fb5da0e0a1a926fee83", size = 8828, upload-time = "2023-09-01T19:39:17.627Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user