hopx-ai 0.1.15__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.
Potentially problematic release.
This version of hopx-ai might be problematic. Click here for more details.
- hopx_ai/__init__.py +114 -0
- hopx_ai/_agent_client.py +391 -0
- hopx_ai/_async_agent_client.py +223 -0
- hopx_ai/_async_cache.py +38 -0
- hopx_ai/_async_client.py +230 -0
- hopx_ai/_async_commands.py +58 -0
- hopx_ai/_async_env_vars.py +151 -0
- hopx_ai/_async_files.py +81 -0
- hopx_ai/_async_files_clean.py +489 -0
- hopx_ai/_async_terminal.py +184 -0
- hopx_ai/_client.py +230 -0
- hopx_ai/_generated/__init__.py +22 -0
- hopx_ai/_generated/models.py +502 -0
- hopx_ai/_temp_async_token.py +14 -0
- hopx_ai/_test_env_fix.py +30 -0
- hopx_ai/_utils.py +9 -0
- hopx_ai/_ws_client.py +141 -0
- hopx_ai/async_sandbox.py +763 -0
- hopx_ai/cache.py +97 -0
- hopx_ai/commands.py +174 -0
- hopx_ai/desktop.py +1227 -0
- hopx_ai/env_vars.py +244 -0
- hopx_ai/errors.py +249 -0
- hopx_ai/files.py +489 -0
- hopx_ai/models.py +274 -0
- hopx_ai/models_updated.py +270 -0
- hopx_ai/sandbox.py +1447 -0
- hopx_ai/template/__init__.py +47 -0
- hopx_ai/template/build_flow.py +540 -0
- hopx_ai/template/builder.py +300 -0
- hopx_ai/template/file_hasher.py +81 -0
- hopx_ai/template/ready_checks.py +106 -0
- hopx_ai/template/tar_creator.py +122 -0
- hopx_ai/template/types.py +199 -0
- hopx_ai/terminal.py +164 -0
- hopx_ai-0.1.15.dist-info/METADATA +462 -0
- hopx_ai-0.1.15.dist-info/RECORD +38 -0
- hopx_ai-0.1.15.dist-info/WHEEL +4 -0
hopx_ai/_client.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Internal HTTP client with retry logic."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional, Dict, Any
|
|
7
|
+
import httpx
|
|
8
|
+
from .errors import (
|
|
9
|
+
APIError,
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ResourceLimitError,
|
|
15
|
+
ServerError,
|
|
16
|
+
NetworkError,
|
|
17
|
+
TimeoutError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HTTPClient:
|
|
24
|
+
"""HTTP client with automatic retries and error handling."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
api_key: Optional[str] = None,
|
|
29
|
+
base_url: str = "https://api.hopx.dev",
|
|
30
|
+
timeout: int = 60,
|
|
31
|
+
max_retries: int = 3,
|
|
32
|
+
):
|
|
33
|
+
# API key priority: param > env var > error
|
|
34
|
+
self.api_key = api_key or os.environ.get("HOPX_API_KEY")
|
|
35
|
+
if not self.api_key:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"API key required. Pass api_key parameter or set HOPX_API_KEY environment variable.\n"
|
|
38
|
+
"Get your API key at: https://hopx.ai"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
self.base_url = base_url.rstrip("/")
|
|
42
|
+
self.timeout = timeout
|
|
43
|
+
self.max_retries = max_retries
|
|
44
|
+
|
|
45
|
+
# Force IPv4 to avoid IPv6 timeout issues (270s delay)
|
|
46
|
+
import socket
|
|
47
|
+
self._client = httpx.Client(
|
|
48
|
+
base_url=self.base_url,
|
|
49
|
+
timeout=timeout,
|
|
50
|
+
headers=self._default_headers(),
|
|
51
|
+
transport=httpx.HTTPTransport(
|
|
52
|
+
local_address="0.0.0.0", # Force IPv4
|
|
53
|
+
retries=0 # We handle retries ourselves
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def _default_headers(self) -> Dict[str, str]:
|
|
58
|
+
"""Get default headers for all requests."""
|
|
59
|
+
return {
|
|
60
|
+
"X-API-Key": self.api_key,
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
"User-Agent": "bunnyshell-python/0.1.0",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def _should_retry(self, status_code: int, attempt: int) -> bool:
|
|
66
|
+
"""Determine if request should be retried."""
|
|
67
|
+
if attempt >= self.max_retries:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
# Retry on server errors and rate limits
|
|
71
|
+
return status_code in (429, 500, 502, 503, 504)
|
|
72
|
+
|
|
73
|
+
def _get_retry_delay(self, attempt: int, retry_after: Optional[int] = None) -> float:
|
|
74
|
+
"""Calculate retry delay with exponential backoff."""
|
|
75
|
+
if retry_after:
|
|
76
|
+
return float(retry_after)
|
|
77
|
+
|
|
78
|
+
# Exponential backoff: 1s, 2s, 4s, 8s...
|
|
79
|
+
return min(2 ** attempt, 60)
|
|
80
|
+
|
|
81
|
+
def _handle_error(self, response: httpx.Response) -> None:
|
|
82
|
+
"""Convert HTTP errors to appropriate exceptions."""
|
|
83
|
+
try:
|
|
84
|
+
error_data = response.json().get("error", {})
|
|
85
|
+
message = error_data.get("message", response.text)
|
|
86
|
+
code = error_data.get("code")
|
|
87
|
+
request_id = error_data.get("request_id")
|
|
88
|
+
details = error_data.get("details", {})
|
|
89
|
+
except Exception:
|
|
90
|
+
message = response.text or f"HTTP {response.status_code}"
|
|
91
|
+
code = None
|
|
92
|
+
request_id = response.headers.get("X-Request-ID")
|
|
93
|
+
details = {}
|
|
94
|
+
|
|
95
|
+
kwargs = {
|
|
96
|
+
"code": code,
|
|
97
|
+
"request_id": request_id,
|
|
98
|
+
"details": details,
|
|
99
|
+
"status_code": response.status_code,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if response.status_code == 401:
|
|
103
|
+
raise AuthenticationError(message, **kwargs)
|
|
104
|
+
elif response.status_code == 404:
|
|
105
|
+
raise NotFoundError(message, **kwargs)
|
|
106
|
+
elif response.status_code == 400:
|
|
107
|
+
raise ValidationError(message, **kwargs)
|
|
108
|
+
elif response.status_code == 429:
|
|
109
|
+
retry_after = details.get("retry_after_seconds")
|
|
110
|
+
raise RateLimitError(message, retry_after=retry_after, **kwargs)
|
|
111
|
+
elif response.status_code == 403 and "limit" in message.lower():
|
|
112
|
+
raise ResourceLimitError(
|
|
113
|
+
message,
|
|
114
|
+
limit=details.get("limit"),
|
|
115
|
+
current=details.get("current"),
|
|
116
|
+
available=details.get("available"),
|
|
117
|
+
upgrade_url=details.get("upgrade_url"),
|
|
118
|
+
**kwargs
|
|
119
|
+
)
|
|
120
|
+
elif response.status_code >= 500:
|
|
121
|
+
raise ServerError(message, **kwargs)
|
|
122
|
+
else:
|
|
123
|
+
raise APIError(message, **kwargs)
|
|
124
|
+
|
|
125
|
+
def request(
|
|
126
|
+
self,
|
|
127
|
+
method: str,
|
|
128
|
+
path: str,
|
|
129
|
+
*,
|
|
130
|
+
params: Optional[Dict[str, Any]] = None,
|
|
131
|
+
json: Optional[Dict[str, Any]] = None,
|
|
132
|
+
) -> Dict[str, Any]:
|
|
133
|
+
"""
|
|
134
|
+
Make an HTTP request with automatic retries.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
method: HTTP method (GET, POST, DELETE, etc.)
|
|
138
|
+
path: API endpoint path (without base URL)
|
|
139
|
+
params: Query parameters
|
|
140
|
+
json: JSON request body
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Response JSON data
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
HopxError: On API errors
|
|
147
|
+
NetworkError: On network errors
|
|
148
|
+
TimeoutError: On timeout
|
|
149
|
+
"""
|
|
150
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
151
|
+
|
|
152
|
+
# Debug logging
|
|
153
|
+
logger.debug(f"{method} {url}")
|
|
154
|
+
if json:
|
|
155
|
+
logger.debug(f"Request body: {json}")
|
|
156
|
+
if params:
|
|
157
|
+
logger.debug(f"Query params: {params}")
|
|
158
|
+
|
|
159
|
+
for attempt in range(self.max_retries + 1):
|
|
160
|
+
try:
|
|
161
|
+
start_time = time.time()
|
|
162
|
+
|
|
163
|
+
response = self._client.request(
|
|
164
|
+
method=method,
|
|
165
|
+
url=url,
|
|
166
|
+
params=params,
|
|
167
|
+
json=json,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
elapsed = time.time() - start_time
|
|
171
|
+
logger.debug(f"Response: {response.status_code} ({elapsed:.3f}s)")
|
|
172
|
+
|
|
173
|
+
# Success
|
|
174
|
+
if response.status_code < 400:
|
|
175
|
+
result = response.json()
|
|
176
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
177
|
+
logger.debug(f"Response body: {result}")
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
# Should we retry?
|
|
181
|
+
if self._should_retry(response.status_code, attempt):
|
|
182
|
+
retry_after = None
|
|
183
|
+
if response.status_code == 429:
|
|
184
|
+
try:
|
|
185
|
+
retry_after = response.json().get("error", {}).get("details", {}).get("retry_after_seconds")
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
delay = self._get_retry_delay(attempt, retry_after)
|
|
190
|
+
logger.debug(f"Retrying in {delay}s (attempt {attempt + 1}/{self.max_retries})")
|
|
191
|
+
time.sleep(delay)
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# Error - no retry
|
|
195
|
+
self._handle_error(response)
|
|
196
|
+
|
|
197
|
+
except httpx.TimeoutException as e:
|
|
198
|
+
if attempt < self.max_retries:
|
|
199
|
+
delay = self._get_retry_delay(attempt)
|
|
200
|
+
logger.debug(f"Timeout, retrying in {delay}s")
|
|
201
|
+
time.sleep(delay)
|
|
202
|
+
continue
|
|
203
|
+
raise TimeoutError(f"Request timed out after {self.timeout}s") from e
|
|
204
|
+
|
|
205
|
+
except httpx.NetworkError as e:
|
|
206
|
+
if attempt < self.max_retries:
|
|
207
|
+
delay = self._get_retry_delay(attempt)
|
|
208
|
+
logger.debug(f"Network error, retrying in {delay}s")
|
|
209
|
+
time.sleep(delay)
|
|
210
|
+
continue
|
|
211
|
+
raise NetworkError(f"Network error: {e}") from e
|
|
212
|
+
|
|
213
|
+
raise ServerError("Max retries exceeded")
|
|
214
|
+
|
|
215
|
+
def get(self, path: str, **kwargs) -> Dict[str, Any]:
|
|
216
|
+
"""GET request."""
|
|
217
|
+
return self.request("GET", path, **kwargs)
|
|
218
|
+
|
|
219
|
+
def post(self, path: str, **kwargs) -> Dict[str, Any]:
|
|
220
|
+
"""POST request."""
|
|
221
|
+
return self.request("POST", path, **kwargs)
|
|
222
|
+
|
|
223
|
+
def delete(self, path: str, **kwargs) -> Dict[str, Any]:
|
|
224
|
+
"""DELETE request."""
|
|
225
|
+
return self.request("DELETE", path, **kwargs)
|
|
226
|
+
|
|
227
|
+
def close(self) -> None:
|
|
228
|
+
"""Close the HTTP client."""
|
|
229
|
+
self._client.close()
|
|
230
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-generated models from OpenAPI spec v3.1.2.
|
|
3
|
+
|
|
4
|
+
These models are automatically generated from the HOPX VM Agent API OpenAPI specification.
|
|
5
|
+
DO NOT EDIT MANUALLY - regenerate using scripts/generate_models.sh
|
|
6
|
+
|
|
7
|
+
All models are type-safe Pydantic v2 models with built-in validation.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from bunnyshell._generated import ExecuteRequest, ExecuteResponse
|
|
11
|
+
|
|
12
|
+
request = ExecuteRequest(code="print('hello')", language="python")
|
|
13
|
+
# Pydantic validates automatically!
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# Re-export all models from models.py
|
|
17
|
+
from .models import *
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
# Export everything from models
|
|
21
|
+
# This is populated automatically from models.py
|
|
22
|
+
]
|