fleet-python 0.2.128__py3-none-any.whl → 0.2.129__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.
fleet/__init__.py CHANGED
@@ -26,6 +26,8 @@ from .exceptions import (
26
26
  )
27
27
  from .client import Fleet, SyncEnv, Session
28
28
  from ._async.client import AsyncFleet, AsyncEnv, AsyncSession
29
+ from .browser import BrowserLease, host_from_url
30
+ from ._async.browser import AsyncBrowserLease
29
31
  from .models import InstanceResponse, Environment, Run
30
32
  from .instance.models import Resource, ResetResponse
31
33
 
@@ -76,7 +78,7 @@ from . import env
76
78
  from . import global_client as _global_client
77
79
  from ._async import global_client as _async_global_client
78
80
 
79
- __version__ = "0.2.128"
81
+ __version__ = "0.2.129"
80
82
 
81
83
  __all__ = [
82
84
  # Core classes
@@ -84,6 +86,10 @@ __all__ = [
84
86
  "SyncEnv",
85
87
  "AsyncFleet",
86
88
  "AsyncEnv",
89
+ # Browser lease (orchestrator-managed /v1/browser)
90
+ "BrowserLease",
91
+ "AsyncBrowserLease",
92
+ "host_from_url",
87
93
  # Models
88
94
  "InstanceResponse",
89
95
  "SyncEnv",
fleet/_async/__init__.py CHANGED
@@ -44,7 +44,7 @@ from ..types import VerifierFunction
44
44
  from .. import env
45
45
  from . import global_client as _async_global_client
46
46
 
47
- __version__ = "0.2.128"
47
+ __version__ = "0.2.129"
48
48
 
49
49
  __all__ = [
50
50
  # Core classes
fleet/_async/base.py CHANGED
@@ -26,7 +26,7 @@ from .exceptions import (
26
26
  try:
27
27
  from .. import __version__
28
28
  except ImportError:
29
- __version__ = "0.2.128"
29
+ __version__ = "0.2.129"
30
30
 
31
31
  logger = logging.getLogger(__name__)
32
32
 
@@ -0,0 +1,191 @@
1
+ # Copyright 2025 Fleet AI
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ """Async mirror of ``fleet.browser``.
10
+
11
+ See :mod:`fleet.browser` for design notes. Kept as a separate file so the
12
+ async surface stays cleanly importable without dragging the sync wrapper
13
+ through any asyncio shims.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ from typing import Any, Dict, List, Optional, TYPE_CHECKING
20
+
21
+ from ..browser import host_from_url # re-export, identical logic
22
+
23
+ if TYPE_CHECKING:
24
+ from .base import AsyncWrapper
25
+
26
+
27
+ _BROWSER_PATH = "/v1/browser"
28
+
29
+
30
+ def _extra_headers(
31
+ jwt_token: Optional[str] = None, team_id: Optional[str] = None
32
+ ) -> Optional[Dict[str, str]]:
33
+ headers: Dict[str, str] = {}
34
+ if jwt_token:
35
+ headers["X-JWT-Token"] = jwt_token
36
+ if team_id:
37
+ headers["X-Team-ID"] = team_id
38
+ return headers or None
39
+
40
+
41
+ class AsyncBrowserLease:
42
+ """Async counterpart of :class:`fleet.browser.BrowserLease`."""
43
+
44
+ def __init__(self, client: "AsyncWrapper", data: Dict[str, Any]):
45
+ self._client = client
46
+ self._raw: Dict[str, Any] = dict(data)
47
+ self._jwt_token: Optional[str] = None
48
+ self._team_id: Optional[str] = None
49
+ self._apply(data)
50
+
51
+ def _apply(self, data: Dict[str, Any]) -> None:
52
+ self.id: str = data.get("id") or data["lease_id"]
53
+ self.lease_id: str = data["lease_id"]
54
+ self.browser_id: Optional[str] = data.get("browser_id")
55
+ self.host_domain: Optional[str] = data.get("host_domain")
56
+ self.mcp_url: Optional[str] = data.get("mcp_url")
57
+ self.cdp_url: Optional[str] = data.get("cdp_url")
58
+ self.stream_url: Optional[str] = data.get("stream_url")
59
+ self.status: Optional[str] = data.get("status")
60
+ self.stream_ready: Optional[bool] = data.get("stream_ready")
61
+ self.allowed_hosts: Optional[List[str]] = data.get("allowed_hosts")
62
+ self.created_timestamp_ms: Optional[int] = data.get("created_timestamp_ms")
63
+ self.expires_timestamp_ms: Optional[int] = data.get("expires_timestamp_ms")
64
+ self.age_duration_ms: Optional[int] = data.get("age_duration_ms")
65
+ self.cluster_name: Optional[str] = data.get("cluster_name")
66
+
67
+ @property
68
+ def raw(self) -> Dict[str, Any]:
69
+ return self._raw
70
+
71
+ async def refresh(self) -> "AsyncBrowserLease":
72
+ response = await self._client.request(
73
+ "GET",
74
+ f"{_BROWSER_PATH}/{self.lease_id}",
75
+ extra_headers=_extra_headers(self._jwt_token, self._team_id),
76
+ )
77
+ self._raw = response.json()
78
+ self._apply(self._raw)
79
+ return self
80
+
81
+ async def wait_until_running(
82
+ self,
83
+ timeout: float = 60.0,
84
+ poll_interval: float = 1.0,
85
+ require_stream_ready: bool = False,
86
+ ) -> "AsyncBrowserLease":
87
+ loop = asyncio.get_event_loop()
88
+ deadline = loop.time() + timeout
89
+ while True:
90
+ await self.refresh()
91
+ running = self.status == "running"
92
+ if running and (not require_stream_ready or self.stream_ready):
93
+ return self
94
+ if loop.time() >= deadline:
95
+ raise TimeoutError(
96
+ f"Browser lease {self.lease_id} not running after "
97
+ f"{timeout}s (status={self.status}, stream_ready={self.stream_ready})"
98
+ )
99
+ await asyncio.sleep(poll_interval)
100
+
101
+ async def mcp_tools(self) -> Dict[str, Any]:
102
+ response = await self._client.request(
103
+ "GET",
104
+ f"{_BROWSER_PATH}/{self.lease_id}/mcp-tools",
105
+ extra_headers=_extra_headers(self._jwt_token, self._team_id),
106
+ )
107
+ return response.json()
108
+
109
+ async def delete(self) -> None:
110
+ from ..exceptions import FleetAPIError
111
+
112
+ try:
113
+ await self._client.request(
114
+ "DELETE",
115
+ f"{_BROWSER_PATH}/{self.lease_id}",
116
+ extra_headers=_extra_headers(self._jwt_token, self._team_id),
117
+ )
118
+ except FleetAPIError as exc:
119
+ status = getattr(exc, "status_code", None)
120
+ if status not in (404, 410):
121
+ raise
122
+
123
+ async def __aenter__(self) -> "AsyncBrowserLease":
124
+ return self
125
+
126
+ async def __aexit__(self, exc_type, exc, tb) -> None:
127
+ await self.delete()
128
+
129
+ def __repr__(self) -> str:
130
+ return (
131
+ f"AsyncBrowserLease(lease_id={self.lease_id!r}, status={self.status!r}, "
132
+ f"cdp_url={self.cdp_url!r})"
133
+ )
134
+
135
+
136
+ async def create_browser(
137
+ client: "AsyncWrapper",
138
+ *,
139
+ ttl_seconds: int = 300,
140
+ lease_id: Optional[str] = None,
141
+ allowed_hosts: Optional[List[str]] = None,
142
+ request_timestamp_ms: Optional[int] = None,
143
+ extra: Optional[Dict[str, Any]] = None,
144
+ jwt_token: Optional[str] = None,
145
+ team_id: Optional[str] = None,
146
+ wait_until_running: bool = False,
147
+ wait_timeout: float = 60.0,
148
+ ) -> AsyncBrowserLease:
149
+ payload: Dict[str, Any] = {"ttl_seconds": ttl_seconds}
150
+ if lease_id is not None:
151
+ payload["lease_id"] = lease_id
152
+ if allowed_hosts is not None:
153
+ payload["allowed_hosts"] = allowed_hosts
154
+ if request_timestamp_ms is not None:
155
+ payload["request_timestamp_ms"] = request_timestamp_ms
156
+ if extra:
157
+ payload.update(extra)
158
+
159
+ response = await client.request(
160
+ "POST",
161
+ _BROWSER_PATH,
162
+ json=payload,
163
+ extra_headers=_extra_headers(jwt_token, team_id),
164
+ )
165
+ lease = AsyncBrowserLease(client, response.json())
166
+ lease._jwt_token = jwt_token
167
+ lease._team_id = team_id
168
+ if wait_until_running:
169
+ await lease.wait_until_running(timeout=wait_timeout)
170
+ return lease
171
+
172
+
173
+ async def get_browser(
174
+ client: "AsyncWrapper",
175
+ lease_id: str,
176
+ *,
177
+ jwt_token: Optional[str] = None,
178
+ team_id: Optional[str] = None,
179
+ ) -> AsyncBrowserLease:
180
+ response = await client.request(
181
+ "GET",
182
+ f"{_BROWSER_PATH}/{lease_id}",
183
+ extra_headers=_extra_headers(jwt_token, team_id),
184
+ )
185
+ lease = AsyncBrowserLease(client, response.json())
186
+ lease._jwt_token = jwt_token
187
+ lease._team_id = team_id
188
+ return lease
189
+
190
+
191
+ __all__ = ["AsyncBrowserLease", "create_browser", "get_browser", "host_from_url"]
fleet/_async/client.py CHANGED
@@ -171,10 +171,15 @@ from .instance.base import default_httpx_client
171
171
  from .instance.client import ValidatorType
172
172
  from .resources.base import Resource
173
173
  from .resources.sqlite import AsyncSQLiteResource
174
- from .resources.browser import AsyncBrowserResource
175
174
  from .resources.filesystem import AsyncFilesystemResource
176
175
  from .resources.mcp import AsyncMCPResource
177
176
  from .resources.api import AsyncAPIResource
177
+ from .browser import (
178
+ AsyncBrowserLease,
179
+ create_browser as _create_browser_lease,
180
+ get_browser as _get_browser_lease,
181
+ host_from_url,
182
+ )
178
183
 
179
184
  logger = logging.getLogger(__name__)
180
185
 
@@ -386,8 +391,51 @@ class AsyncEnv(EnvironmentBase):
386
391
  def db(self, name: str = "current") -> AsyncSQLiteResource:
387
392
  return self.instance.db(name)
388
393
 
389
- def browser(self, name: str = "cdp") -> AsyncBrowserResource:
390
- return self.instance.browser(name)
394
+ async def browser(
395
+ self,
396
+ ttl_seconds: int = 300,
397
+ *,
398
+ lease_id: Optional[str] = None,
399
+ allowed_hosts: Optional[List[str]] = None,
400
+ include_root_host: bool = True,
401
+ wait_until_running: bool = False,
402
+ wait_timeout: float = 60.0,
403
+ extra: Optional[Dict[str, Any]] = None,
404
+ jwt_token: Optional[str] = None,
405
+ team_id: Optional[str] = None,
406
+ ) -> AsyncBrowserLease:
407
+ """Spin up an orchestrator-managed Fleet Browser lease for this env.
408
+
409
+ ``await env.browser()`` posts to ``/v1/browser`` and returns an
410
+ :class:`fleet._async.browser.AsyncBrowserLease` with ``cdp_url`` /
411
+ ``mcp_url`` / ``stream_url`` and a ``mcp_tools()`` accessor. By
412
+ default the host derived from ``self.urls.root`` is prepended to
413
+ ``allowed_hosts`` so the browser can reach the instance — pass
414
+ ``include_root_host=False`` to opt out.
415
+ """
416
+ hosts: Optional[List[str]] = list(allowed_hosts) if allowed_hosts else None
417
+ if include_root_host and self.urls and self.urls.root:
418
+ root_host = host_from_url(self.urls.root)
419
+ if root_host:
420
+ hosts = hosts or []
421
+ if root_host not in hosts:
422
+ hosts.insert(0, root_host)
423
+ return await _create_browser_lease(
424
+ self._load_client,
425
+ ttl_seconds=ttl_seconds,
426
+ lease_id=lease_id,
427
+ allowed_hosts=hosts,
428
+ extra=extra,
429
+ jwt_token=jwt_token,
430
+ team_id=team_id,
431
+ wait_until_running=wait_until_running,
432
+ wait_timeout=wait_timeout,
433
+ )
434
+
435
+ @property
436
+ def root_url(self) -> Optional[str]:
437
+ """Convenience: ``self.urls.root`` if available."""
438
+ return self.urls.root if self.urls else None
391
439
 
392
440
  def fs(self) -> AsyncFilesystemResource:
393
441
  """Get a filesystem diff resource for inspecting file changes."""
@@ -605,7 +653,9 @@ class AsyncFleet:
605
653
  )
606
654
 
607
655
  instance = AsyncEnv(client=self.client, **response.json())
608
- await instance.instance.load()
656
+ # Resources are loaded lazily on first `db()`/`browser()`/`resources()` access
657
+ # via `_load_resources()`, so we don't preload here. Eagerly loading would
658
+ # fail-fast with a 502 while the container is still warming up.
609
659
  return instance
610
660
 
611
661
  async def make_for_task(self, task: Task) -> AsyncEnv:
@@ -657,7 +707,7 @@ class AsyncFleet:
657
707
  else:
658
708
  response = await self.client.request("GET", f"/v1/env/instances/{instance_id}")
659
709
  instance = AsyncEnv(client=self.client, **response.json())
660
- await instance.instance.load()
710
+ # Resources load lazily on first `db()`/`browser()`/`resources()` access.
661
711
  return instance
662
712
 
663
713
  def _create_url_instance(self, base_url: str) -> AsyncEnv:
@@ -798,6 +848,45 @@ class AsyncFleet:
798
848
  self.client, bundle_data, args, kwargs, timeout
799
849
  )
800
850
 
851
+ async def create_browser(
852
+ self,
853
+ ttl_seconds: int = 300,
854
+ *,
855
+ lease_id: Optional[str] = None,
856
+ allowed_hosts: Optional[List[str]] = None,
857
+ request_timestamp_ms: Optional[int] = None,
858
+ extra: Optional[Dict[str, Any]] = None,
859
+ jwt_token: Optional[str] = None,
860
+ team_id: Optional[str] = None,
861
+ wait_until_running: bool = False,
862
+ wait_timeout: float = 60.0,
863
+ ) -> AsyncBrowserLease:
864
+ """Create a Fleet Browser lease (``POST /v1/browser``)."""
865
+ return await _create_browser_lease(
866
+ self.client,
867
+ ttl_seconds=ttl_seconds,
868
+ lease_id=lease_id,
869
+ allowed_hosts=allowed_hosts,
870
+ request_timestamp_ms=request_timestamp_ms,
871
+ extra=extra,
872
+ jwt_token=jwt_token,
873
+ team_id=team_id,
874
+ wait_until_running=wait_until_running,
875
+ wait_timeout=wait_timeout,
876
+ )
877
+
878
+ async def get_browser(
879
+ self,
880
+ lease_id: str,
881
+ *,
882
+ jwt_token: Optional[str] = None,
883
+ team_id: Optional[str] = None,
884
+ ) -> AsyncBrowserLease:
885
+ """Inspect an existing browser lease (``GET /v1/browser/{lease_id}``)."""
886
+ return await _get_browser_lease(
887
+ self.client, lease_id, jwt_token=jwt_token, team_id=team_id
888
+ )
889
+
801
890
  async def delete(self, instance_id: str) -> InstanceResponse:
802
891
  return await _delete_instance(self.client, instance_id)
803
892
 
fleet/_async/models.py CHANGED
@@ -51,6 +51,7 @@ class Instance(BaseModel):
51
51
  team_id: str = Field(..., title="Team Id")
52
52
  region: str = Field(..., title="Region")
53
53
  env_variables: Optional[Dict[str, Any]] = Field(None, title="Env Variables")
54
+ multi_env_list: Optional[List[str]] = Field(None, title="Multi Env List")
54
55
 
55
56
 
56
57
  class InstanceRequest(BaseModel):
@@ -373,6 +374,7 @@ class InstanceResponse(BaseModel):
373
374
  data_version: Optional[str] = Field(None, title="Data Version")
374
375
  urls: Optional[InstanceURLs] = Field(None, title="Urls")
375
376
  health: Optional[bool] = Field(None, title="Health")
377
+ multi_env_list: Optional[List[str]] = Field(None, title="Multi Env List")
376
378
 
377
379
 
378
380
  class AccountResponse(BaseModel):
fleet/base.py CHANGED
@@ -27,7 +27,7 @@ from .exceptions import (
27
27
  try:
28
28
  from . import __version__
29
29
  except ImportError:
30
- __version__ = "0.2.128"
30
+ __version__ = "0.2.129"
31
31
 
32
32
  logger = logging.getLogger(__name__)
33
33
 
fleet/browser.py ADDED
@@ -0,0 +1,216 @@
1
+ # Copyright 2025 Fleet AI
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ """Fleet Browser API — orchestrator-managed remote browser leases.
10
+
11
+ Thin wrapper around the `/v1/browser` HTTP surface documented at
12
+ `https://orchestrator.fleetai.com/docs`. A ``BrowserLease`` exposes the
13
+ ``cdp_url`` / ``mcp_url`` / ``stream_url`` returned by the create call and
14
+ lets you poll, list MCP tools, and release the lease.
15
+
16
+ This is intentionally independent of ``fleet.resources.browser`` (the
17
+ in-instance CDP resource); they solve different problems.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import time
23
+ from typing import Any, Dict, List, Optional, TYPE_CHECKING
24
+ from urllib.parse import urlparse
25
+
26
+ if TYPE_CHECKING:
27
+ from .base import SyncWrapper
28
+
29
+
30
+ _BROWSER_PATH = "/v1/browser"
31
+
32
+
33
+ def _extra_headers(
34
+ jwt_token: Optional[str] = None, team_id: Optional[str] = None
35
+ ) -> Optional[Dict[str, str]]:
36
+ headers: Dict[str, str] = {}
37
+ if jwt_token:
38
+ headers["X-JWT-Token"] = jwt_token
39
+ if team_id:
40
+ headers["X-Team-ID"] = team_id
41
+ return headers or None
42
+
43
+
44
+ def host_from_url(url: str) -> str:
45
+ """Return the bare hostname for a URL (no scheme, no port, no path).
46
+
47
+ Useful when populating ``allowed_hosts`` from ``env.urls.root``.
48
+ """
49
+ parsed = urlparse(url if "://" in url else f"https://{url}")
50
+ return parsed.hostname or url
51
+
52
+
53
+ class BrowserLease:
54
+ """A lease created via ``POST /v1/browser``.
55
+
56
+ Attribute names mirror the JSON response. Methods that hit the API
57
+ reuse the same :class:`SyncWrapper` that created the lease so auth /
58
+ base URL / retries stay consistent.
59
+ """
60
+
61
+ def __init__(self, client: "SyncWrapper", data: Dict[str, Any]):
62
+ self._client = client
63
+ self._raw: Dict[str, Any] = dict(data)
64
+ self._jwt_token: Optional[str] = None
65
+ self._team_id: Optional[str] = None
66
+ self._apply(data)
67
+
68
+ def _apply(self, data: Dict[str, Any]) -> None:
69
+ self.id: str = data.get("id") or data["lease_id"]
70
+ self.lease_id: str = data["lease_id"]
71
+ self.browser_id: Optional[str] = data.get("browser_id")
72
+ self.host_domain: Optional[str] = data.get("host_domain")
73
+ self.mcp_url: Optional[str] = data.get("mcp_url")
74
+ self.cdp_url: Optional[str] = data.get("cdp_url")
75
+ self.stream_url: Optional[str] = data.get("stream_url")
76
+ self.status: Optional[str] = data.get("status")
77
+ self.stream_ready: Optional[bool] = data.get("stream_ready")
78
+ self.allowed_hosts: Optional[List[str]] = data.get("allowed_hosts")
79
+ self.created_timestamp_ms: Optional[int] = data.get("created_timestamp_ms")
80
+ self.expires_timestamp_ms: Optional[int] = data.get("expires_timestamp_ms")
81
+ self.age_duration_ms: Optional[int] = data.get("age_duration_ms")
82
+ self.cluster_name: Optional[str] = data.get("cluster_name")
83
+
84
+ @property
85
+ def raw(self) -> Dict[str, Any]:
86
+ """Last server response payload (for fields not surfaced as attrs)."""
87
+ return self._raw
88
+
89
+ def refresh(self) -> "BrowserLease":
90
+ """Re-fetch the lease (``GET /v1/browser/{lease_id}``)."""
91
+ response = self._client.request(
92
+ "GET",
93
+ f"{_BROWSER_PATH}/{self.lease_id}",
94
+ extra_headers=_extra_headers(self._jwt_token, self._team_id),
95
+ )
96
+ self._raw = response.json()
97
+ self._apply(self._raw)
98
+ return self
99
+
100
+ def wait_until_running(
101
+ self,
102
+ timeout: float = 60.0,
103
+ poll_interval: float = 1.0,
104
+ require_stream_ready: bool = False,
105
+ ) -> "BrowserLease":
106
+ """Poll until ``status == 'running'`` (or stream_ready, if requested)."""
107
+ deadline = time.monotonic() + timeout
108
+ while True:
109
+ self.refresh()
110
+ running = self.status == "running"
111
+ if running and (not require_stream_ready or self.stream_ready):
112
+ return self
113
+ if time.monotonic() >= deadline:
114
+ raise TimeoutError(
115
+ f"Browser lease {self.lease_id} not running after "
116
+ f"{timeout}s (status={self.status}, stream_ready={self.stream_ready})"
117
+ )
118
+ time.sleep(poll_interval)
119
+
120
+ def mcp_tools(self) -> Dict[str, Any]:
121
+ """List MCP tools the browser exposes."""
122
+ response = self._client.request(
123
+ "GET",
124
+ f"{_BROWSER_PATH}/{self.lease_id}/mcp-tools",
125
+ extra_headers=_extra_headers(self._jwt_token, self._team_id),
126
+ )
127
+ return response.json()
128
+
129
+ def delete(self) -> None:
130
+ """Release the lease. Idempotent — ignores 404 if already gone."""
131
+ from .exceptions import FleetAPIError
132
+
133
+ try:
134
+ self._client.request(
135
+ "DELETE",
136
+ f"{_BROWSER_PATH}/{self.lease_id}",
137
+ extra_headers=_extra_headers(self._jwt_token, self._team_id),
138
+ )
139
+ except FleetAPIError as exc:
140
+ status = getattr(exc, "status_code", None)
141
+ if status not in (404, 410):
142
+ raise
143
+
144
+ # Context manager so `with client.create_browser(...) as br: ...` cleans up.
145
+ def __enter__(self) -> "BrowserLease":
146
+ return self
147
+
148
+ def __exit__(self, exc_type, exc, tb) -> None:
149
+ self.delete()
150
+
151
+ def __repr__(self) -> str:
152
+ return (
153
+ f"BrowserLease(lease_id={self.lease_id!r}, status={self.status!r}, "
154
+ f"cdp_url={self.cdp_url!r})"
155
+ )
156
+
157
+
158
+ def create_browser(
159
+ client: "SyncWrapper",
160
+ *,
161
+ ttl_seconds: int = 300,
162
+ lease_id: Optional[str] = None,
163
+ allowed_hosts: Optional[List[str]] = None,
164
+ request_timestamp_ms: Optional[int] = None,
165
+ extra: Optional[Dict[str, Any]] = None,
166
+ jwt_token: Optional[str] = None,
167
+ team_id: Optional[str] = None,
168
+ wait_until_running: bool = False,
169
+ wait_timeout: float = 60.0,
170
+ ) -> BrowserLease:
171
+ """POST ``/v1/browser`` and return a :class:`BrowserLease`.
172
+
173
+ ``extra`` is merged into the request body so callers can pass
174
+ fields that aren't first-class kwargs yet — keeps this freeform.
175
+ """
176
+ payload: Dict[str, Any] = {"ttl_seconds": ttl_seconds}
177
+ if lease_id is not None:
178
+ payload["lease_id"] = lease_id
179
+ if allowed_hosts is not None:
180
+ payload["allowed_hosts"] = allowed_hosts
181
+ if request_timestamp_ms is not None:
182
+ payload["request_timestamp_ms"] = request_timestamp_ms
183
+ if extra:
184
+ payload.update(extra)
185
+
186
+ response = client.request(
187
+ "POST",
188
+ _BROWSER_PATH,
189
+ json=payload,
190
+ extra_headers=_extra_headers(jwt_token, team_id),
191
+ )
192
+ lease = BrowserLease(client, response.json())
193
+ lease._jwt_token = jwt_token
194
+ lease._team_id = team_id
195
+ if wait_until_running:
196
+ lease.wait_until_running(timeout=wait_timeout)
197
+ return lease
198
+
199
+
200
+ def get_browser(
201
+ client: "SyncWrapper",
202
+ lease_id: str,
203
+ *,
204
+ jwt_token: Optional[str] = None,
205
+ team_id: Optional[str] = None,
206
+ ) -> BrowserLease:
207
+ """Inspect an existing lease (``GET /v1/browser/{lease_id}``)."""
208
+ response = client.request(
209
+ "GET",
210
+ f"{_BROWSER_PATH}/{lease_id}",
211
+ extra_headers=_extra_headers(jwt_token, team_id),
212
+ )
213
+ lease = BrowserLease(client, response.json())
214
+ lease._jwt_token = jwt_token
215
+ lease._team_id = team_id
216
+ return lease
fleet/client.py CHANGED
@@ -178,10 +178,15 @@ from .instance.base import default_httpx_client
178
178
  from .instance.client import ValidatorType
179
179
  from .resources.base import Resource
180
180
  from .resources.sqlite import SQLiteResource
181
- from .resources.browser import BrowserResource
182
181
  from .resources.filesystem import FilesystemResource
183
182
  from .resources.mcp import SyncMCPResource
184
183
  from .resources.api import APIResource
184
+ from .browser import (
185
+ BrowserLease,
186
+ create_browser as _create_browser_lease,
187
+ get_browser as _get_browser_lease,
188
+ host_from_url,
189
+ )
185
190
 
186
191
  logger = logging.getLogger(__name__)
187
192
 
@@ -399,8 +404,51 @@ class SyncEnv(EnvironmentBase):
399
404
  def db(self, name: str = "current") -> SQLiteResource:
400
405
  return self.instance.db(name)
401
406
 
402
- def browser(self, name: str = "cdp") -> BrowserResource:
403
- return self.instance.browser(name)
407
+ def browser(
408
+ self,
409
+ ttl_seconds: int = 300,
410
+ *,
411
+ lease_id: Optional[str] = None,
412
+ allowed_hosts: Optional[List[str]] = None,
413
+ include_root_host: bool = True,
414
+ wait_until_running: bool = False,
415
+ wait_timeout: float = 60.0,
416
+ extra: Optional[Dict[str, Any]] = None,
417
+ jwt_token: Optional[str] = None,
418
+ team_id: Optional[str] = None,
419
+ ) -> BrowserLease:
420
+ """Spin up an orchestrator-managed Fleet Browser lease for this env.
421
+
422
+ ``env.browser()`` posts to ``/v1/browser`` and returns a
423
+ :class:`fleet.browser.BrowserLease` with ``cdp_url`` / ``mcp_url`` /
424
+ ``stream_url`` and a ``mcp_tools()`` accessor. By default the host
425
+ from ``self.urls.root`` is prepended to ``allowed_hosts`` so the
426
+ browser can reach the instance — pass ``include_root_host=False``
427
+ to opt out.
428
+ """
429
+ hosts: Optional[List[str]] = list(allowed_hosts) if allowed_hosts else None
430
+ if include_root_host and self.urls and self.urls.root:
431
+ root_host = host_from_url(self.urls.root)
432
+ if root_host:
433
+ hosts = hosts or []
434
+ if root_host not in hosts:
435
+ hosts.insert(0, root_host)
436
+ return _create_browser_lease(
437
+ self._load_client,
438
+ ttl_seconds=ttl_seconds,
439
+ lease_id=lease_id,
440
+ allowed_hosts=hosts,
441
+ extra=extra,
442
+ jwt_token=jwt_token,
443
+ team_id=team_id,
444
+ wait_until_running=wait_until_running,
445
+ wait_timeout=wait_timeout,
446
+ )
447
+
448
+ @property
449
+ def root_url(self) -> Optional[str]:
450
+ """Convenience: ``self.urls.root`` if available (handy paired with spawn_browser)."""
451
+ return self.urls.root if self.urls else None
404
452
 
405
453
  def fs(self) -> FilesystemResource:
406
454
  """Get a filesystem diff resource for inspecting file changes."""
@@ -618,7 +666,9 @@ class Fleet:
618
666
  )
619
667
 
620
668
  instance = SyncEnv(client=self.client, **response.json())
621
- instance.instance.load()
669
+ # Resources load lazily on first `db()`/`browser()`/`resources()` access via
670
+ # `_load_resources()`. Skipping the eager preload avoids fail-fast 502s while
671
+ # the container is still warming up.
622
672
  return instance
623
673
 
624
674
  def make_for_task(self, task: Task) -> SyncEnv:
@@ -670,7 +720,7 @@ class Fleet:
670
720
  else:
671
721
  response = self.client.request("GET", f"/v1/env/instances/{instance_id}")
672
722
  instance = SyncEnv(client=self.client, **response.json())
673
- instance.instance.load()
723
+ # Resources load lazily on first `db()`/`browser()`/`resources()` access.
674
724
  return instance
675
725
 
676
726
  def _create_url_instance(self, base_url: str) -> SyncEnv:
@@ -811,6 +861,50 @@ class Fleet:
811
861
  ) -> VerifiersExecuteResponse:
812
862
  return _execute_verifier_remote(self.client, bundle_data, args, kwargs, timeout)
813
863
 
864
+ def create_browser(
865
+ self,
866
+ ttl_seconds: int = 300,
867
+ *,
868
+ lease_id: Optional[str] = None,
869
+ allowed_hosts: Optional[List[str]] = None,
870
+ request_timestamp_ms: Optional[int] = None,
871
+ extra: Optional[Dict[str, Any]] = None,
872
+ jwt_token: Optional[str] = None,
873
+ team_id: Optional[str] = None,
874
+ wait_until_running: bool = False,
875
+ wait_timeout: float = 60.0,
876
+ ) -> BrowserLease:
877
+ """Create a Fleet Browser lease (``POST /v1/browser``).
878
+
879
+ Freeform — pass any of ``allowed_hosts`` / ``lease_id`` /
880
+ ``request_timestamp_ms`` directly, or use ``extra`` for keys this
881
+ SDK version doesn't surface yet.
882
+ """
883
+ return _create_browser_lease(
884
+ self.client,
885
+ ttl_seconds=ttl_seconds,
886
+ lease_id=lease_id,
887
+ allowed_hosts=allowed_hosts,
888
+ request_timestamp_ms=request_timestamp_ms,
889
+ extra=extra,
890
+ jwt_token=jwt_token,
891
+ team_id=team_id,
892
+ wait_until_running=wait_until_running,
893
+ wait_timeout=wait_timeout,
894
+ )
895
+
896
+ def get_browser(
897
+ self,
898
+ lease_id: str,
899
+ *,
900
+ jwt_token: Optional[str] = None,
901
+ team_id: Optional[str] = None,
902
+ ) -> BrowserLease:
903
+ """Inspect an existing browser lease (``GET /v1/browser/{lease_id}``)."""
904
+ return _get_browser_lease(
905
+ self.client, lease_id, jwt_token=jwt_token, team_id=team_id
906
+ )
907
+
814
908
  def delete(self, instance_id: str) -> InstanceResponse:
815
909
  return _delete_instance(self.client, instance_id)
816
910
 
fleet/models.py CHANGED
@@ -52,6 +52,7 @@ class Instance(BaseModel):
52
52
  region: str = Field(..., title="Region")
53
53
  env_variables: Optional[Dict[str, Any]] = Field(None, title="Env Variables")
54
54
  run_id: Optional[str] = Field(None, title="Run Id")
55
+ multi_env_list: Optional[List[str]] = Field(None, title="Multi Env List")
55
56
 
56
57
 
57
58
  class InstanceRequest(BaseModel):
@@ -385,6 +386,7 @@ class InstanceResponse(BaseModel):
385
386
  profile_id: Optional[str] = Field(None, title="Profile Id")
386
387
  heartbeat_interval: Optional[int] = Field(None, title="Heartbeat Interval")
387
388
  heartbeat_region: Optional[str] = Field(None, title="Heartbeat Region")
389
+ multi_env_list: Optional[List[str]] = Field(None, title="Multi Env List")
388
390
 
389
391
 
390
392
  class Run(BaseModel):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fleet-python
3
- Version: 0.2.128
3
+ Version: 0.2.129
4
4
  Summary: Python SDK for Fleet environments
5
5
  Author-email: Fleet AI <nic@fleet.so>
6
6
  License: Apache-2.0
@@ -24,24 +24,26 @@ examples/openai_simple_example.py,sha256=HmiufucrAZne7tHq9uoEsDWlEhjNC265bQAyIGB
24
24
  examples/query_builder_example.py,sha256=-cOMfWGNifYfYEt_Ds73XpwATZvFDL6F4KTkVxdMjzg,3951
25
25
  examples/quickstart.py,sha256=1VT39IRRhemsJgxi0O0gprdpcw7HB4pYO97GAYagIcg,3788
26
26
  examples/test_cdp_logging.py,sha256=AkCwQCgOTQEI8w3v0knWK_4eXMph7L9x07wj9yIYM10,2836
27
- fleet/__init__.py,sha256=eCbHM32E7To3Q5t7VJsd6sGQ4_WmrTCNdYT8rfjq8d4,7977
28
- fleet/base.py,sha256=2Y_9_1pJ3gDzgK_hAMrSvmsSKQgpmCEOX0XXTpq6YB4,10209
27
+ fleet/__init__.py,sha256=THC4oStKpTQC6zNjZRS0TEDqzGiU4Mst_jftJW1hXko,8193
28
+ fleet/base.py,sha256=yVB5d1xFN-1KxRVSjFCQpRSQ1IhVW5FjtoHOXzkM5Yo,10209
29
+ fleet/browser.py,sha256=6259Hgsygu9i_txbufOmQJYCyVb6qjPw_2-0aCLTLFU,7511
29
30
  fleet/cli.py,sha256=arX1E-fjLXtcV3tVVkPHfEXxl7FDn4zRmf0ssXlXaMg,40104
30
- fleet/client.py,sha256=RzUMoP-XsBcvZkQHUnKic5swflcpsAI9mdIGEusB18I,69820
31
+ fleet/client.py,sha256=AMpRyKdDhMwQZQbHVepFkUVmVzJ3aURQRkYsNwkGul8,73370
31
32
  fleet/config.py,sha256=n_wh9Sahu3gGE7nHJ7kqNFUH1qDiBtF4bgZq9MvIBMU,319
32
33
  fleet/exceptions.py,sha256=YqhQonZlxGdLP1HD0DNdKs9Q9BuyeYvK3pc6Glhpl14,5352
33
34
  fleet/global_client.py,sha256=frrDAFNM2ywN0JHLtlm9qbE1dQpnQJsavJpb7xSR_bU,1072
34
35
  fleet/judge.py,sha256=c41hAPFMfZvA0LLGPmp6BjaOsIKPf5Wz90UNUvBorRg,35044
35
- fleet/models.py,sha256=_HhgxeN563wG8MAHFGnFqzPlZKXRPDY2J1Ipp6Jiea0,25702
36
+ fleet/models.py,sha256=CS-sL84W50eH21MgtuFC0Oz1V7fdRyeqdb_txsmai3Q,25858
36
37
  fleet/tasks.py,sha256=7-bXf-H2EpnKMAT7t0XDiAePn6R76vi-OgTlD1bDt6Q,22500
37
38
  fleet/types.py,sha256=L4Y82xICf1tzyCLqhLYUgEoaIIS5h9T05TyFNHSWs3s,652
38
- fleet/_async/__init__.py,sha256=8EM5RKgPnzSw9NnaUZwwV-IyMXYHFX4MJTDRpuAdcqk,9116
39
- fleet/_async/base.py,sha256=OSRe1j7wKhP4LGnjGruQUXU2Qeao7UWx-1CsEJpiAnk,9774
40
- fleet/_async/client.py,sha256=Tm9et3n-cAa21fdkOp7FFD9xqi5LeuMvZxm8AuAiqnU,66283
39
+ fleet/_async/__init__.py,sha256=oTpKiIljz2_Tr5oWLsJqvfANVcD9PnsCB9xn2KZOtqo,9116
40
+ fleet/_async/base.py,sha256=zvMNIz1ctckeTMB4aIN5ELLBgPmv6u4mr95VVXxyNLk,9774
41
+ fleet/_async/browser.py,sha256=_lU23nuoSg8gwtiGGd4gGvD9ZRQ-gJFo42zxvDdM_ic,6388
42
+ fleet/_async/client.py,sha256=-mLVJ_ur1MFJZRb0vUBHyBPNlf1zYxT7Un67vLWWsWw,69693
41
43
  fleet/_async/exceptions.py,sha256=fUmPwWhnT8SR97lYsRq0kLHQHKtSh2eJS0VQ2caSzEI,5055
42
44
  fleet/_async/global_client.py,sha256=4WskpLHbsDEgWW7hXMD09W-brkp4euy8w2ZJ88594rQ,1103
43
45
  fleet/_async/judge.py,sha256=Ape82WAveHB9ApbslmoiUnuj5KL-DkdQLThUlOsuxgk,6026
44
- fleet/_async/models.py,sha256=MWNyzxEdODDvgngMCEXpTpOoUd6HN0oIt3Oul8aVJ90,14116
46
+ fleet/_async/models.py,sha256=wXvXhSk6XeCVFhF5q7YHQM-DgS4tAbm0mCaPW6MZPUg,14272
45
47
  fleet/_async/tasks.py,sha256=r8HPRchH354hUauf3PruMY4i-jqjfwbIfafwrWEFlZo,22310
46
48
  fleet/_async/env/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
49
  fleet/_async/env/client.py,sha256=FqetvDlABeHLaAc8UF_m_2OFEhXy4ZqH7ly_Nh8p3uA,3479
@@ -101,7 +103,7 @@ fleet/verifiers/decorator.py,sha256=RuTjjDijbicNfMSjA7HcTpKueEki5dzNOdTuHS7UoZs,
101
103
  fleet/verifiers/parse.py,sha256=qz9AfJrTbjlg-LU-lE8Ciqi7Yt2a8-cs17FdpjTLhMk,8550
102
104
  fleet/verifiers/sql_differ.py,sha256=TqTLWyK3uOyLbitT6HYzYEzuSFC39wcyhgk3rcm__k8,6525
103
105
  fleet/verifiers/verifier.py,sha256=DQ2m90AOEAhgNT2X7wl5NGDJY6NTSkkMsmLNXhOfs-A,15789
104
- fleet_python-0.2.128.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
106
+ fleet_python-0.2.129.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
105
107
  scripts/fix_sync_imports.py,sha256=X9fWLTpiPGkSHsjyQUDepOJkxOqw1DPj7nd8wFlFqLQ,8368
106
108
  scripts/unasync.py,sha256=vWVQxRWX8SRZO5cmzEhpvnG_REhCWXpidIGIpWmEcvI,696
107
109
  tests/__init__.py,sha256=Re1SdyxH8NfyL1kjhi7SQkGP1mYeWB-D6UALqdIMd8I,35
@@ -112,8 +114,8 @@ tests/test_instance_dispatch.py,sha256=CvU4C3LBIqsYZdEsEFfontGjyxAZfVYyXnGwxyIvX
112
114
  tests/test_sqlite_resource_dual_mode.py,sha256=Mh8jBd-xsIGDYFsOACKKK_5DXMUYlFFS7W-jaY6AjG4,8734
113
115
  tests/test_sqlite_shared_memory_behavior.py,sha256=fKx_1BmLS3b8x-9pMgjMycpnaHWY8P-2ZuXEspx6Sbw,4082
114
116
  tests/test_verifier_from_string.py,sha256=Lxi3TpFHFb-hG4-UhLKZJkqo84ax9YJY8G6beO-1erM,13581
115
- fleet_python-0.2.128.dist-info/METADATA,sha256=OxrOpgm1103DZCLYDKkkPKCZUQkwlwuZnPOYtBkN8SE,4240
116
- fleet_python-0.2.128.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
117
- fleet_python-0.2.128.dist-info/entry_points.txt,sha256=qKIQ326cHR5WyCd16QnrW-1DpcT0YyxVRDb3IlTyzTA,39
118
- fleet_python-0.2.128.dist-info/top_level.txt,sha256=qb1zIbtEktyhRFZdqVytwg54l64qtoZL0wjHB4bUg3c,29
119
- fleet_python-0.2.128.dist-info/RECORD,,
117
+ fleet_python-0.2.129.dist-info/METADATA,sha256=E5styAa9s0pNU03jBq-ncZaeUsNnDGvbpjsTXjUHjWw,4240
118
+ fleet_python-0.2.129.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
119
+ fleet_python-0.2.129.dist-info/entry_points.txt,sha256=qKIQ326cHR5WyCd16QnrW-1DpcT0YyxVRDb3IlTyzTA,39
120
+ fleet_python-0.2.129.dist-info/top_level.txt,sha256=qb1zIbtEktyhRFZdqVytwg54l64qtoZL0wjHB4bUg3c,29
121
+ fleet_python-0.2.129.dist-info/RECORD,,