fleet-python 0.2.2__py3-none-any.whl → 0.2.4__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 fleet-python might be problematic. Click here for more details.
- examples/dsl_example.py +108 -92
- examples/example.py +2 -2
- examples/json_tasks_example.py +82 -0
- examples/nova_act_example.py +18 -169
- examples/openai_example.py +89 -311
- examples/openai_simple_example.py +60 -0
- examples/quickstart.py +5 -5
- fleet/__init__.py +32 -3
- fleet/_async/base.py +51 -0
- fleet/_async/client.py +133 -0
- fleet/_async/env/__init__.py +0 -0
- fleet/_async/env/client.py +15 -0
- fleet/_async/exceptions.py +73 -0
- fleet/{manager → _async/instance}/__init__.py +4 -2
- fleet/{manager → _async/instance}/base.py +1 -24
- fleet/{manager → _async/instance}/client.py +44 -24
- fleet/{manager → _async/instance}/models.py +13 -0
- fleet/_async/models.py +109 -0
- fleet/_async/playwright.py +291 -0
- fleet/_async/resources/__init__.py +0 -0
- fleet/_async/resources/base.py +26 -0
- fleet/_async/resources/browser.py +41 -0
- fleet/_async/resources/sqlite.py +41 -0
- fleet/base.py +1 -24
- fleet/client.py +42 -95
- fleet/env/__init__.py +13 -1
- fleet/env/client.py +7 -7
- fleet/instance/__init__.py +26 -0
- fleet/instance/base.py +37 -0
- fleet/instance/client.py +278 -0
- fleet/instance/models.py +141 -0
- fleet/playwright.py +289 -0
- fleet/resources/__init__.py +0 -0
- fleet/resources/base.py +1 -1
- fleet/resources/browser.py +20 -23
- fleet/resources/sqlite.py +13 -13
- fleet/verifiers/__init__.py +10 -3
- fleet/verifiers/code.py +1 -0
- fleet/verifiers/{database_snapshot.py → db.py} +62 -22
- fleet/verifiers/sql_differ.py +1 -1
- {fleet_python-0.2.2.dist-info → fleet_python-0.2.4.dist-info}/METADATA +4 -1
- fleet_python-0.2.4.dist-info/RECORD +48 -0
- {fleet_python-0.2.2.dist-info → fleet_python-0.2.4.dist-info}/top_level.txt +1 -0
- scripts/unasync.py +28 -0
- fleet_python-0.2.2.dist-info/RECORD +0 -27
- {fleet_python-0.2.2.dist-info → fleet_python-0.2.4.dist-info}/WHEEL +0 -0
- {fleet_python-0.2.2.dist-info → fleet_python-0.2.4.dist-info}/licenses/LICENSE +0 -0
fleet/playwright.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from typing import List, Dict, Any
|
|
3
|
+
from playwright.sync_api import sync_playwright, Browser, Page
|
|
4
|
+
from .client import Environment
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Key mapping for computer use actions
|
|
8
|
+
CUA_KEY_TO_PLAYWRIGHT_KEY = {
|
|
9
|
+
"/": "Divide",
|
|
10
|
+
"\\": "Backslash",
|
|
11
|
+
"alt": "Alt",
|
|
12
|
+
"arrowdown": "ArrowDown",
|
|
13
|
+
"arrowleft": "ArrowLeft",
|
|
14
|
+
"arrowright": "ArrowRight",
|
|
15
|
+
"arrowup": "ArrowUp",
|
|
16
|
+
"backspace": "Backspace",
|
|
17
|
+
"capslock": "CapsLock",
|
|
18
|
+
"cmd": "Meta",
|
|
19
|
+
"ctrl": "Control",
|
|
20
|
+
"delete": "Delete",
|
|
21
|
+
"end": "End",
|
|
22
|
+
"enter": "Enter",
|
|
23
|
+
"esc": "Escape",
|
|
24
|
+
"home": "Home",
|
|
25
|
+
"insert": "Insert",
|
|
26
|
+
"option": "Alt",
|
|
27
|
+
"pagedown": "PageDown",
|
|
28
|
+
"pageup": "PageUp",
|
|
29
|
+
"shift": "Shift",
|
|
30
|
+
"space": " ",
|
|
31
|
+
"super": "Meta",
|
|
32
|
+
"tab": "Tab",
|
|
33
|
+
"win": "Meta",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FleetPlaywrightWrapper:
|
|
38
|
+
"""
|
|
39
|
+
A wrapper that adds Playwright browser automation to Fleet environment instances.
|
|
40
|
+
|
|
41
|
+
This class handles:
|
|
42
|
+
- Browser connection via CDP
|
|
43
|
+
- Computer actions (click, scroll, type, etc.)
|
|
44
|
+
- Screenshot capture
|
|
45
|
+
- Integration with OpenAI computer use API
|
|
46
|
+
|
|
47
|
+
Usage:
|
|
48
|
+
instance = await fleet.env.make(env_key="hubspot", version="v1.2.7")
|
|
49
|
+
browser = FleetPlaywrightWrapper(instance)
|
|
50
|
+
await browser.start()
|
|
51
|
+
|
|
52
|
+
# Use browser methods
|
|
53
|
+
screenshot = await browser.screenshot()
|
|
54
|
+
tools = [browser.openai_cua_tool]
|
|
55
|
+
|
|
56
|
+
# Clean up when done
|
|
57
|
+
await browser.close()
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def get_environment(self):
|
|
61
|
+
return "browser"
|
|
62
|
+
|
|
63
|
+
def get_dimensions(self):
|
|
64
|
+
return (1920, 1080)
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
env: Environment,
|
|
69
|
+
display_width: int = 1920,
|
|
70
|
+
display_height: int = 1080,
|
|
71
|
+
):
|
|
72
|
+
"""
|
|
73
|
+
Initialize the Fleet Playwright wrapper.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
env: Fleet environment instance
|
|
77
|
+
display_width: Browser viewport width
|
|
78
|
+
display_height: Browser viewport height
|
|
79
|
+
"""
|
|
80
|
+
self.env = env
|
|
81
|
+
self.display_width = display_width
|
|
82
|
+
self.display_height = display_height
|
|
83
|
+
|
|
84
|
+
self._playwright = None
|
|
85
|
+
self._browser: Browser | None = None
|
|
86
|
+
self._page: Page | None = None
|
|
87
|
+
self._started = False
|
|
88
|
+
|
|
89
|
+
def start(self):
|
|
90
|
+
"""Start the browser and establish connection."""
|
|
91
|
+
if self._started:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
# Start Playwright
|
|
95
|
+
self._playwright = sync_playwright().start()
|
|
96
|
+
|
|
97
|
+
# Start browser on the Fleet instance
|
|
98
|
+
print("Starting browser...")
|
|
99
|
+
self.env.browser().start()
|
|
100
|
+
cdp = self.env.browser().describe()
|
|
101
|
+
|
|
102
|
+
# Connect to browser
|
|
103
|
+
self._browser = self._playwright.chromium.connect_over_cdp(cdp.cdp_browser_url)
|
|
104
|
+
self._page = self._browser.contexts[0].pages[0]
|
|
105
|
+
self._page.set_viewport_size(
|
|
106
|
+
{"width": self.display_width, "height": self.display_height}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
self._started = True
|
|
110
|
+
print(f"Track agent: {cdp.cdp_devtools_url}")
|
|
111
|
+
|
|
112
|
+
def close(self):
|
|
113
|
+
"""Close the browser connection."""
|
|
114
|
+
if self._playwright:
|
|
115
|
+
self._playwright.stop()
|
|
116
|
+
self._playwright = None
|
|
117
|
+
self._browser = None
|
|
118
|
+
self._page = None
|
|
119
|
+
self._started = False
|
|
120
|
+
|
|
121
|
+
def _ensure_started(self):
|
|
122
|
+
"""Ensure browser is started before operations."""
|
|
123
|
+
if not self._started:
|
|
124
|
+
raise RuntimeError("Browser not started. Call await browser.start() first.")
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def openai_cua_tool(self) -> Dict[str, Any]:
|
|
128
|
+
"""
|
|
129
|
+
Tool definition for OpenAI computer use API.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Tool definition dict for use with OpenAI responses API
|
|
133
|
+
"""
|
|
134
|
+
return {
|
|
135
|
+
"type": "computer_use_preview",
|
|
136
|
+
"display_width": self.display_width,
|
|
137
|
+
"display_height": self.display_height,
|
|
138
|
+
"environment": "browser",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def screenshot(self) -> str:
|
|
142
|
+
"""
|
|
143
|
+
Take a screenshot and return base64 encoded string.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Base64 encoded PNG screenshot
|
|
147
|
+
"""
|
|
148
|
+
self._ensure_started()
|
|
149
|
+
|
|
150
|
+
png_bytes = self._page.screenshot(full_page=False)
|
|
151
|
+
return base64.b64encode(png_bytes).decode("utf-8")
|
|
152
|
+
|
|
153
|
+
def get_current_url(self) -> str:
|
|
154
|
+
"""Get the current page URL."""
|
|
155
|
+
self._ensure_started()
|
|
156
|
+
return self._page.url
|
|
157
|
+
|
|
158
|
+
def execute_computer_action(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
|
159
|
+
"""
|
|
160
|
+
Execute a computer action and return the result for OpenAI API.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
action: Computer action dict from OpenAI response
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Result dict for computer_call_output
|
|
167
|
+
"""
|
|
168
|
+
self._ensure_started()
|
|
169
|
+
|
|
170
|
+
action_type = action["type"]
|
|
171
|
+
action_args = {k: v for k, v in action.items() if k != "type"}
|
|
172
|
+
|
|
173
|
+
print(f"Executing: {action_type}({action_args})")
|
|
174
|
+
|
|
175
|
+
# Execute the action
|
|
176
|
+
if hasattr(self, f"_{action_type}"):
|
|
177
|
+
method = getattr(self, f"_{action_type}")
|
|
178
|
+
method(**action_args)
|
|
179
|
+
else:
|
|
180
|
+
raise ValueError(f"Unsupported action type: {action_type}")
|
|
181
|
+
|
|
182
|
+
# Take screenshot after action
|
|
183
|
+
screenshot_base64 = self.screenshot()
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
"type": "input_image",
|
|
187
|
+
"image_url": f"data:image/png;base64,{screenshot_base64}",
|
|
188
|
+
"current_url": self.get_current_url(),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Computer action implementations
|
|
192
|
+
def _click(self, x: int, y: int, button: str = "left") -> None:
|
|
193
|
+
"""Click at coordinates."""
|
|
194
|
+
self._ensure_started()
|
|
195
|
+
self._page.mouse.click(x, y, button=button)
|
|
196
|
+
|
|
197
|
+
def _double_click(self, x: int, y: int) -> None:
|
|
198
|
+
"""Double-click at coordinates."""
|
|
199
|
+
self._ensure_started()
|
|
200
|
+
self._page.mouse.dblclick(x, y)
|
|
201
|
+
|
|
202
|
+
def _scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None:
|
|
203
|
+
"""Scroll from coordinates."""
|
|
204
|
+
self._ensure_started()
|
|
205
|
+
self._page.mouse.move(x, y)
|
|
206
|
+
self._page.evaluate(f"window.scrollBy({scroll_x}, {scroll_y})")
|
|
207
|
+
|
|
208
|
+
def _type(self, text: str) -> None:
|
|
209
|
+
"""Type text."""
|
|
210
|
+
self._ensure_started()
|
|
211
|
+
self._page.keyboard.type(text)
|
|
212
|
+
|
|
213
|
+
def _keypress(self, keys: List[str]) -> None:
|
|
214
|
+
"""Press key combination."""
|
|
215
|
+
self._ensure_started()
|
|
216
|
+
mapped_keys = [CUA_KEY_TO_PLAYWRIGHT_KEY.get(key.lower(), key) for key in keys]
|
|
217
|
+
for key in mapped_keys:
|
|
218
|
+
self._page.keyboard.down(key)
|
|
219
|
+
for key in reversed(mapped_keys):
|
|
220
|
+
self._page.keyboard.up(key)
|
|
221
|
+
|
|
222
|
+
def _move(self, x: int, y: int) -> None:
|
|
223
|
+
"""Move mouse to coordinates."""
|
|
224
|
+
self._ensure_started()
|
|
225
|
+
self._page.mouse.move(x, y)
|
|
226
|
+
|
|
227
|
+
def _drag(self, path: List[Dict[str, int]]) -> None:
|
|
228
|
+
"""Drag mouse along path."""
|
|
229
|
+
self._ensure_started()
|
|
230
|
+
if not path:
|
|
231
|
+
return
|
|
232
|
+
self._page.mouse.move(path[0]["x"], path[0]["y"])
|
|
233
|
+
self._page.mouse.down()
|
|
234
|
+
for point in path[1:]:
|
|
235
|
+
self._page.mouse.move(point["x"], point["y"])
|
|
236
|
+
self._page.mouse.up()
|
|
237
|
+
|
|
238
|
+
def _wait(self, ms: int = 1000) -> None:
|
|
239
|
+
"""Wait for specified milliseconds."""
|
|
240
|
+
import asyncio
|
|
241
|
+
|
|
242
|
+
asyncio.sleep(ms / 1000)
|
|
243
|
+
|
|
244
|
+
# Browser-specific actions
|
|
245
|
+
def _goto(self, url: str) -> None:
|
|
246
|
+
"""Navigate to URL."""
|
|
247
|
+
self._ensure_started()
|
|
248
|
+
try:
|
|
249
|
+
self._page.goto(url)
|
|
250
|
+
except Exception as e:
|
|
251
|
+
print(f"Error navigating to {url}: {e}")
|
|
252
|
+
|
|
253
|
+
def _back(self) -> None:
|
|
254
|
+
"""Go back in browser history."""
|
|
255
|
+
self._ensure_started()
|
|
256
|
+
self._page.go_back()
|
|
257
|
+
|
|
258
|
+
def _forward(self) -> None:
|
|
259
|
+
"""Go forward in browser history."""
|
|
260
|
+
self._ensure_started()
|
|
261
|
+
self._page.go_forward()
|
|
262
|
+
|
|
263
|
+
def _refresh(self) -> None:
|
|
264
|
+
"""Refresh the page."""
|
|
265
|
+
self._ensure_started()
|
|
266
|
+
self._page.reload()
|
|
267
|
+
|
|
268
|
+
# ------------------------------------------------------------------
|
|
269
|
+
# Public aliases (no leading underscore) expected by the Agent &
|
|
270
|
+
# OpenAI computer-use API. They forward directly to the underscored
|
|
271
|
+
# implementations above so the external interface matches the older
|
|
272
|
+
# BasePlaywrightComputer class.
|
|
273
|
+
# ------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
# Mouse / keyboard actions
|
|
276
|
+
click = _click
|
|
277
|
+
double_click = _double_click
|
|
278
|
+
scroll = _scroll
|
|
279
|
+
type = _type # noqa: A003 – shadowing built-in for API compatibility
|
|
280
|
+
keypress = _keypress
|
|
281
|
+
move = _move
|
|
282
|
+
drag = _drag
|
|
283
|
+
wait = _wait
|
|
284
|
+
|
|
285
|
+
# Browser navigation actions
|
|
286
|
+
goto = _goto
|
|
287
|
+
back = _back
|
|
288
|
+
forward = _forward
|
|
289
|
+
refresh = _refresh
|
|
File without changes
|
fleet/resources/base.py
CHANGED
fleet/resources/browser.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
|
-
from ..
|
|
2
|
+
from ..instance.models import (
|
|
3
3
|
Resource as ResourceModel,
|
|
4
4
|
CDPDescribeResponse,
|
|
5
5
|
ChromeStartRequest,
|
|
@@ -10,35 +10,32 @@ from .base import Resource
|
|
|
10
10
|
from typing import TYPE_CHECKING
|
|
11
11
|
|
|
12
12
|
if TYPE_CHECKING:
|
|
13
|
-
from ..
|
|
13
|
+
from ..instance.base import SyncWrapper
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
class
|
|
17
|
-
def __init__(self, resource: ResourceModel, client: "
|
|
16
|
+
class BrowserResource(Resource):
|
|
17
|
+
def __init__(self, resource: ResourceModel, client: "SyncWrapper"):
|
|
18
18
|
super().__init__(resource)
|
|
19
19
|
self.client = client
|
|
20
|
-
self._describe: Optional[CDPDescribeResponse] = None
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
response =
|
|
21
|
+
def start(self, width: int = 1920, height: int = 1080) -> CDPDescribeResponse:
|
|
22
|
+
response = self.client.request(
|
|
24
23
|
"POST",
|
|
25
24
|
"/resources/cdp/start",
|
|
26
25
|
json=ChromeStartRequest(resolution=f"{width},{height}").model_dump(),
|
|
27
26
|
)
|
|
28
27
|
ChromeStartResponse(**response.json())
|
|
29
|
-
return
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
async def devtools_url(self) -> str:
|
|
44
|
-
return (await self.describe()).cdp_devtools_url
|
|
28
|
+
return self.describe()
|
|
29
|
+
|
|
30
|
+
def describe(self) -> CDPDescribeResponse:
|
|
31
|
+
response = self.client.request("GET", "/resources/cdp/describe")
|
|
32
|
+
if response.status_code != 200:
|
|
33
|
+
self.start()
|
|
34
|
+
response = self.client.request("GET", "/resources/cdp/describe")
|
|
35
|
+
return CDPDescribeResponse(**response.json())
|
|
36
|
+
|
|
37
|
+
def cdp_url(self) -> str:
|
|
38
|
+
return (self.describe()).cdp_browser_url
|
|
39
|
+
|
|
40
|
+
def devtools_url(self) -> str:
|
|
41
|
+
return (self.describe()).cdp_devtools_url
|
fleet/resources/sqlite.py
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
1
|
from typing import Any, List, Optional
|
|
2
|
-
from ..
|
|
3
|
-
from ..
|
|
2
|
+
from ..instance.models import Resource as ResourceModel
|
|
3
|
+
from ..instance.models import DescribeResponse, QueryRequest, QueryResponse
|
|
4
4
|
from .base import Resource
|
|
5
5
|
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
8
|
if TYPE_CHECKING:
|
|
9
|
-
from ..
|
|
9
|
+
from ..instance.base import SyncWrapper
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
class
|
|
13
|
-
def __init__(self, resource: ResourceModel, client: "
|
|
12
|
+
class SQLiteResource(Resource):
|
|
13
|
+
def __init__(self, resource: ResourceModel, client: "SyncWrapper"):
|
|
14
14
|
super().__init__(resource)
|
|
15
15
|
self.client = client
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
def describe(self) -> DescribeResponse:
|
|
18
18
|
"""Describe the SQLite database schema."""
|
|
19
|
-
response =
|
|
19
|
+
response = self.client.request(
|
|
20
20
|
"GET", f"/resources/sqlite/{self.resource.name}/describe"
|
|
21
21
|
)
|
|
22
22
|
return DescribeResponse(**response.json())
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
def query(
|
|
25
25
|
self, query: str, args: Optional[List[Any]] = None
|
|
26
26
|
) -> QueryResponse:
|
|
27
|
-
return
|
|
27
|
+
return self._query(query, args, read_only=True)
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
return
|
|
29
|
+
def exec(self, query: str, args: Optional[List[Any]] = None) -> QueryResponse:
|
|
30
|
+
return self._query(query, args, read_only=False)
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
def _query(
|
|
33
33
|
self, query: str, args: Optional[List[Any]] = None, read_only: bool = True
|
|
34
34
|
) -> QueryResponse:
|
|
35
35
|
request = QueryRequest(query=query, args=args, read_only=read_only)
|
|
36
|
-
response =
|
|
36
|
+
response = self.client.request(
|
|
37
37
|
"POST",
|
|
38
38
|
f"/resources/sqlite/{self.resource.name}/query",
|
|
39
39
|
json=request.model_dump(),
|
fleet/verifiers/__init__.py
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
from .sql_differ import SQLiteDiffer
|
|
1
|
+
"""Fleet verifiers module - database snapshot validation utilities."""
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
from .db import DatabaseSnapshot, IgnoreConfig, SnapshotDiff
|
|
4
|
+
from .code import TASK_SUCCESSFUL_SCORE
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"DatabaseSnapshot",
|
|
8
|
+
"IgnoreConfig",
|
|
9
|
+
"SnapshotDiff",
|
|
10
|
+
"TASK_SUCCESSFUL_SCORE",
|
|
11
|
+
]
|
fleet/verifiers/code.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
TASK_SUCCESSFUL_SCORE = 1
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
# database_dsl.py
|
|
2
1
|
"""A schema‑agnostic, SQL‑native DSL for snapshot validation and diff invariants.
|
|
3
2
|
|
|
4
3
|
The module extends your original `DatabaseSnapshot` implementation with
|
|
@@ -11,11 +10,13 @@ The module extends your original `DatabaseSnapshot` implementation with
|
|
|
11
10
|
The public API stays tiny yet composable; everything else is built on
|
|
12
11
|
orthogonal primitives so it works for *any* relational schema.
|
|
13
12
|
"""
|
|
13
|
+
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import sqlite3
|
|
17
17
|
from datetime import datetime
|
|
18
18
|
from typing import Any
|
|
19
|
+
import json
|
|
19
20
|
|
|
20
21
|
################################################################################
|
|
21
22
|
# Low‑level helpers
|
|
@@ -26,6 +27,36 @@ Condition = tuple[str, str, SQLValue] # (column, op, value)
|
|
|
26
27
|
JoinSpec = tuple[str, dict[str, str]] # (table, on mapping)
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
def _is_json_string(value: Any) -> bool:
|
|
31
|
+
"""Check if a value looks like a JSON string."""
|
|
32
|
+
if not isinstance(value, str):
|
|
33
|
+
return False
|
|
34
|
+
value = value.strip()
|
|
35
|
+
return (value.startswith("{") and value.endswith("}")) or (
|
|
36
|
+
value.startswith("[") and value.endswith("]")
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _values_equivalent(val1: Any, val2: Any) -> bool:
|
|
41
|
+
"""Compare two values, using JSON semantic comparison for JSON strings."""
|
|
42
|
+
# If both are exactly equal, return True
|
|
43
|
+
if val1 == val2:
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
# If both look like JSON strings, try semantic comparison
|
|
47
|
+
if _is_json_string(val1) and _is_json_string(val2):
|
|
48
|
+
try:
|
|
49
|
+
parsed1 = json.loads(val1)
|
|
50
|
+
parsed2 = json.loads(val2)
|
|
51
|
+
return parsed1 == parsed2
|
|
52
|
+
except (json.JSONDecodeError, TypeError):
|
|
53
|
+
# If parsing fails, fall back to string comparison
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
# Default to exact comparison
|
|
57
|
+
return val1 == val2
|
|
58
|
+
|
|
59
|
+
|
|
29
60
|
class _CountResult:
|
|
30
61
|
"""Wraps an integer count so we can chain assertions fluently."""
|
|
31
62
|
|
|
@@ -96,9 +127,7 @@ class QueryBuilder:
|
|
|
96
127
|
# ---------------------------------------------------------------------
|
|
97
128
|
# WHERE helpers (SQL‑like)
|
|
98
129
|
# ---------------------------------------------------------------------
|
|
99
|
-
def _add_condition(
|
|
100
|
-
self, column: str, op: str, value: SQLValue
|
|
101
|
-
) -> "QueryBuilder": # noqa: UP037
|
|
130
|
+
def _add_condition(self, column: str, op: str, value: SQLValue) -> "QueryBuilder": # noqa: UP037
|
|
102
131
|
qb = self._clone()
|
|
103
132
|
qb._conditions.append((column, op, value))
|
|
104
133
|
return qb
|
|
@@ -126,9 +155,7 @@ class QueryBuilder:
|
|
|
126
155
|
qb._conditions.append((column, "IN", tuple(values)))
|
|
127
156
|
return qb
|
|
128
157
|
|
|
129
|
-
def not_in(
|
|
130
|
-
self, column: str, values: list[SQLValue]
|
|
131
|
-
) -> "QueryBuilder": # noqa: UP037
|
|
158
|
+
def not_in(self, column: str, values: list[SQLValue]) -> "QueryBuilder": # noqa: UP037
|
|
132
159
|
qb = self._clone()
|
|
133
160
|
qb._conditions.append((column, "NOT IN", tuple(values)))
|
|
134
161
|
return qb
|
|
@@ -147,9 +174,7 @@ class QueryBuilder:
|
|
|
147
174
|
# ---------------------------------------------------------------------
|
|
148
175
|
# JOIN (simple inner join)
|
|
149
176
|
# ---------------------------------------------------------------------
|
|
150
|
-
def join(
|
|
151
|
-
self, other_table: str, on: dict[str, str]
|
|
152
|
-
) -> "QueryBuilder": # noqa: UP037
|
|
177
|
+
def join(self, other_table: str, on: dict[str, str]) -> "QueryBuilder": # noqa: UP037
|
|
153
178
|
"""`on` expects {local_col: remote_col}."""
|
|
154
179
|
qb = self._clone()
|
|
155
180
|
qb._joins.append((other_table, on))
|
|
@@ -166,7 +191,8 @@ class QueryBuilder:
|
|
|
166
191
|
# Joins -------------------------------------------------------------
|
|
167
192
|
for tbl, onmap in self._joins:
|
|
168
193
|
join_clauses = [
|
|
169
|
-
f"{self._table}.{l} = {tbl}.{r}"
|
|
194
|
+
f"{self._table}.{l} = {tbl}.{r}"
|
|
195
|
+
for l, r in onmap.items() # noqa: E741
|
|
170
196
|
]
|
|
171
197
|
sql.append(f"JOIN {tbl} ON {' AND '.join(join_clauses)}")
|
|
172
198
|
|
|
@@ -430,10 +456,27 @@ class SnapshotDiff:
|
|
|
430
456
|
def expect_only(self, allowed_changes: list[dict[str, Any]]):
|
|
431
457
|
"""Allowed changes is a list of {table, pk, field, after} (before optional)."""
|
|
432
458
|
diff = self._collect()
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
459
|
+
|
|
460
|
+
def _is_change_allowed(
|
|
461
|
+
table: str, row_id: str, field: str | None, after_value: Any
|
|
462
|
+
) -> bool:
|
|
463
|
+
"""Check if a change is in the allowed list using semantic comparison."""
|
|
464
|
+
for allowed in allowed_changes:
|
|
465
|
+
allowed_pk = allowed.get("pk")
|
|
466
|
+
# Handle type conversion for primary key comparison
|
|
467
|
+
# Convert both to strings for comparison to handle int/string mismatches
|
|
468
|
+
pk_match = (
|
|
469
|
+
str(allowed_pk) == str(row_id) if allowed_pk is not None else False
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
if (
|
|
473
|
+
allowed["table"] == table
|
|
474
|
+
and pk_match
|
|
475
|
+
and allowed.get("field") == field
|
|
476
|
+
and _values_equivalent(allowed.get("after"), after_value)
|
|
477
|
+
):
|
|
478
|
+
return True
|
|
479
|
+
return False
|
|
437
480
|
|
|
438
481
|
# Collect all unexpected changes for detailed reporting
|
|
439
482
|
unexpected_changes = []
|
|
@@ -443,8 +486,7 @@ class SnapshotDiff:
|
|
|
443
486
|
for f, vals in row["changes"].items():
|
|
444
487
|
if self.ignore_config.should_ignore_field(tbl, f):
|
|
445
488
|
continue
|
|
446
|
-
|
|
447
|
-
if tup not in allowed_set:
|
|
489
|
+
if not _is_change_allowed(tbl, row["row_id"], f, vals["after"]):
|
|
448
490
|
unexpected_changes.append(
|
|
449
491
|
{
|
|
450
492
|
"type": "modification",
|
|
@@ -458,8 +500,7 @@ class SnapshotDiff:
|
|
|
458
500
|
)
|
|
459
501
|
|
|
460
502
|
for row in report.get("added_rows", []):
|
|
461
|
-
|
|
462
|
-
if tup not in allowed_set:
|
|
503
|
+
if not _is_change_allowed(tbl, row["row_id"], None, "__added__"):
|
|
463
504
|
unexpected_changes.append(
|
|
464
505
|
{
|
|
465
506
|
"type": "insertion",
|
|
@@ -472,8 +513,7 @@ class SnapshotDiff:
|
|
|
472
513
|
)
|
|
473
514
|
|
|
474
515
|
for row in report.get("removed_rows", []):
|
|
475
|
-
|
|
476
|
-
if tup not in allowed_set:
|
|
516
|
+
if not _is_change_allowed(tbl, row["row_id"], None, "__removed__"):
|
|
477
517
|
unexpected_changes.append(
|
|
478
518
|
{
|
|
479
519
|
"type": "deletion",
|
|
@@ -663,4 +703,4 @@ class DatabaseSnapshot:
|
|
|
663
703
|
|
|
664
704
|
# ---------------------------------------------------------------------
|
|
665
705
|
def __repr__(self):
|
|
666
|
-
return f"<DatabaseSnapshot {self.name} at {self.db_path}>"
|
|
706
|
+
return f"<DatabaseSnapshot {self.name} at {self.db_path}>"
|
fleet/verifiers/sql_differ.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fleet-python
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Python SDK for Fleet environments
|
|
5
5
|
Author-email: Fleet AI <nic@fleet.so>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -31,6 +31,9 @@ Requires-Dist: black>=22.0.0; extra == "dev"
|
|
|
31
31
|
Requires-Dist: isort>=5.0.0; extra == "dev"
|
|
32
32
|
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
33
33
|
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
34
|
+
Requires-Dist: unasync>=0.6.0; extra == "dev"
|
|
35
|
+
Provides-Extra: playwright
|
|
36
|
+
Requires-Dist: playwright>=1.40.0; extra == "playwright"
|
|
34
37
|
Dynamic: license-file
|
|
35
38
|
|
|
36
39
|
# Fleet SDK
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
examples/dsl_example.py,sha256=nBfKdv4u6XuM97TG-ugfVStPdtoYq86ildFzLcQqGIk,5315
|
|
2
|
+
examples/example.py,sha256=B1ERVudPsPx1D1od3gCQU8MnG4FxuSBm0p7otkvIaLw,945
|
|
3
|
+
examples/json_tasks_example.py,sha256=_y4w94FfAaMBGgIqhVlrDU0PXbinNDWrT28DsUMvd9Y,2354
|
|
4
|
+
examples/nova_act_example.py,sha256=YMfx_7O1_6pqsy0Rf6Yabyqyg4_kI3cgMBlMJjAQXW4,790
|
|
5
|
+
examples/openai_example.py,sha256=5ZvBC0dcw8gPHoC6Gn-1KZx3CmUQBlBmPJgOrcgMSVM,8188
|
|
6
|
+
examples/openai_simple_example.py,sha256=pS2z7Q3s7tml5hbahc1NGgMXoF0mWwhi7VeLDTA4sE4,1702
|
|
7
|
+
examples/quickstart.py,sha256=lVRzbnWIweU9ioe7uk6R2Rm7oSpt4mt8Jq_VUUp1zKg,4696
|
|
8
|
+
fleet/__init__.py,sha256=CxDokocjQsv_JUNwhSqnijpQVXhQx73CXLu2Cp-rtgk,2087
|
|
9
|
+
fleet/base.py,sha256=t4xkgazl8kEP05JFjNByyf39RvvASRP0GsvxuoqKPY0,1395
|
|
10
|
+
fleet/client.py,sha256=SQEUoj4DMuXa4m5wfPPCkBEhQc7f6941K9V8Qk6ZF0U,4675
|
|
11
|
+
fleet/exceptions.py,sha256=yG3QWprCw1OnF-vdFBFJWE4m3ftBLBng31Dr__VbjI4,2249
|
|
12
|
+
fleet/models.py,sha256=Jf6Zmk689TPXhTSnVENK_VCw0VsujWzEWsN3T29MQ0k,3713
|
|
13
|
+
fleet/playwright.py,sha256=LWx_1UlNZPo117Lf7qnBlWT4RJA0OcwqRVRdFE98bSQ,8627
|
|
14
|
+
fleet/_async/base.py,sha256=hUch1I5oUPgaCXR3IpJ8f_PjigifAZg2-LR7BJdZSo8,1413
|
|
15
|
+
fleet/_async/client.py,sha256=pL0csdrAJKltA9km6DHDRogPpTEzi8AH32mS1efxNgg,4920
|
|
16
|
+
fleet/_async/exceptions.py,sha256=yG3QWprCw1OnF-vdFBFJWE4m3ftBLBng31Dr__VbjI4,2249
|
|
17
|
+
fleet/_async/models.py,sha256=Jf6Zmk689TPXhTSnVENK_VCw0VsujWzEWsN3T29MQ0k,3713
|
|
18
|
+
fleet/_async/playwright.py,sha256=5MLFPE5P_-MpzAQ3EJ6GsLthuJiWwYkNuvhPE_rwe_E,8914
|
|
19
|
+
fleet/_async/env/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
fleet/_async/env/client.py,sha256=Gft6pNkUK4PYMV8VE3KyS5R_-HuqGO5HEjAUuCPObiw,440
|
|
21
|
+
fleet/_async/instance/__init__.py,sha256=jIt-7EEJ0WM_ipheT_s0lniCbLei6yUdN0qQv1bMJ3E,524
|
|
22
|
+
fleet/_async/instance/base.py,sha256=QgcCTHdcqhi5VQi6_a1uuR-uO2_2Z19-RwVPp1k266A,947
|
|
23
|
+
fleet/_async/instance/client.py,sha256=Llc-yefnbL3fkBsayU4ANEqy_fFB2Uy6jNhTkfGj1mU,9362
|
|
24
|
+
fleet/_async/instance/models.py,sha256=ZTiue0YOuhuwX8jYfJAoCzGfqjLqqXRLqK1LVFhq6rQ,4183
|
|
25
|
+
fleet/_async/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
|
+
fleet/_async/resources/base.py,sha256=203gD54NP1IvjuSqFo-f7FvrkhtjChggtzrxJK7xf2E,667
|
|
27
|
+
fleet/_async/resources/browser.py,sha256=x11y4aKHogIEv83FByHtExerjV-cDWI3U62349Guq_Q,1368
|
|
28
|
+
fleet/_async/resources/sqlite.py,sha256=sRiII_qJ8X6-FSemlBsXThz4ZPjkNy9wDT8g5UAz2XM,1501
|
|
29
|
+
fleet/env/__init__.py,sha256=_lvYBqieXWmvU_dyPi2seSpLO3AZh5kdprdqFeefkzk,338
|
|
30
|
+
fleet/env/client.py,sha256=UGPrRlu89NM2RFdY7m6cGwUjOHJdtiPe4vkz-f2ByRg,351
|
|
31
|
+
fleet/instance/__init__.py,sha256=cdVC50HLLp2y7yf1Ga5wpLiy-hmamxmyibH0NDG7xI4,597
|
|
32
|
+
fleet/instance/base.py,sha256=U-qW1EQVBo6yvMpP1JeKiPRhCjZ3y3aTsYFhLPNOTtQ,929
|
|
33
|
+
fleet/instance/client.py,sha256=RFp5R703pP3tjKd_HBozJJ2qNie7LpMUx9NYjyPnPOI,9128
|
|
34
|
+
fleet/instance/models.py,sha256=ZTiue0YOuhuwX8jYfJAoCzGfqjLqqXRLqK1LVFhq6rQ,4183
|
|
35
|
+
fleet/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
36
|
+
fleet/resources/base.py,sha256=203gD54NP1IvjuSqFo-f7FvrkhtjChggtzrxJK7xf2E,667
|
|
37
|
+
fleet/resources/browser.py,sha256=hRNM0YMsVQUAraZGNi_B-KXxLpuddy4ntoEDFSw7czU,1295
|
|
38
|
+
fleet/resources/sqlite.py,sha256=sZVWsuq46ger-ta6PSlqcXGJG8iWpNQVj0CPmDNBXv8,1446
|
|
39
|
+
fleet/verifiers/__init__.py,sha256=mRMN8x0gDWFJ1MRLqdBtQw0gn_q8kDV3lMLyoiEf1yY,281
|
|
40
|
+
fleet/verifiers/code.py,sha256=NJ4OLZnpqLkI1lXY7-5m2GuZklLxMzHUCnRMVyN2_OI,25
|
|
41
|
+
fleet/verifiers/db.py,sha256=tssmvJjDHuBIy8qlL_P5-UdmEFUw2DZcqLsWZ8ot3Xw,27766
|
|
42
|
+
fleet/verifiers/sql_differ.py,sha256=dmiGCFXVMEMbAX519OjhVqgA8ZvhnvdmC1BVpL7QCF0,6490
|
|
43
|
+
fleet_python-0.2.4.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
44
|
+
scripts/unasync.py,sha256=--Fmaae47o-dZ1HYgX1c3Nvi-rMjcFymTRlJcWWnmpw,725
|
|
45
|
+
fleet_python-0.2.4.dist-info/METADATA,sha256=WbcSC3GkduODoS30lm4TE1YMX6WEJ92HdMwHb-igUR4,3199
|
|
46
|
+
fleet_python-0.2.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
47
|
+
fleet_python-0.2.4.dist-info/top_level.txt,sha256=_3DSmTohvSDf3AIP_BYfGzhwO1ECFwuzg83X-wHCx3Y,23
|
|
48
|
+
fleet_python-0.2.4.dist-info/RECORD,,
|