simplex 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simplex/__init__.py +50 -0
- simplex/_http_client.py +220 -0
- simplex/client.py +289 -0
- simplex/errors.py +119 -0
- simplex/types.py +64 -0
- simplex-2.0.0.dist-info/METADATA +224 -0
- simplex-2.0.0.dist-info/RECORD +10 -0
- simplex-2.0.0.dist-info/WHEEL +5 -0
- simplex-2.0.0.dist-info/licenses/LICENSE +21 -0
- simplex-2.0.0.dist-info/top_level.txt +1 -0
simplex/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simplex Python SDK
|
|
3
|
+
|
|
4
|
+
Official Python SDK for the Simplex API - A workflow automation platform.
|
|
5
|
+
|
|
6
|
+
Example usage:
|
|
7
|
+
>>> from simplex import SimplexClient
|
|
8
|
+
>>> client = SimplexClient(api_key="your-api-key")
|
|
9
|
+
>>> result = client.run_workflow("workflow-id", variables={"key": "value"})
|
|
10
|
+
>>>
|
|
11
|
+
>>> # Poll for completion
|
|
12
|
+
>>> import time
|
|
13
|
+
>>> while True:
|
|
14
|
+
... status = client.get_session_status(result["session_id"])
|
|
15
|
+
... if not status["in_progress"]:
|
|
16
|
+
... break
|
|
17
|
+
... time.sleep(1)
|
|
18
|
+
>>>
|
|
19
|
+
>>> if status["success"]:
|
|
20
|
+
... print("Outputs:", status["scraper_outputs"])
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from simplex.client import SimplexClient
|
|
24
|
+
from simplex.errors import (
|
|
25
|
+
AuthenticationError,
|
|
26
|
+
NetworkError,
|
|
27
|
+
RateLimitError,
|
|
28
|
+
SimplexError,
|
|
29
|
+
ValidationError,
|
|
30
|
+
WorkflowError,
|
|
31
|
+
)
|
|
32
|
+
from simplex.types import (
|
|
33
|
+
FileMetadata,
|
|
34
|
+
RunWorkflowResponse,
|
|
35
|
+
SessionStatusResponse,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__version__ = "2.0.0"
|
|
39
|
+
__all__ = [
|
|
40
|
+
"SimplexClient",
|
|
41
|
+
"SimplexError",
|
|
42
|
+
"NetworkError",
|
|
43
|
+
"ValidationError",
|
|
44
|
+
"AuthenticationError",
|
|
45
|
+
"RateLimitError",
|
|
46
|
+
"WorkflowError",
|
|
47
|
+
"FileMetadata",
|
|
48
|
+
"SessionStatusResponse",
|
|
49
|
+
"RunWorkflowResponse",
|
|
50
|
+
]
|
simplex/_http_client.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Internal HTTP client for the Simplex SDK.
|
|
3
|
+
|
|
4
|
+
This module provides a robust HTTP client with automatic retry logic,
|
|
5
|
+
error handling, and support for various request types.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlencode
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from simplex.errors import (
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
NetworkError,
|
|
19
|
+
RateLimitError,
|
|
20
|
+
SimplexError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__version__ = "2.0.0"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HttpClient:
|
|
28
|
+
"""
|
|
29
|
+
Internal HTTP client with retry logic and error handling.
|
|
30
|
+
|
|
31
|
+
This client handles all communication with the Simplex API, including:
|
|
32
|
+
- Automatic retry with exponential backoff for 429, 5xx errors
|
|
33
|
+
- Error mapping to custom exceptions
|
|
34
|
+
- Support for form-encoded and JSON requests
|
|
35
|
+
- File downloads
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
base_url: str,
|
|
41
|
+
api_key: str,
|
|
42
|
+
timeout: int = 30,
|
|
43
|
+
max_retries: int = 3,
|
|
44
|
+
retry_delay: float = 1.0,
|
|
45
|
+
):
|
|
46
|
+
"""
|
|
47
|
+
Initialize the HTTP client.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
base_url: Base URL for the API (e.g., 'https://api.simplex.sh')
|
|
51
|
+
api_key: Your Simplex API key
|
|
52
|
+
timeout: Request timeout in seconds (default: 30)
|
|
53
|
+
max_retries: Maximum number of retry attempts (default: 3)
|
|
54
|
+
retry_delay: Base delay between retries in seconds (default: 1.0)
|
|
55
|
+
"""
|
|
56
|
+
self.base_url = base_url.rstrip("/")
|
|
57
|
+
self.api_key = api_key
|
|
58
|
+
self.timeout = timeout
|
|
59
|
+
self.max_retries = max_retries
|
|
60
|
+
self.retry_delay = retry_delay
|
|
61
|
+
|
|
62
|
+
self.session = requests.Session()
|
|
63
|
+
self.session.headers.update(
|
|
64
|
+
{
|
|
65
|
+
"X-API-Key": api_key,
|
|
66
|
+
"User-Agent": f"Simplex-Python-SDK/{__version__}",
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def _should_retry(self, status_code: int | None) -> bool:
|
|
71
|
+
"""Determine if a request should be retried based on status code."""
|
|
72
|
+
if status_code is None:
|
|
73
|
+
return True # Network error
|
|
74
|
+
return status_code == 429 or status_code >= 500
|
|
75
|
+
|
|
76
|
+
def _handle_error(self, response: requests.Response) -> SimplexError:
|
|
77
|
+
"""Convert HTTP errors to appropriate exception types."""
|
|
78
|
+
status_code = response.status_code
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
data = response.json()
|
|
82
|
+
if isinstance(data, dict):
|
|
83
|
+
message = data.get("message") or data.get("error") or "An error occurred"
|
|
84
|
+
else:
|
|
85
|
+
message = str(data)
|
|
86
|
+
except ValueError:
|
|
87
|
+
message = response.text or "An error occurred"
|
|
88
|
+
|
|
89
|
+
if status_code == 400:
|
|
90
|
+
return ValidationError(message, data=response.json() if response.text else None)
|
|
91
|
+
elif status_code in [401, 403]:
|
|
92
|
+
return AuthenticationError(message)
|
|
93
|
+
elif status_code == 429:
|
|
94
|
+
retry_after = response.headers.get("Retry-After")
|
|
95
|
+
retry_after_seconds = int(retry_after) if retry_after and retry_after.isdigit() else None
|
|
96
|
+
return RateLimitError(message, retry_after=retry_after_seconds)
|
|
97
|
+
else:
|
|
98
|
+
return SimplexError(message, status_code=status_code, data=response.json() if response.text else None)
|
|
99
|
+
|
|
100
|
+
def _make_request(
|
|
101
|
+
self,
|
|
102
|
+
method: str,
|
|
103
|
+
path: str,
|
|
104
|
+
data: Any = None,
|
|
105
|
+
params: dict[str, Any] | None = None,
|
|
106
|
+
headers: dict[str, str] | None = None,
|
|
107
|
+
**kwargs: Any,
|
|
108
|
+
) -> requests.Response:
|
|
109
|
+
"""
|
|
110
|
+
Make an HTTP request with retry logic.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
method: HTTP method (GET, POST, etc.)
|
|
114
|
+
path: API endpoint path
|
|
115
|
+
data: Request body data
|
|
116
|
+
params: Query parameters
|
|
117
|
+
headers: Additional headers for this request
|
|
118
|
+
**kwargs: Additional arguments to pass to requests
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Response object
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
SimplexError: If the request fails after all retries
|
|
125
|
+
"""
|
|
126
|
+
url = f"{self.base_url}{path}"
|
|
127
|
+
attempt = 0
|
|
128
|
+
last_exception: SimplexError | None = None
|
|
129
|
+
|
|
130
|
+
while attempt <= self.max_retries:
|
|
131
|
+
try:
|
|
132
|
+
response = self.session.request(
|
|
133
|
+
method=method,
|
|
134
|
+
url=url,
|
|
135
|
+
data=data,
|
|
136
|
+
params=params,
|
|
137
|
+
headers=headers,
|
|
138
|
+
timeout=self.timeout,
|
|
139
|
+
**kwargs,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if not response.ok:
|
|
143
|
+
error = self._handle_error(response)
|
|
144
|
+
|
|
145
|
+
if self._should_retry(response.status_code) and attempt < self.max_retries:
|
|
146
|
+
attempt += 1
|
|
147
|
+
time.sleep(self.retry_delay * attempt)
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
raise error
|
|
151
|
+
|
|
152
|
+
return response
|
|
153
|
+
|
|
154
|
+
except requests.exceptions.RequestException as e:
|
|
155
|
+
last_exception = NetworkError(str(e))
|
|
156
|
+
|
|
157
|
+
if attempt < self.max_retries:
|
|
158
|
+
attempt += 1
|
|
159
|
+
time.sleep(self.retry_delay * attempt)
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
raise last_exception
|
|
163
|
+
|
|
164
|
+
if last_exception:
|
|
165
|
+
raise last_exception
|
|
166
|
+
raise NetworkError("Request failed after all retries")
|
|
167
|
+
|
|
168
|
+
def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
169
|
+
"""Make a GET request and return JSON response."""
|
|
170
|
+
response = self._make_request("GET", path, params=params)
|
|
171
|
+
return response.json()
|
|
172
|
+
|
|
173
|
+
def post(
|
|
174
|
+
self,
|
|
175
|
+
path: str,
|
|
176
|
+
data: dict[str, Any] | None = None,
|
|
177
|
+
) -> Any:
|
|
178
|
+
"""
|
|
179
|
+
Make a POST request with form-encoded data.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
path: API endpoint path
|
|
183
|
+
data: Form data to send
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Parsed JSON response
|
|
187
|
+
"""
|
|
188
|
+
import json as json_module
|
|
189
|
+
|
|
190
|
+
form_data = {}
|
|
191
|
+
if data:
|
|
192
|
+
for key, value in data.items():
|
|
193
|
+
if value is not None:
|
|
194
|
+
if isinstance(value, (dict, list)):
|
|
195
|
+
form_data[key] = json_module.dumps(value)
|
|
196
|
+
else:
|
|
197
|
+
form_data[key] = str(value)
|
|
198
|
+
|
|
199
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
200
|
+
response = self._make_request(
|
|
201
|
+
"POST",
|
|
202
|
+
path,
|
|
203
|
+
data=urlencode(form_data) if form_data else None,
|
|
204
|
+
headers=headers,
|
|
205
|
+
)
|
|
206
|
+
return response.json()
|
|
207
|
+
|
|
208
|
+
def download_file(self, path: str, params: dict[str, Any] | None = None) -> bytes:
|
|
209
|
+
"""
|
|
210
|
+
Download a file from the API.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
path: API endpoint path
|
|
214
|
+
params: Query parameters
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
File content as bytes
|
|
218
|
+
"""
|
|
219
|
+
response = self._make_request("GET", path, params=params)
|
|
220
|
+
return response.content
|
simplex/client.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main SimplexClient class for the Simplex SDK.
|
|
3
|
+
|
|
4
|
+
This module provides the SimplexClient, which is the primary entry point
|
|
5
|
+
for interacting with the Simplex API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from simplex._http_client import HttpClient
|
|
14
|
+
from simplex.errors import WorkflowError
|
|
15
|
+
from simplex.types import RunWorkflowResponse, SessionStatusResponse
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SimplexClient:
|
|
19
|
+
"""
|
|
20
|
+
Main client for interacting with the Simplex API.
|
|
21
|
+
|
|
22
|
+
This is the primary entry point for the SDK. It provides a flat API
|
|
23
|
+
for all Simplex API functionality.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
>>> from simplex import SimplexClient
|
|
27
|
+
>>> client = SimplexClient(api_key="your-api-key")
|
|
28
|
+
>>>
|
|
29
|
+
>>> # Run a workflow
|
|
30
|
+
>>> result = client.run_workflow("workflow-id", variables={"key": "value"})
|
|
31
|
+
>>>
|
|
32
|
+
>>> # Poll for completion
|
|
33
|
+
>>> import time
|
|
34
|
+
>>> while True:
|
|
35
|
+
... status = client.get_session_status(result["session_id"])
|
|
36
|
+
... if not status["in_progress"]:
|
|
37
|
+
... break
|
|
38
|
+
... time.sleep(1)
|
|
39
|
+
>>>
|
|
40
|
+
>>> if status["success"]:
|
|
41
|
+
... print("Scraper outputs:", status["scraper_outputs"])
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
api_key: str,
|
|
47
|
+
base_url: str = "https://api.simplex.sh",
|
|
48
|
+
timeout: int = 30,
|
|
49
|
+
max_retries: int = 3,
|
|
50
|
+
retry_delay: float = 1.0,
|
|
51
|
+
):
|
|
52
|
+
"""
|
|
53
|
+
Initialize the Simplex client.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
api_key: Your Simplex API key (required)
|
|
57
|
+
base_url: Base URL for the API (default: "https://api.simplex.sh")
|
|
58
|
+
timeout: Request timeout in seconds (default: 30)
|
|
59
|
+
max_retries: Maximum number of retry attempts (default: 3)
|
|
60
|
+
retry_delay: Delay between retries in seconds (default: 1.0)
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: If api_key is not provided
|
|
64
|
+
"""
|
|
65
|
+
if not api_key:
|
|
66
|
+
raise ValueError("api_key is required")
|
|
67
|
+
|
|
68
|
+
self._http_client = HttpClient(
|
|
69
|
+
base_url=base_url,
|
|
70
|
+
api_key=api_key,
|
|
71
|
+
timeout=timeout,
|
|
72
|
+
max_retries=max_retries,
|
|
73
|
+
retry_delay=retry_delay,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def run_workflow(
|
|
77
|
+
self,
|
|
78
|
+
workflow_id: str,
|
|
79
|
+
variables: dict[str, Any] | None = None,
|
|
80
|
+
metadata: str | None = None,
|
|
81
|
+
webhook_url: str | None = None,
|
|
82
|
+
) -> RunWorkflowResponse:
|
|
83
|
+
"""
|
|
84
|
+
Run a workflow by its ID.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
workflow_id: The ID of the workflow to run
|
|
88
|
+
variables: Dictionary of variables to pass to the workflow
|
|
89
|
+
metadata: Optional metadata string to attach to the workflow run
|
|
90
|
+
webhook_url: Optional webhook URL for status updates
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
RunWorkflowResponse with session_id and other details
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
WorkflowError: If the workflow fails to start
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
>>> result = client.run_workflow(
|
|
100
|
+
... "workflow-id",
|
|
101
|
+
... variables={"email": "user@example.com"}
|
|
102
|
+
... )
|
|
103
|
+
>>> print(f"Session ID: {result['session_id']}")
|
|
104
|
+
"""
|
|
105
|
+
request_data: dict[str, Any] = {"workflow_id": workflow_id}
|
|
106
|
+
|
|
107
|
+
if variables is not None:
|
|
108
|
+
request_data["variables"] = variables
|
|
109
|
+
if metadata is not None:
|
|
110
|
+
request_data["metadata"] = metadata
|
|
111
|
+
if webhook_url is not None:
|
|
112
|
+
request_data["webhook_url"] = webhook_url
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
response: RunWorkflowResponse = self._http_client.post(
|
|
116
|
+
"/run_workflow",
|
|
117
|
+
data=request_data,
|
|
118
|
+
)
|
|
119
|
+
return response
|
|
120
|
+
except Exception as e:
|
|
121
|
+
if isinstance(e, WorkflowError):
|
|
122
|
+
raise
|
|
123
|
+
raise WorkflowError(f"Failed to run workflow: {e}", workflow_id=workflow_id)
|
|
124
|
+
|
|
125
|
+
def get_session_status(self, session_id: str) -> SessionStatusResponse:
|
|
126
|
+
"""
|
|
127
|
+
Get the status of a session.
|
|
128
|
+
|
|
129
|
+
Use this method to poll for workflow completion. The session is complete
|
|
130
|
+
when `in_progress` is False.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
session_id: The session ID to check
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
SessionStatusResponse with status, metadata, and scraper outputs
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
WorkflowError: If retrieving status fails
|
|
140
|
+
|
|
141
|
+
Example:
|
|
142
|
+
>>> status = client.get_session_status("session-123")
|
|
143
|
+
>>> if not status["in_progress"]:
|
|
144
|
+
... if status["success"]:
|
|
145
|
+
... print("Success! Outputs:", status["scraper_outputs"])
|
|
146
|
+
... else:
|
|
147
|
+
... print("Failed")
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
response: SessionStatusResponse = self._http_client.get(
|
|
151
|
+
f"/get_session_status/{session_id}"
|
|
152
|
+
)
|
|
153
|
+
return response
|
|
154
|
+
except Exception as e:
|
|
155
|
+
if isinstance(e, WorkflowError):
|
|
156
|
+
raise
|
|
157
|
+
raise WorkflowError(
|
|
158
|
+
f"Failed to get session status: {e}",
|
|
159
|
+
session_id=session_id,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def download_session_files(
|
|
163
|
+
self,
|
|
164
|
+
session_id: str,
|
|
165
|
+
filename: str | None = None,
|
|
166
|
+
) -> bytes:
|
|
167
|
+
"""
|
|
168
|
+
Download files from a session.
|
|
169
|
+
|
|
170
|
+
Downloads files that were created or downloaded during a workflow session.
|
|
171
|
+
If no filename is specified, all files are downloaded as a zip archive.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
session_id: ID of the session to download files from
|
|
175
|
+
filename: Optional specific filename to download
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
File content as bytes
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
WorkflowError: If file download fails
|
|
182
|
+
|
|
183
|
+
Example:
|
|
184
|
+
>>> # Download all files as zip
|
|
185
|
+
>>> zip_data = client.download_session_files("session-123")
|
|
186
|
+
>>> with open("files.zip", "wb") as f:
|
|
187
|
+
... f.write(zip_data)
|
|
188
|
+
>>>
|
|
189
|
+
>>> # Download specific file
|
|
190
|
+
>>> pdf_data = client.download_session_files("session-123", "report.pdf")
|
|
191
|
+
"""
|
|
192
|
+
try:
|
|
193
|
+
params: dict[str, str] = {"session_id": session_id}
|
|
194
|
+
if filename:
|
|
195
|
+
params["filename"] = filename
|
|
196
|
+
|
|
197
|
+
content = self._http_client.download_file("/download_session_files", params=params)
|
|
198
|
+
|
|
199
|
+
# Check if the response is a JSON error
|
|
200
|
+
try:
|
|
201
|
+
text = content.decode("utf-8")
|
|
202
|
+
data = json.loads(text)
|
|
203
|
+
if isinstance(data, dict) and data.get("succeeded") is False:
|
|
204
|
+
raise WorkflowError(
|
|
205
|
+
data.get("error") or "Failed to download session files",
|
|
206
|
+
session_id=session_id,
|
|
207
|
+
)
|
|
208
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
209
|
+
# Binary data (the file), which is what we want
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
return content
|
|
213
|
+
except WorkflowError:
|
|
214
|
+
raise
|
|
215
|
+
except Exception as e:
|
|
216
|
+
raise WorkflowError(
|
|
217
|
+
f"Failed to download session files: {e}",
|
|
218
|
+
session_id=session_id,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def retrieve_session_replay(self, session_id: str) -> bytes:
|
|
222
|
+
"""
|
|
223
|
+
Retrieve the session replay video for a completed session.
|
|
224
|
+
|
|
225
|
+
Downloads a video (MP4) recording of the browser session after it
|
|
226
|
+
has completed.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
session_id: ID of the session to retrieve replay for
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Video content as bytes (MP4 format)
|
|
233
|
+
|
|
234
|
+
Raises:
|
|
235
|
+
WorkflowError: If retrieving session replay fails
|
|
236
|
+
|
|
237
|
+
Example:
|
|
238
|
+
>>> video_data = client.retrieve_session_replay("session-123")
|
|
239
|
+
>>> with open("replay.mp4", "wb") as f:
|
|
240
|
+
... f.write(video_data)
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
content = self._http_client.download_file(f"/retrieve_session_replay/{session_id}")
|
|
244
|
+
return content
|
|
245
|
+
except Exception as e:
|
|
246
|
+
if isinstance(e, WorkflowError):
|
|
247
|
+
raise
|
|
248
|
+
raise WorkflowError(
|
|
249
|
+
f"Failed to retrieve session replay: {e}",
|
|
250
|
+
session_id=session_id,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def retrieve_session_logs(self, session_id: str) -> Any:
|
|
254
|
+
"""
|
|
255
|
+
Retrieve the session logs for a completed session.
|
|
256
|
+
|
|
257
|
+
Downloads the detailed logs of all actions and events that occurred
|
|
258
|
+
during the workflow session execution.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
session_id: ID of the session to retrieve logs for
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Parsed JSON logs containing session events and details
|
|
265
|
+
|
|
266
|
+
Raises:
|
|
267
|
+
WorkflowError: If retrieving session logs fails
|
|
268
|
+
|
|
269
|
+
Example:
|
|
270
|
+
>>> logs = client.retrieve_session_logs("session-123")
|
|
271
|
+
>>> for entry in logs:
|
|
272
|
+
... print(f"{entry['timestamp']}: {entry['message']}")
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
content = self._http_client.download_file(f"/retrieve_session_logs/{session_id}")
|
|
276
|
+
text = content.decode("utf-8")
|
|
277
|
+
return json.loads(text)
|
|
278
|
+
except json.JSONDecodeError as e:
|
|
279
|
+
raise WorkflowError(
|
|
280
|
+
f"Failed to parse session logs: {e}",
|
|
281
|
+
session_id=session_id,
|
|
282
|
+
)
|
|
283
|
+
except Exception as e:
|
|
284
|
+
if isinstance(e, WorkflowError):
|
|
285
|
+
raise
|
|
286
|
+
raise WorkflowError(
|
|
287
|
+
f"Failed to retrieve session logs: {e}",
|
|
288
|
+
session_id=session_id,
|
|
289
|
+
)
|
simplex/errors.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exception classes for the Simplex SDK.
|
|
3
|
+
|
|
4
|
+
This module defines a hierarchy of exceptions that can be raised by the SDK,
|
|
5
|
+
allowing for specific error handling based on the type of error encountered.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SimplexError(Exception):
|
|
14
|
+
"""
|
|
15
|
+
Base exception class for all Simplex SDK errors.
|
|
16
|
+
|
|
17
|
+
All custom exceptions in the SDK inherit from this class, making it easy
|
|
18
|
+
to catch any SDK-related error with a single except clause.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
message: Human-readable error message
|
|
22
|
+
status_code: HTTP status code if applicable
|
|
23
|
+
data: Additional error data or context
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
status_code: int | None = None,
|
|
30
|
+
data: Any = None,
|
|
31
|
+
):
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.message = message
|
|
34
|
+
self.status_code = status_code
|
|
35
|
+
self.data = data
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
if self.status_code:
|
|
39
|
+
return f"[{self.status_code}] {self.message}"
|
|
40
|
+
return self.message
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NetworkError(SimplexError):
|
|
44
|
+
"""
|
|
45
|
+
Raised when a network-related error occurs.
|
|
46
|
+
|
|
47
|
+
This includes connection failures, timeouts, and other network issues
|
|
48
|
+
that prevent communication with the Simplex API.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str):
|
|
52
|
+
super().__init__(f"Network error: {message}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ValidationError(SimplexError):
|
|
56
|
+
"""
|
|
57
|
+
Raised when request validation fails (HTTP 400).
|
|
58
|
+
|
|
59
|
+
This indicates that the request data was invalid or malformed,
|
|
60
|
+
such as missing required fields or invalid parameter values.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, message: str, data: Any = None):
|
|
64
|
+
super().__init__(message, status_code=400, data=data)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AuthenticationError(SimplexError):
|
|
68
|
+
"""
|
|
69
|
+
Raised when authentication fails (HTTP 401 or 403).
|
|
70
|
+
|
|
71
|
+
This typically indicates an invalid API key or insufficient permissions
|
|
72
|
+
to access the requested resource.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, message: str):
|
|
76
|
+
super().__init__(message, status_code=401)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RateLimitError(SimplexError):
|
|
80
|
+
"""
|
|
81
|
+
Raised when rate limit is exceeded (HTTP 429).
|
|
82
|
+
|
|
83
|
+
The Simplex API has rate limits to prevent abuse. When exceeded,
|
|
84
|
+
this error is raised with information about when to retry.
|
|
85
|
+
|
|
86
|
+
Attributes:
|
|
87
|
+
retry_after: Number of seconds to wait before retrying
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, message: str, retry_after: int | None = None):
|
|
91
|
+
super().__init__(message, status_code=429)
|
|
92
|
+
self.retry_after = retry_after
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class WorkflowError(SimplexError):
|
|
96
|
+
"""
|
|
97
|
+
Raised when a workflow operation fails.
|
|
98
|
+
|
|
99
|
+
This is a specialized error for workflow-related failures,
|
|
100
|
+
including session creation, workflow execution, and agent tasks.
|
|
101
|
+
|
|
102
|
+
Attributes:
|
|
103
|
+
workflow_id: The ID of the workflow that failed (if applicable)
|
|
104
|
+
session_id: The ID of the session that failed (if applicable)
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
message: str,
|
|
110
|
+
workflow_id: str | None = None,
|
|
111
|
+
session_id: str | None = None,
|
|
112
|
+
):
|
|
113
|
+
super().__init__(
|
|
114
|
+
message,
|
|
115
|
+
status_code=500,
|
|
116
|
+
data={"workflow_id": workflow_id, "session_id": session_id},
|
|
117
|
+
)
|
|
118
|
+
self.workflow_id = workflow_id
|
|
119
|
+
self.session_id = session_id
|
simplex/types.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Type definitions for the Simplex SDK.
|
|
3
|
+
|
|
4
|
+
This module contains TypedDict classes used for type hinting throughout the SDK.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, TypedDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FileMetadata(TypedDict):
|
|
13
|
+
"""
|
|
14
|
+
Metadata for a file downloaded or created during a session.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
filename: The filename
|
|
18
|
+
download_url: The URL the file was downloaded from
|
|
19
|
+
file_size: File size in bytes
|
|
20
|
+
download_timestamp: ISO timestamp when the file was downloaded/created
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
filename: str
|
|
24
|
+
download_url: str
|
|
25
|
+
file_size: int
|
|
26
|
+
download_timestamp: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SessionStatusResponse(TypedDict):
|
|
30
|
+
"""
|
|
31
|
+
Response from polling session status.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
in_progress: Whether the session is still running
|
|
35
|
+
success: Whether the session completed successfully (None while in progress)
|
|
36
|
+
metadata: Custom metadata provided when the session was started
|
|
37
|
+
workflow_metadata: Metadata from the workflow definition
|
|
38
|
+
file_metadata: Metadata for files downloaded during the session
|
|
39
|
+
scraper_outputs: Scraper outputs collected during the session
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
in_progress: bool
|
|
43
|
+
success: bool | None
|
|
44
|
+
metadata: dict[str, Any]
|
|
45
|
+
workflow_metadata: dict[str, Any]
|
|
46
|
+
file_metadata: list[FileMetadata]
|
|
47
|
+
scraper_outputs: list[Any]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RunWorkflowResponse(TypedDict):
|
|
51
|
+
"""
|
|
52
|
+
Response from running a workflow.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
succeeded: Whether the workflow started successfully
|
|
56
|
+
message: Human-readable status message
|
|
57
|
+
session_id: Unique identifier for this workflow session
|
|
58
|
+
vnc_url: URL for VNC access to the workflow session
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
succeeded: bool
|
|
62
|
+
message: str
|
|
63
|
+
session_id: str
|
|
64
|
+
vnc_url: str
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simplex
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Official Python SDK for the Simplex API
|
|
5
|
+
Author-email: Simplex <support@simplex.sh>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://simplex.sh
|
|
8
|
+
Project-URL: Documentation, https://docs.simplex.sh
|
|
9
|
+
Project-URL: Repository, https://github.com/simplexlabs/simplex-python
|
|
10
|
+
Keywords: simplex,api,sdk,workflow,automation,browser,scraping
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Operating System :: OS Independent
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: requests>=2.25.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
29
|
+
Requires-Dist: types-requests>=2.25.0; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# Simplex Python SDK
|
|
33
|
+
|
|
34
|
+
Official Python SDK for the [Simplex API](https://simplex.sh) - A powerful workflow automation platform for browser-based tasks.
|
|
35
|
+
|
|
36
|
+
[](https://www.python.org/downloads/)
|
|
37
|
+
[](https://opensource.org/licenses/MIT)
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install simplex
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import time
|
|
49
|
+
from simplex import SimplexClient
|
|
50
|
+
|
|
51
|
+
# Initialize the client
|
|
52
|
+
client = SimplexClient(api_key="your-api-key")
|
|
53
|
+
|
|
54
|
+
# Run a workflow
|
|
55
|
+
response = client.run_workflow(
|
|
56
|
+
"workflow-id",
|
|
57
|
+
variables={"email": "user@example.com"}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
print(f"Session started: {response['session_id']}")
|
|
61
|
+
|
|
62
|
+
# Poll for completion
|
|
63
|
+
while True:
|
|
64
|
+
status = client.get_session_status(response["session_id"])
|
|
65
|
+
if not status["in_progress"]:
|
|
66
|
+
break
|
|
67
|
+
time.sleep(1)
|
|
68
|
+
|
|
69
|
+
# Check results
|
|
70
|
+
if status["success"]:
|
|
71
|
+
print("Success!")
|
|
72
|
+
print("Scraper outputs:", status["scraper_outputs"])
|
|
73
|
+
print("File metadata:", status["file_metadata"])
|
|
74
|
+
else:
|
|
75
|
+
print("Failed")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## API Reference
|
|
79
|
+
|
|
80
|
+
### SimplexClient
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
client = SimplexClient(
|
|
84
|
+
api_key="your-api-key",
|
|
85
|
+
base_url="https://api.simplex.sh", # Optional
|
|
86
|
+
timeout=30, # Request timeout in seconds
|
|
87
|
+
max_retries=3, # Retry attempts for failed requests
|
|
88
|
+
retry_delay=1.0, # Delay between retries in seconds
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Methods
|
|
93
|
+
|
|
94
|
+
#### `run_workflow(workflow_id, variables=None, metadata=None, webhook_url=None)`
|
|
95
|
+
|
|
96
|
+
Run a workflow by its ID.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
response = client.run_workflow(
|
|
100
|
+
"workflow-id",
|
|
101
|
+
variables={"key": "value"},
|
|
102
|
+
metadata="optional metadata",
|
|
103
|
+
webhook_url="https://your-webhook.com/callback"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
print(response["session_id"]) # Session ID for polling
|
|
107
|
+
print(response["vnc_url"]) # VNC URL to watch the session
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
#### `get_session_status(session_id)`
|
|
111
|
+
|
|
112
|
+
Get the status of a running or completed session.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
status = client.get_session_status("session-id")
|
|
116
|
+
|
|
117
|
+
print(status["in_progress"]) # True while running
|
|
118
|
+
print(status["success"]) # True/False when complete, None while running
|
|
119
|
+
print(status["scraper_outputs"]) # Data collected by scrapers
|
|
120
|
+
print(status["file_metadata"]) # Metadata for downloaded files
|
|
121
|
+
print(status["metadata"]) # Custom metadata
|
|
122
|
+
print(status["workflow_metadata"]) # Workflow metadata
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
#### `download_session_files(session_id, filename=None)`
|
|
126
|
+
|
|
127
|
+
Download files from a completed session.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
# Download all files as a zip
|
|
131
|
+
zip_data = client.download_session_files("session-id")
|
|
132
|
+
with open("files.zip", "wb") as f:
|
|
133
|
+
f.write(zip_data)
|
|
134
|
+
|
|
135
|
+
# Download a specific file
|
|
136
|
+
pdf_data = client.download_session_files("session-id", filename="report.pdf")
|
|
137
|
+
with open("report.pdf", "wb") as f:
|
|
138
|
+
f.write(pdf_data)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
#### `retrieve_session_replay(session_id)`
|
|
142
|
+
|
|
143
|
+
Download the session replay video (MP4).
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
video = client.retrieve_session_replay("session-id")
|
|
147
|
+
with open("replay.mp4", "wb") as f:
|
|
148
|
+
f.write(video)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
#### `retrieve_session_logs(session_id)`
|
|
152
|
+
|
|
153
|
+
Get the session logs as parsed JSON.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
logs = client.retrieve_session_logs("session-id")
|
|
157
|
+
for entry in logs:
|
|
158
|
+
print(f"{entry['timestamp']}: {entry['message']}")
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Error Handling
|
|
162
|
+
|
|
163
|
+
The SDK provides specific exception types for different error scenarios:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from simplex import (
|
|
167
|
+
SimplexClient,
|
|
168
|
+
SimplexError,
|
|
169
|
+
WorkflowError,
|
|
170
|
+
AuthenticationError,
|
|
171
|
+
RateLimitError,
|
|
172
|
+
NetworkError,
|
|
173
|
+
ValidationError,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
client = SimplexClient(api_key="your-api-key")
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
result = client.run_workflow("workflow-id")
|
|
180
|
+
except AuthenticationError as e:
|
|
181
|
+
print(f"Invalid API key: {e.message}")
|
|
182
|
+
except RateLimitError as e:
|
|
183
|
+
print(f"Rate limited. Retry after {e.retry_after} seconds")
|
|
184
|
+
except ValidationError as e:
|
|
185
|
+
print(f"Invalid request: {e.message}")
|
|
186
|
+
except WorkflowError as e:
|
|
187
|
+
print(f"Workflow error: {e.message}")
|
|
188
|
+
print(f"Session ID: {e.session_id}")
|
|
189
|
+
except NetworkError as e:
|
|
190
|
+
print(f"Network error: {e.message}")
|
|
191
|
+
except SimplexError as e:
|
|
192
|
+
print(f"General error: {e.message}")
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Type Hints
|
|
196
|
+
|
|
197
|
+
The SDK includes full type hints for better IDE support:
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from simplex import (
|
|
201
|
+
SimplexClient,
|
|
202
|
+
SessionStatusResponse,
|
|
203
|
+
RunWorkflowResponse,
|
|
204
|
+
FileMetadata,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
client = SimplexClient(api_key="your-api-key")
|
|
208
|
+
response: RunWorkflowResponse = client.run_workflow("workflow-id")
|
|
209
|
+
status: SessionStatusResponse = client.get_session_status(response["session_id"])
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Requirements
|
|
213
|
+
|
|
214
|
+
- Python 3.9+
|
|
215
|
+
- `requests>=2.25.0`
|
|
216
|
+
|
|
217
|
+
## License
|
|
218
|
+
|
|
219
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
220
|
+
|
|
221
|
+
## Support
|
|
222
|
+
|
|
223
|
+
- Documentation: [https://docs.simplex.sh](https://docs.simplex.sh)
|
|
224
|
+
- Email: support@simplex.sh
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
simplex/__init__.py,sha256=Okie1I8gw2-rwBZaNoqOXggKgcuy9I-wezRnH4yMFoQ,1188
|
|
2
|
+
simplex/_http_client.py,sha256=BbDsNYnfpydjNNXNw7gkyoRXGIG-QFMXfQgKN93WBxU,6819
|
|
3
|
+
simplex/client.py,sha256=xPw5eQhF7V9os0iXwT0jQbIWPjqloj-oXZD_6RXQmk0,9480
|
|
4
|
+
simplex/errors.py,sha256=xfRn6FCbQT_YgjZJrWq9MQPj8LMfjNAhNYMBW6XL8aw,3323
|
|
5
|
+
simplex/types.py,sha256=pc4n-HQ2hNm1LfK5psgh-UDKfvl1_pN9EXfkCyFTkvM,1765
|
|
6
|
+
simplex-2.0.0.dist-info/licenses/LICENSE,sha256=TyxVTRp5rBigFCL8EDC9Bv7AZfb4JBMVUZUeCs4Pk6Y,1063
|
|
7
|
+
simplex-2.0.0.dist-info/METADATA,sha256=VN8m-8rhvYg5ZIV7L_mjlscZm0-xrsuw1eg4t1daWBc,6024
|
|
8
|
+
simplex-2.0.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
|
|
9
|
+
simplex-2.0.0.dist-info/top_level.txt,sha256=cbMH1bYpN0A3gP-ecibPRHasHoqB-01T_2BUFS8p0CE,8
|
|
10
|
+
simplex-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Simplex
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
simplex
|