fleet-python 0.2.66b2__py3-none-any.whl → 0.2.105__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.
- examples/export_tasks.py +16 -5
- examples/export_tasks_filtered.py +245 -0
- examples/fetch_tasks.py +230 -0
- examples/import_tasks.py +140 -8
- examples/iterate_verifiers.py +725 -0
- fleet/__init__.py +128 -5
- fleet/_async/__init__.py +27 -3
- fleet/_async/base.py +24 -9
- fleet/_async/client.py +938 -41
- fleet/_async/env/client.py +60 -3
- fleet/_async/instance/client.py +52 -7
- fleet/_async/models.py +15 -0
- fleet/_async/resources/api.py +200 -0
- fleet/_async/resources/sqlite.py +1801 -46
- fleet/_async/tasks.py +122 -25
- fleet/_async/verifiers/bundler.py +22 -21
- fleet/_async/verifiers/verifier.py +25 -19
- fleet/agent/__init__.py +32 -0
- fleet/agent/gemini_cua/Dockerfile +45 -0
- fleet/agent/gemini_cua/__init__.py +10 -0
- fleet/agent/gemini_cua/agent.py +759 -0
- fleet/agent/gemini_cua/mcp/main.py +108 -0
- fleet/agent/gemini_cua/mcp_server/__init__.py +5 -0
- fleet/agent/gemini_cua/mcp_server/main.py +105 -0
- fleet/agent/gemini_cua/mcp_server/tools.py +178 -0
- fleet/agent/gemini_cua/requirements.txt +5 -0
- fleet/agent/gemini_cua/start.sh +30 -0
- fleet/agent/orchestrator.py +854 -0
- fleet/agent/types.py +49 -0
- fleet/agent/utils.py +34 -0
- fleet/base.py +34 -9
- fleet/cli.py +1061 -0
- fleet/client.py +1060 -48
- fleet/config.py +1 -1
- fleet/env/__init__.py +16 -0
- fleet/env/client.py +60 -3
- fleet/eval/__init__.py +15 -0
- fleet/eval/uploader.py +231 -0
- fleet/exceptions.py +8 -0
- fleet/instance/client.py +53 -8
- fleet/instance/models.py +1 -0
- fleet/models.py +303 -0
- fleet/proxy/__init__.py +25 -0
- fleet/proxy/proxy.py +453 -0
- fleet/proxy/whitelist.py +244 -0
- fleet/resources/api.py +200 -0
- fleet/resources/sqlite.py +1845 -46
- fleet/tasks.py +113 -20
- fleet/utils/__init__.py +7 -0
- fleet/utils/http_logging.py +178 -0
- fleet/utils/logging.py +13 -0
- fleet/utils/playwright.py +440 -0
- fleet/verifiers/bundler.py +22 -21
- fleet/verifiers/db.py +985 -1
- fleet/verifiers/decorator.py +1 -1
- fleet/verifiers/verifier.py +25 -19
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/METADATA +28 -1
- fleet_python-0.2.105.dist-info/RECORD +115 -0
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/WHEEL +1 -1
- fleet_python-0.2.105.dist-info/entry_points.txt +2 -0
- tests/test_app_method.py +85 -0
- tests/test_expect_exactly.py +4148 -0
- tests/test_expect_only.py +2593 -0
- tests/test_instance_dispatch.py +607 -0
- tests/test_sqlite_resource_dual_mode.py +263 -0
- tests/test_sqlite_shared_memory_behavior.py +117 -0
- fleet_python-0.2.66b2.dist-info/RECORD +0 -81
- tests/test_verifier_security.py +0 -427
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/licenses/LICENSE +0 -0
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/top_level.txt +0 -0
fleet/config.py
CHANGED
fleet/env/__init__.py
CHANGED
|
@@ -7,6 +7,10 @@ from .client import (
|
|
|
7
7
|
list_regions,
|
|
8
8
|
get,
|
|
9
9
|
list_instances,
|
|
10
|
+
close,
|
|
11
|
+
close_all,
|
|
12
|
+
list_runs,
|
|
13
|
+
heartbeat,
|
|
10
14
|
account,
|
|
11
15
|
)
|
|
12
16
|
|
|
@@ -17,6 +21,10 @@ from .._async.env.client import (
|
|
|
17
21
|
list_regions_async,
|
|
18
22
|
get_async,
|
|
19
23
|
list_instances_async,
|
|
24
|
+
close_async,
|
|
25
|
+
close_all_async,
|
|
26
|
+
list_runs_async,
|
|
27
|
+
heartbeat_async,
|
|
20
28
|
account_async,
|
|
21
29
|
)
|
|
22
30
|
|
|
@@ -27,11 +35,19 @@ __all__ = [
|
|
|
27
35
|
"list_regions",
|
|
28
36
|
"list_instances",
|
|
29
37
|
"get",
|
|
38
|
+
"close",
|
|
39
|
+
"close_all",
|
|
40
|
+
"list_runs",
|
|
41
|
+
"heartbeat",
|
|
30
42
|
"make_async",
|
|
31
43
|
"list_envs_async",
|
|
32
44
|
"list_regions_async",
|
|
33
45
|
"list_instances_async",
|
|
34
46
|
"get_async",
|
|
47
|
+
"close_async",
|
|
48
|
+
"close_all_async",
|
|
49
|
+
"list_runs_async",
|
|
50
|
+
"heartbeat_async",
|
|
35
51
|
"account",
|
|
36
52
|
"account_async",
|
|
37
53
|
]
|
fleet/env/client.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from ..client import Fleet, SyncEnv, Task
|
|
2
|
-
from ..models import Environment as EnvironmentModel, AccountResponse
|
|
2
|
+
from ..models import Environment as EnvironmentModel, AccountResponse, InstanceResponse, Run, HeartbeatResponse
|
|
3
3
|
from typing import List, Optional, Dict, Any
|
|
4
4
|
|
|
5
5
|
|
|
@@ -10,6 +10,8 @@ def make(
|
|
|
10
10
|
env_variables: Optional[Dict[str, Any]] = None,
|
|
11
11
|
image_type: Optional[str] = None,
|
|
12
12
|
ttl_seconds: Optional[int] = None,
|
|
13
|
+
run_id: Optional[str] = None,
|
|
14
|
+
heartbeat_interval: Optional[int] = None,
|
|
13
15
|
) -> SyncEnv:
|
|
14
16
|
return Fleet().make(
|
|
15
17
|
env_key,
|
|
@@ -18,6 +20,8 @@ def make(
|
|
|
18
20
|
env_variables=env_variables,
|
|
19
21
|
image_type=image_type,
|
|
20
22
|
ttl_seconds=ttl_seconds,
|
|
23
|
+
run_id=run_id,
|
|
24
|
+
heartbeat_interval=heartbeat_interval,
|
|
21
25
|
)
|
|
22
26
|
|
|
23
27
|
|
|
@@ -34,14 +38,67 @@ def list_regions() -> List[str]:
|
|
|
34
38
|
|
|
35
39
|
|
|
36
40
|
def list_instances(
|
|
37
|
-
status: Optional[str] = None, region: Optional[str] = None
|
|
41
|
+
status: Optional[str] = None, region: Optional[str] = None, run_id: Optional[str] = None, profile_id: Optional[str] = None
|
|
38
42
|
) -> List[SyncEnv]:
|
|
39
|
-
return Fleet().instances(status=status, region=region)
|
|
43
|
+
return Fleet().instances(status=status, region=region, run_id=run_id, profile_id=profile_id)
|
|
40
44
|
|
|
41
45
|
|
|
42
46
|
def get(instance_id: str) -> SyncEnv:
|
|
43
47
|
return Fleet().instance(instance_id)
|
|
44
48
|
|
|
45
49
|
|
|
50
|
+
def close(instance_id: str) -> InstanceResponse:
|
|
51
|
+
"""Close (delete) a specific instance by ID.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
instance_id: The instance ID to close
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
InstanceResponse containing the deleted instance details
|
|
58
|
+
"""
|
|
59
|
+
return Fleet().close(instance_id)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def close_all(run_id: Optional[str] = None, profile_id: Optional[str] = None) -> List[InstanceResponse]:
|
|
63
|
+
"""Close (delete) instances using the batch delete endpoint.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
run_id: Optional run ID to filter instances by
|
|
67
|
+
profile_id: Optional profile ID to filter instances by (use "self" for your own profile)
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
List[InstanceResponse] containing the deleted instances
|
|
71
|
+
|
|
72
|
+
Note:
|
|
73
|
+
At least one of run_id or profile_id must be provided.
|
|
74
|
+
"""
|
|
75
|
+
return Fleet().close_all(run_id=run_id, profile_id=profile_id)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def list_runs(profile_id: Optional[str] = None, status: Optional[str] = "active") -> List[Run]:
|
|
79
|
+
"""List all runs (groups of instances by run_id) with aggregated statistics.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
profile_id: Optional profile ID to filter runs by (use "self" for your own profile)
|
|
83
|
+
status: Filter by run status - "active" (default), "inactive", or "all"
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
List[Run] containing run information with instance counts and timestamps
|
|
87
|
+
"""
|
|
88
|
+
return Fleet().list_runs(profile_id=profile_id, status=status)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def heartbeat(instance_id: str) -> HeartbeatResponse:
|
|
92
|
+
"""Send heartbeat to keep instance alive (if heartbeat monitoring is enabled).
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
instance_id: The instance ID to send heartbeat for
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
HeartbeatResponse containing heartbeat status and deadline information
|
|
99
|
+
"""
|
|
100
|
+
return Fleet().heartbeat(instance_id)
|
|
101
|
+
|
|
102
|
+
|
|
46
103
|
def account() -> AccountResponse:
|
|
47
104
|
return Fleet().account()
|
fleet/eval/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Eval telemetry - uploads raw proxy traffic to backend.
|
|
2
|
+
|
|
3
|
+
Simple design:
|
|
4
|
+
- Proxy captures all HTTP traffic to JSONL file
|
|
5
|
+
- Uploader tails file, batches entries, ships raw to backend
|
|
6
|
+
- Backend does all parsing/structuring of transcripts
|
|
7
|
+
- Optional whitelist to filter URLs
|
|
8
|
+
|
|
9
|
+
No local parsing - just spool and ship.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .uploader import TrafficUploader
|
|
13
|
+
|
|
14
|
+
__all__ = ["TrafficUploader"]
|
|
15
|
+
|
fleet/eval/uploader.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Raw traffic uploader - spools proxy logs and uploads to backend.
|
|
2
|
+
|
|
3
|
+
No parsing, no structuring - just batch and ship raw entries.
|
|
4
|
+
Backend handles all parsing/extraction of transcripts.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
uploader = TrafficUploader(job_id="eval_123", log_file=proxy.log_path)
|
|
8
|
+
await uploader.start() # Starts tailing and uploading
|
|
9
|
+
# ... run tasks ...
|
|
10
|
+
await uploader.stop() # Flushes remaining
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import List, Optional, Set
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TrafficUploader:
|
|
25
|
+
"""Tails proxy log file and uploads raw entries in batches.
|
|
26
|
+
|
|
27
|
+
Design:
|
|
28
|
+
- Tails JSONL file (like tail -f)
|
|
29
|
+
- Batches by count (100) or time (500ms)
|
|
30
|
+
- Uploads raw JSON entries (no parsing)
|
|
31
|
+
- Optional URL whitelist for filtering
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
BATCH_SIZE = 100
|
|
35
|
+
FLUSH_INTERVAL_MS = 500
|
|
36
|
+
UPLOAD_TIMEOUT = 10.0
|
|
37
|
+
MAX_RETRIES = 3
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
job_id: str,
|
|
42
|
+
log_file: Path,
|
|
43
|
+
whitelist: Optional[Set[str]] = None,
|
|
44
|
+
):
|
|
45
|
+
self.job_id = job_id
|
|
46
|
+
self.log_file = log_file
|
|
47
|
+
self.whitelist = whitelist # URL patterns to include (None = all)
|
|
48
|
+
|
|
49
|
+
self._running = False
|
|
50
|
+
self._task: Optional[asyncio.Task] = None
|
|
51
|
+
self._file = None
|
|
52
|
+
self._position = 0
|
|
53
|
+
|
|
54
|
+
# Stats
|
|
55
|
+
self._read = 0
|
|
56
|
+
self._uploaded = 0
|
|
57
|
+
self._filtered = 0
|
|
58
|
+
|
|
59
|
+
# HTTP client
|
|
60
|
+
self._client = None
|
|
61
|
+
self._base_url = os.environ.get("FLEET_API_URL", "https://orchestrator.fleetai.com")
|
|
62
|
+
self._api_key = os.environ.get("FLEET_API_KEY", "")
|
|
63
|
+
|
|
64
|
+
async def start(self):
|
|
65
|
+
"""Start tailing and uploading."""
|
|
66
|
+
if self._running:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
self._running = True
|
|
70
|
+
self._position = 0
|
|
71
|
+
self._read = 0
|
|
72
|
+
self._uploaded = 0
|
|
73
|
+
self._filtered = 0
|
|
74
|
+
|
|
75
|
+
# Start tail loop
|
|
76
|
+
self._task = asyncio.create_task(self._tail_loop())
|
|
77
|
+
logger.info(f"Uploader started: job={self.job_id}, file={self.log_file}")
|
|
78
|
+
|
|
79
|
+
async def stop(self):
|
|
80
|
+
"""Stop and flush remaining entries."""
|
|
81
|
+
if not self._running:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
self._running = False
|
|
85
|
+
|
|
86
|
+
if self._task:
|
|
87
|
+
self._task.cancel()
|
|
88
|
+
try:
|
|
89
|
+
await self._task
|
|
90
|
+
except asyncio.CancelledError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
# Final read and upload
|
|
94
|
+
entries = self._read_new_entries()
|
|
95
|
+
if entries:
|
|
96
|
+
await self._upload_batch(entries)
|
|
97
|
+
|
|
98
|
+
# Close client
|
|
99
|
+
if self._client:
|
|
100
|
+
await self._client.aclose()
|
|
101
|
+
self._client = None
|
|
102
|
+
|
|
103
|
+
logger.info(f"Uploader stopped: read={self._read}, uploaded={self._uploaded}, filtered={self._filtered}")
|
|
104
|
+
|
|
105
|
+
async def _tail_loop(self):
|
|
106
|
+
"""Main loop - tail file and upload batches."""
|
|
107
|
+
batch: List[dict] = []
|
|
108
|
+
last_flush = time.time()
|
|
109
|
+
|
|
110
|
+
while self._running:
|
|
111
|
+
try:
|
|
112
|
+
# Read new entries from file
|
|
113
|
+
new_entries = self._read_new_entries()
|
|
114
|
+
|
|
115
|
+
for entry in new_entries:
|
|
116
|
+
# Apply whitelist filter
|
|
117
|
+
if self._should_include(entry):
|
|
118
|
+
batch.append(entry)
|
|
119
|
+
else:
|
|
120
|
+
self._filtered += 1
|
|
121
|
+
|
|
122
|
+
# Check if we should flush
|
|
123
|
+
now = time.time()
|
|
124
|
+
should_flush = (
|
|
125
|
+
len(batch) >= self.BATCH_SIZE or
|
|
126
|
+
(batch and (now - last_flush) * 1000 >= self.FLUSH_INTERVAL_MS)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if should_flush:
|
|
130
|
+
await self._upload_batch(batch)
|
|
131
|
+
batch = []
|
|
132
|
+
last_flush = now
|
|
133
|
+
|
|
134
|
+
# Small sleep to avoid busy loop
|
|
135
|
+
await asyncio.sleep(0.05) # 50ms
|
|
136
|
+
|
|
137
|
+
except asyncio.CancelledError:
|
|
138
|
+
# Upload remaining on cancel
|
|
139
|
+
if batch:
|
|
140
|
+
await self._upload_batch(batch)
|
|
141
|
+
raise
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.error(f"Tail loop error: {e}")
|
|
144
|
+
await asyncio.sleep(1)
|
|
145
|
+
|
|
146
|
+
def _read_new_entries(self) -> List[dict]:
|
|
147
|
+
"""Read new lines from log file."""
|
|
148
|
+
entries = []
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
if not self.log_file.exists():
|
|
152
|
+
return entries
|
|
153
|
+
|
|
154
|
+
with open(self.log_file, "r") as f:
|
|
155
|
+
f.seek(self._position)
|
|
156
|
+
for line in f:
|
|
157
|
+
line = line.strip()
|
|
158
|
+
if line:
|
|
159
|
+
try:
|
|
160
|
+
entry = json.loads(line)
|
|
161
|
+
entries.append(entry)
|
|
162
|
+
self._read += 1
|
|
163
|
+
except json.JSONDecodeError:
|
|
164
|
+
pass
|
|
165
|
+
self._position = f.tell()
|
|
166
|
+
except Exception as e:
|
|
167
|
+
logger.error(f"Read error: {e}")
|
|
168
|
+
|
|
169
|
+
return entries
|
|
170
|
+
|
|
171
|
+
def _should_include(self, entry: dict) -> bool:
|
|
172
|
+
"""Check if entry passes whitelist filter."""
|
|
173
|
+
if self.whitelist is None:
|
|
174
|
+
return True # No filter, include all
|
|
175
|
+
|
|
176
|
+
# Check URL against whitelist patterns
|
|
177
|
+
url = entry.get("request", {}).get("url", "")
|
|
178
|
+
for pattern in self.whitelist:
|
|
179
|
+
if pattern in url:
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
async def _upload_batch(self, batch: List[dict]):
|
|
185
|
+
"""Upload batch of raw entries to backend."""
|
|
186
|
+
if not batch:
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
if not self._api_key:
|
|
190
|
+
# No API key, just count as uploaded (data is in local file)
|
|
191
|
+
self._uploaded += len(batch)
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
for attempt in range(self.MAX_RETRIES):
|
|
195
|
+
try:
|
|
196
|
+
await self._do_upload(batch)
|
|
197
|
+
self._uploaded += len(batch)
|
|
198
|
+
return
|
|
199
|
+
except Exception as e:
|
|
200
|
+
if attempt == self.MAX_RETRIES - 1:
|
|
201
|
+
logger.warning(f"Upload failed after {self.MAX_RETRIES} attempts: {e}")
|
|
202
|
+
else:
|
|
203
|
+
await asyncio.sleep(0.2 * (attempt + 1))
|
|
204
|
+
|
|
205
|
+
async def _do_upload(self, batch: List[dict]):
|
|
206
|
+
"""POST raw entries to backend."""
|
|
207
|
+
import httpx
|
|
208
|
+
|
|
209
|
+
if not self._client:
|
|
210
|
+
self._client = httpx.AsyncClient(
|
|
211
|
+
base_url=self._base_url,
|
|
212
|
+
headers={"Authorization": f"Bearer {self._api_key}"},
|
|
213
|
+
timeout=self.UPLOAD_TIMEOUT,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
payload = {
|
|
217
|
+
"job_id": self.job_id,
|
|
218
|
+
"entries": batch, # Raw entries, no parsing
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
resp = await self._client.post(f"/v1/eval_jobs/{self.job_id}/logs", json=payload)
|
|
222
|
+
resp.raise_for_status()
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def stats(self) -> dict:
|
|
226
|
+
return {
|
|
227
|
+
"read": self._read,
|
|
228
|
+
"uploaded": self._uploaded,
|
|
229
|
+
"filtered": self._filtered,
|
|
230
|
+
}
|
|
231
|
+
|
fleet/exceptions.py
CHANGED
|
@@ -137,6 +137,14 @@ class FleetTeamNotFoundError(FleetPermissionError):
|
|
|
137
137
|
self.team_id = team_id
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
class FleetConflictError(FleetAPIError):
|
|
141
|
+
"""Exception raised when there's a conflict (e.g., resource already exists)."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, message: str, resource_name: Optional[str] = None):
|
|
144
|
+
super().__init__(message, status_code=409)
|
|
145
|
+
self.resource_name = resource_name
|
|
146
|
+
|
|
147
|
+
|
|
140
148
|
class FleetEnvironmentError(FleetError):
|
|
141
149
|
"""Exception raised when environment operations fail."""
|
|
142
150
|
|
fleet/instance/client.py
CHANGED
|
@@ -9,6 +9,7 @@ from urllib.parse import urlparse
|
|
|
9
9
|
|
|
10
10
|
from ..resources.sqlite import SQLiteResource
|
|
11
11
|
from ..resources.browser import BrowserResource
|
|
12
|
+
from ..resources.api import APIResource
|
|
12
13
|
from ..resources.base import Resource
|
|
13
14
|
|
|
14
15
|
from fleet.verifiers import DatabaseSnapshot
|
|
@@ -23,6 +24,7 @@ from .models import (
|
|
|
23
24
|
ResetResponse,
|
|
24
25
|
Resource as ResourceModel,
|
|
25
26
|
ResourceType,
|
|
27
|
+
ResourceMode,
|
|
26
28
|
HealthResponse,
|
|
27
29
|
ExecuteFunctionRequest,
|
|
28
30
|
ExecuteFunctionResponse,
|
|
@@ -35,6 +37,7 @@ logger = logging.getLogger(__name__)
|
|
|
35
37
|
RESOURCE_TYPES = {
|
|
36
38
|
ResourceType.db: SQLiteResource,
|
|
37
39
|
ResourceType.cdp: BrowserResource,
|
|
40
|
+
ResourceType.api: APIResource,
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
ValidatorType = Callable[
|
|
@@ -83,15 +86,46 @@ class InstanceClient:
|
|
|
83
86
|
Returns:
|
|
84
87
|
An SQLite database resource for the given database name
|
|
85
88
|
"""
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
)
|
|
89
|
+
resource_info = self._resources_state[ResourceType.db.value][name]
|
|
90
|
+
# Local mode - resource_info is a dict with creation parameters
|
|
91
|
+
if isinstance(resource_info, dict) and resource_info.get('type') == 'local':
|
|
92
|
+
# Create new instance each time (matching HTTP mode behavior)
|
|
93
|
+
return SQLiteResource(
|
|
94
|
+
resource_info['resource_model'],
|
|
95
|
+
client=None,
|
|
96
|
+
db_path=resource_info['db_path']
|
|
97
|
+
)
|
|
98
|
+
# HTTP mode - resource_info is a ResourceModel, create new wrapper
|
|
99
|
+
return SQLiteResource(resource_info, self.client)
|
|
89
100
|
|
|
90
101
|
def browser(self, name: str) -> BrowserResource:
|
|
91
102
|
return BrowserResource(
|
|
92
103
|
self._resources_state[ResourceType.cdp.value][name], self.client
|
|
93
104
|
)
|
|
94
105
|
|
|
106
|
+
def api(self, name: str, base_url: str) -> APIResource:
|
|
107
|
+
"""
|
|
108
|
+
Returns an API resource for making HTTP requests.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
name: The name of the API resource
|
|
112
|
+
base_url: The base URL for API requests
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
An APIResource for making HTTP requests
|
|
116
|
+
"""
|
|
117
|
+
# Create a minimal resource model for API
|
|
118
|
+
resource_model = ResourceModel(
|
|
119
|
+
name=name,
|
|
120
|
+
type=ResourceType.api,
|
|
121
|
+
mode=ResourceMode.rw,
|
|
122
|
+
)
|
|
123
|
+
return APIResource(
|
|
124
|
+
resource_model,
|
|
125
|
+
base_url=base_url,
|
|
126
|
+
client=self.client.httpx_client if self.client else None,
|
|
127
|
+
)
|
|
128
|
+
|
|
95
129
|
def resources(self) -> List[Resource]:
|
|
96
130
|
self._load_resources()
|
|
97
131
|
return [
|
|
@@ -128,10 +162,14 @@ class InstanceClient:
|
|
|
128
162
|
|
|
129
163
|
def _load_resources(self) -> None:
|
|
130
164
|
if self._resources is None:
|
|
131
|
-
response = self.client.request("GET", "/resources"
|
|
165
|
+
response = self.client.request("GET", "/resources")
|
|
132
166
|
if response.status_code != 200:
|
|
133
|
-
|
|
134
|
-
|
|
167
|
+
response_body = response.text[:500] if response.text else "empty"
|
|
168
|
+
self._resources = [] # Mark as loaded (empty) to prevent retries
|
|
169
|
+
raise FleetEnvironmentError(
|
|
170
|
+
f"Failed to load instance resources: status_code={response.status_code} "
|
|
171
|
+
f"(url={self.base_url}, response={response_body})"
|
|
172
|
+
)
|
|
135
173
|
|
|
136
174
|
# Handle both old and new response formats
|
|
137
175
|
response_data = response.json()
|
|
@@ -175,10 +213,17 @@ class InstanceClient:
|
|
|
175
213
|
response = self.client.request("GET", "/health")
|
|
176
214
|
return HealthResponse(**response.json())
|
|
177
215
|
|
|
216
|
+
def close(self):
|
|
217
|
+
"""Close anchor connections for in-memory databases."""
|
|
218
|
+
if hasattr(self, '_memory_anchors'):
|
|
219
|
+
for conn in self._memory_anchors.values():
|
|
220
|
+
conn.close()
|
|
221
|
+
self._memory_anchors.clear()
|
|
222
|
+
|
|
178
223
|
def __enter__(self):
|
|
179
|
-
"""
|
|
224
|
+
"""Context manager entry."""
|
|
180
225
|
return self
|
|
181
226
|
|
|
182
227
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
183
|
-
"""
|
|
228
|
+
"""Context manager exit."""
|
|
184
229
|
self.close()
|