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.
Files changed (70) hide show
  1. examples/export_tasks.py +16 -5
  2. examples/export_tasks_filtered.py +245 -0
  3. examples/fetch_tasks.py +230 -0
  4. examples/import_tasks.py +140 -8
  5. examples/iterate_verifiers.py +725 -0
  6. fleet/__init__.py +128 -5
  7. fleet/_async/__init__.py +27 -3
  8. fleet/_async/base.py +24 -9
  9. fleet/_async/client.py +938 -41
  10. fleet/_async/env/client.py +60 -3
  11. fleet/_async/instance/client.py +52 -7
  12. fleet/_async/models.py +15 -0
  13. fleet/_async/resources/api.py +200 -0
  14. fleet/_async/resources/sqlite.py +1801 -46
  15. fleet/_async/tasks.py +122 -25
  16. fleet/_async/verifiers/bundler.py +22 -21
  17. fleet/_async/verifiers/verifier.py +25 -19
  18. fleet/agent/__init__.py +32 -0
  19. fleet/agent/gemini_cua/Dockerfile +45 -0
  20. fleet/agent/gemini_cua/__init__.py +10 -0
  21. fleet/agent/gemini_cua/agent.py +759 -0
  22. fleet/agent/gemini_cua/mcp/main.py +108 -0
  23. fleet/agent/gemini_cua/mcp_server/__init__.py +5 -0
  24. fleet/agent/gemini_cua/mcp_server/main.py +105 -0
  25. fleet/agent/gemini_cua/mcp_server/tools.py +178 -0
  26. fleet/agent/gemini_cua/requirements.txt +5 -0
  27. fleet/agent/gemini_cua/start.sh +30 -0
  28. fleet/agent/orchestrator.py +854 -0
  29. fleet/agent/types.py +49 -0
  30. fleet/agent/utils.py +34 -0
  31. fleet/base.py +34 -9
  32. fleet/cli.py +1061 -0
  33. fleet/client.py +1060 -48
  34. fleet/config.py +1 -1
  35. fleet/env/__init__.py +16 -0
  36. fleet/env/client.py +60 -3
  37. fleet/eval/__init__.py +15 -0
  38. fleet/eval/uploader.py +231 -0
  39. fleet/exceptions.py +8 -0
  40. fleet/instance/client.py +53 -8
  41. fleet/instance/models.py +1 -0
  42. fleet/models.py +303 -0
  43. fleet/proxy/__init__.py +25 -0
  44. fleet/proxy/proxy.py +453 -0
  45. fleet/proxy/whitelist.py +244 -0
  46. fleet/resources/api.py +200 -0
  47. fleet/resources/sqlite.py +1845 -46
  48. fleet/tasks.py +113 -20
  49. fleet/utils/__init__.py +7 -0
  50. fleet/utils/http_logging.py +178 -0
  51. fleet/utils/logging.py +13 -0
  52. fleet/utils/playwright.py +440 -0
  53. fleet/verifiers/bundler.py +22 -21
  54. fleet/verifiers/db.py +985 -1
  55. fleet/verifiers/decorator.py +1 -1
  56. fleet/verifiers/verifier.py +25 -19
  57. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/METADATA +28 -1
  58. fleet_python-0.2.105.dist-info/RECORD +115 -0
  59. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/WHEEL +1 -1
  60. fleet_python-0.2.105.dist-info/entry_points.txt +2 -0
  61. tests/test_app_method.py +85 -0
  62. tests/test_expect_exactly.py +4148 -0
  63. tests/test_expect_only.py +2593 -0
  64. tests/test_instance_dispatch.py +607 -0
  65. tests/test_sqlite_resource_dual_mode.py +263 -0
  66. tests/test_sqlite_shared_memory_behavior.py +117 -0
  67. fleet_python-0.2.66b2.dist-info/RECORD +0 -81
  68. tests/test_verifier_security.py +0 -427
  69. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/licenses/LICENSE +0 -0
  70. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/top_level.txt +0 -0
fleet/config.py CHANGED
@@ -1,5 +1,5 @@
1
1
  DEFAULT_MAX_RETRIES = 3
2
- DEFAULT_TIMEOUT = 180.0
2
+ DEFAULT_TIMEOUT = 300.0
3
3
 
4
4
  GLOBAL_BASE_URL = "https://orchestrator.fleetai.com"
5
5
  REGION_BASE_URL = {
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
- return SQLiteResource(
87
- self._resources_state[ResourceType.db.value][name], self.client
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", timeout=1.0)
165
+ response = self.client.request("GET", "/resources")
132
166
  if response.status_code != 200:
133
- self._resources = []
134
- return
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
- """Async context manager entry."""
224
+ """Context manager entry."""
180
225
  return self
181
226
 
182
227
  def __exit__(self, exc_type, exc_val, exc_tb):
183
- """Async context manager exit."""
228
+ """Context manager exit."""
184
229
  self.close()
fleet/instance/models.py CHANGED
@@ -91,6 +91,7 @@ class ResourceMode(Enum):
91
91
  class ResourceType(Enum):
92
92
  db = "sqlite"
93
93
  cdp = "cdp"
94
+ api = "api"
94
95
 
95
96
 
96
97
  class TableSchema(BaseModel):