boxfab-desktop 0.1.0a1__tar.gz

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.
@@ -0,0 +1,10 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 FOUNDRYLABS, INC.
4
+ Copyright (c) 2026 ContextFab, Inc.
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,290 @@
1
+ Metadata-Version: 2.4
2
+ Name: boxfab-desktop
3
+ Version: 0.1.0a1
4
+ Summary: BoxFab Desktop: a virtual Linux desktop in a BoxFab sandbox, for computer-use agents
5
+ Author: ContextFab, Inc.
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Dist: boxfab>=0.1.0a3,<0.2
15
+ Requires-Dist: requests>=2.32.3,<3
16
+ Requires-Dist: urllib3>=2.8.0,<3
17
+ Requires-Dist: pillow>=13.0.0,<14
18
+ Requires-Python: >=3.11
19
+ Project-URL: Repository, https://github.com/boxfab/python-sdk
20
+ Description-Content-Type: text/markdown
21
+
22
+ # BoxFab Desktop (Python)
23
+
24
+ A virtual Linux desktop in a [BoxFab](https://boxfab.io) sandbox, for computer-use agents: mouse
25
+ and keyboard control, screenshots, application windows, and a live stream of the screen in the
26
+ browser.
27
+
28
+ ## Getting started
29
+
30
+ ### 1. Get a BoxFab API key
31
+
32
+ Get a key from the [BoxFab dashboard](https://dashboard.boxfab.io) and set it as
33
+ `BOXFAB_API_KEY`. The SDK reads only `BOXFAB_*` variables.
34
+
35
+ ### 2. Install the SDK
36
+
37
+ ```bash
38
+ pip install boxfab-desktop
39
+ ```
40
+
41
+ > **Stream access.** `desktop.stream.start()` runs without a password unless you pass
42
+ > `require_auth=True`. The stream URL contains the sandbox ID, and anyone who has the URL can see
43
+ > and control the desktop. Pass `require_auth=True` whenever the URL leaves your backend; the auth
44
+ > key is then part of the URL returned by `get_url(auth_key=...)`, so share that URL only with the
45
+ > intended viewer.
46
+
47
+ ### 3. Create Desktop Sandbox
48
+
49
+ ```python
50
+ from boxfab_desktop import Sandbox
51
+
52
+ # Create a new desktop sandbox
53
+ desktop = Sandbox.create()
54
+
55
+ # Launch an application
56
+ desktop.launch('google-chrome') # or vscode, firefox, etc.
57
+
58
+ # Wait 10s for the application to open
59
+ desktop.wait(10000)
60
+
61
+ # Stream the application's window
62
+ # Note: There can be only one stream at a time
63
+ # You need to stop the current stream before streaming another application
64
+ desktop.stream.start(
65
+ window_id=desktop.get_current_window_id(), # if not provided the whole desktop will be streamed
66
+ require_auth=True
67
+ )
68
+
69
+ # Get the stream auth key
70
+ auth_key = desktop.stream.get_auth_key()
71
+
72
+ # Print the stream URL
73
+ print('Stream URL:', desktop.stream.get_url(auth_key=auth_key))
74
+
75
+ # Kill the sandbox after the tasks are finished
76
+ # desktop.kill()
77
+ ```
78
+
79
+ ### 4. Bind the configuration to a client
80
+
81
+ The top-level `Sandbox` export reads its configuration from the environment variables. To use an explicit configuration — e.g. several API keys or domains in one process — create a `BoxFab` client and use the resource classes it exposes:
82
+
83
+ ```python
84
+ from boxfab_desktop import BoxFab
85
+
86
+ client = BoxFab(api_key="e2b_***", domain="boxfab.io")
87
+
88
+ desktop = client.Sandbox.create()
89
+ desktop.stream.start()
90
+
91
+ # The core resources are bound to the client's configuration as well.
92
+ volume = client.Volume.create("my-volume")
93
+ exists = client.Template.exists("my-template")
94
+ secret = client.Secret.create("openai-api-key", "sk-***")
95
+
96
+ # The classes can be assigned and used like the top-level ones.
97
+ Sandbox = client.Sandbox
98
+ paginator = Sandbox.list()
99
+ ```
100
+
101
+ Per-call params still take precedence over the client's params, and clients are isolated from each other and from the env-configured top-level exports.
102
+
103
+ ## Features
104
+
105
+ ### Streaming desktop's screen
106
+
107
+ ```python
108
+ from boxfab_desktop import Sandbox
109
+ desktop = Sandbox.create()
110
+
111
+ # Start the stream
112
+ desktop.stream.start()
113
+
114
+ # Get stream URL
115
+ url = desktop.stream.get_url()
116
+ print(url)
117
+
118
+ # Stop the stream
119
+ desktop.stream.stop()
120
+ ```
121
+
122
+ ### Streaming with password protection
123
+
124
+ ```python
125
+ from boxfab_desktop import Sandbox
126
+ desktop = Sandbox.create()
127
+
128
+ # Start the stream
129
+ desktop.stream.start(
130
+ require_auth=True # Enable authentication with an auto-generated key
131
+ )
132
+
133
+ # Retrieve the authentication key
134
+ auth_key = desktop.stream.get_auth_key()
135
+
136
+ # Get stream URL
137
+ url = desktop.stream.get_url(auth_key=auth_key)
138
+ print(url)
139
+
140
+ # Stop the stream
141
+ desktop.stream.stop()
142
+ ```
143
+
144
+ ### Streaming specific application
145
+
146
+ > [!WARNING]
147
+ >
148
+ > - Will raise an error if the desired application is not open yet
149
+ > - The stream will close once the application closes
150
+ > - Creating multiple streams at the same time is not supported, you may have to stop the current stream and start a new one for each application
151
+
152
+ ```python
153
+ from boxfab_desktop import Sandbox
154
+ desktop = Sandbox.create()
155
+
156
+ # Get current (active) window ID
157
+ window_id = desktop.get_current_window_id()
158
+
159
+ # Get all windows of the application
160
+ window_ids = desktop.get_application_windows("Firefox")
161
+
162
+ # Start the stream
163
+ desktop.stream.start(window_id=window_ids[0])
164
+
165
+ # Stop the stream
166
+ desktop.stream.stop()
167
+ ```
168
+
169
+ ### Mouse control
170
+
171
+ ```python
172
+ from boxfab_desktop import Sandbox
173
+ desktop = Sandbox.create()
174
+
175
+ desktop.double_click()
176
+ desktop.left_click()
177
+ desktop.left_click(x=100, y=200)
178
+ desktop.right_click()
179
+ desktop.right_click(x=100, y=200)
180
+ desktop.middle_click()
181
+ desktop.middle_click(x=100, y=200)
182
+ desktop.scroll(10) # Scroll by the amount. Positive for up, negative for down.
183
+ desktop.move_mouse(100, 200) # Move to x, y coordinates
184
+ desktop.drag((100, 100), (200, 200)) # Drag using the mouse
185
+ desktop.mouse_press("left") # Press the mouse button
186
+ desktop.mouse_release("left") # Release the mouse button
187
+ ```
188
+
189
+ ### Keyboard control
190
+
191
+ ```python
192
+ from boxfab_desktop import Sandbox
193
+ desktop = Sandbox.create()
194
+
195
+ # Write text at the current cursor position with customizable typing speed
196
+ desktop.write("Hello, world!") # Default: chunk_size=25, delay_in_ms=75
197
+ desktop.write("Fast typing!", chunk_size=50, delay_in_ms=25) # Faster typing
198
+
199
+ # Press keys
200
+ desktop.press("enter")
201
+ desktop.press("space")
202
+ desktop.press("backspace")
203
+ desktop.press(["ctrl", "c"]) # Key combination
204
+ ```
205
+
206
+ ### Window control
207
+
208
+ ```python
209
+ from boxfab_desktop import Sandbox
210
+ desktop = Sandbox.create()
211
+
212
+ # Get current (active) window ID
213
+ window_id = desktop.get_current_window_id()
214
+
215
+ # Get all windows of the application
216
+ window_ids = desktop.get_application_windows("Firefox")
217
+
218
+ # Get window title
219
+ title = desktop.get_window_title(window_id)
220
+ ```
221
+
222
+ ### Screenshot
223
+
224
+ ```python
225
+ from boxfab_desktop import Sandbox
226
+ desktop = Sandbox.create()
227
+
228
+ # Take a screenshot and save it as "screenshot.png" locally
229
+ image = desktop.screenshot()
230
+ # Save the image to a file
231
+ with open("screenshot.png", "wb") as f:
232
+ f.write(image)
233
+ ```
234
+
235
+ ### Open file
236
+
237
+ ```python
238
+ from boxfab_desktop import Sandbox
239
+ desktop = Sandbox.create()
240
+
241
+ # Open file with default application
242
+ desktop.files.write("/home/user/index.js", "console.log('hello')") # First create the file
243
+ desktop.open("/home/user/index.js") # Then open it
244
+ ```
245
+
246
+ ### Launch applications
247
+
248
+ ```python
249
+ from boxfab_desktop import Sandbox
250
+ desktop = Sandbox.create()
251
+
252
+ # Launch the application
253
+ desktop.launch('google-chrome')
254
+ ```
255
+
256
+ ### Run any bash commands
257
+
258
+ ```python
259
+ from boxfab_desktop import Sandbox
260
+ desktop = Sandbox.create()
261
+
262
+ # Run any bash command
263
+ out = desktop.commands.run("ls -la /home/user")
264
+ print(out)
265
+ ```
266
+
267
+ ### Wait
268
+
269
+ ```python
270
+ from boxfab_desktop import Sandbox
271
+ desktop = Sandbox.create()
272
+
273
+ desktop.wait(1000) # Wait for 1 second
274
+ ```
275
+
276
+ ## Under the hood
277
+
278
+ The desktop-like environment is based on Linux and [Xfce](https://www.xfce.org/) at the moment. We chose Xfce because it's a fast and lightweight environment that's also popular and actively supported. However, this Sandbox template is fully customizable and you can create your own desktop environment.
279
+ BoxFab's template is in [boxfab/desktop](https://github.com/boxfab/desktop/tree/main/template), generated from [e2b-dev/desktop](https://github.com/e2b-dev/desktop/tree/main/template).
280
+
281
+ BoxFab sandboxes are [Firecracker](https://firecracker-microvm.github.io/) microVMs, run on the
282
+ open-source [E2B](https://github.com/e2b-dev/infra) sandbox infrastructure. This package is derived
283
+ from the open-source E2B Desktop SDK;
284
+ [UPSTREAM.md](https://github.com/boxfab/python-sdk/blob/main/UPSTREAM.md) lists every change.
285
+
286
+ ## License
287
+
288
+ MIT, as the E2B Desktop SDK it is derived from; see [LICENSE](LICENSE).
289
+
290
+ BoxFab is a product of ContextFab, Inc.
@@ -0,0 +1,269 @@
1
+ # BoxFab Desktop (Python)
2
+
3
+ A virtual Linux desktop in a [BoxFab](https://boxfab.io) sandbox, for computer-use agents: mouse
4
+ and keyboard control, screenshots, application windows, and a live stream of the screen in the
5
+ browser.
6
+
7
+ ## Getting started
8
+
9
+ ### 1. Get a BoxFab API key
10
+
11
+ Get a key from the [BoxFab dashboard](https://dashboard.boxfab.io) and set it as
12
+ `BOXFAB_API_KEY`. The SDK reads only `BOXFAB_*` variables.
13
+
14
+ ### 2. Install the SDK
15
+
16
+ ```bash
17
+ pip install boxfab-desktop
18
+ ```
19
+
20
+ > **Stream access.** `desktop.stream.start()` runs without a password unless you pass
21
+ > `require_auth=True`. The stream URL contains the sandbox ID, and anyone who has the URL can see
22
+ > and control the desktop. Pass `require_auth=True` whenever the URL leaves your backend; the auth
23
+ > key is then part of the URL returned by `get_url(auth_key=...)`, so share that URL only with the
24
+ > intended viewer.
25
+
26
+ ### 3. Create Desktop Sandbox
27
+
28
+ ```python
29
+ from boxfab_desktop import Sandbox
30
+
31
+ # Create a new desktop sandbox
32
+ desktop = Sandbox.create()
33
+
34
+ # Launch an application
35
+ desktop.launch('google-chrome') # or vscode, firefox, etc.
36
+
37
+ # Wait 10s for the application to open
38
+ desktop.wait(10000)
39
+
40
+ # Stream the application's window
41
+ # Note: There can be only one stream at a time
42
+ # You need to stop the current stream before streaming another application
43
+ desktop.stream.start(
44
+ window_id=desktop.get_current_window_id(), # if not provided the whole desktop will be streamed
45
+ require_auth=True
46
+ )
47
+
48
+ # Get the stream auth key
49
+ auth_key = desktop.stream.get_auth_key()
50
+
51
+ # Print the stream URL
52
+ print('Stream URL:', desktop.stream.get_url(auth_key=auth_key))
53
+
54
+ # Kill the sandbox after the tasks are finished
55
+ # desktop.kill()
56
+ ```
57
+
58
+ ### 4. Bind the configuration to a client
59
+
60
+ The top-level `Sandbox` export reads its configuration from the environment variables. To use an explicit configuration — e.g. several API keys or domains in one process — create a `BoxFab` client and use the resource classes it exposes:
61
+
62
+ ```python
63
+ from boxfab_desktop import BoxFab
64
+
65
+ client = BoxFab(api_key="e2b_***", domain="boxfab.io")
66
+
67
+ desktop = client.Sandbox.create()
68
+ desktop.stream.start()
69
+
70
+ # The core resources are bound to the client's configuration as well.
71
+ volume = client.Volume.create("my-volume")
72
+ exists = client.Template.exists("my-template")
73
+ secret = client.Secret.create("openai-api-key", "sk-***")
74
+
75
+ # The classes can be assigned and used like the top-level ones.
76
+ Sandbox = client.Sandbox
77
+ paginator = Sandbox.list()
78
+ ```
79
+
80
+ Per-call params still take precedence over the client's params, and clients are isolated from each other and from the env-configured top-level exports.
81
+
82
+ ## Features
83
+
84
+ ### Streaming desktop's screen
85
+
86
+ ```python
87
+ from boxfab_desktop import Sandbox
88
+ desktop = Sandbox.create()
89
+
90
+ # Start the stream
91
+ desktop.stream.start()
92
+
93
+ # Get stream URL
94
+ url = desktop.stream.get_url()
95
+ print(url)
96
+
97
+ # Stop the stream
98
+ desktop.stream.stop()
99
+ ```
100
+
101
+ ### Streaming with password protection
102
+
103
+ ```python
104
+ from boxfab_desktop import Sandbox
105
+ desktop = Sandbox.create()
106
+
107
+ # Start the stream
108
+ desktop.stream.start(
109
+ require_auth=True # Enable authentication with an auto-generated key
110
+ )
111
+
112
+ # Retrieve the authentication key
113
+ auth_key = desktop.stream.get_auth_key()
114
+
115
+ # Get stream URL
116
+ url = desktop.stream.get_url(auth_key=auth_key)
117
+ print(url)
118
+
119
+ # Stop the stream
120
+ desktop.stream.stop()
121
+ ```
122
+
123
+ ### Streaming specific application
124
+
125
+ > [!WARNING]
126
+ >
127
+ > - Will raise an error if the desired application is not open yet
128
+ > - The stream will close once the application closes
129
+ > - Creating multiple streams at the same time is not supported, you may have to stop the current stream and start a new one for each application
130
+
131
+ ```python
132
+ from boxfab_desktop import Sandbox
133
+ desktop = Sandbox.create()
134
+
135
+ # Get current (active) window ID
136
+ window_id = desktop.get_current_window_id()
137
+
138
+ # Get all windows of the application
139
+ window_ids = desktop.get_application_windows("Firefox")
140
+
141
+ # Start the stream
142
+ desktop.stream.start(window_id=window_ids[0])
143
+
144
+ # Stop the stream
145
+ desktop.stream.stop()
146
+ ```
147
+
148
+ ### Mouse control
149
+
150
+ ```python
151
+ from boxfab_desktop import Sandbox
152
+ desktop = Sandbox.create()
153
+
154
+ desktop.double_click()
155
+ desktop.left_click()
156
+ desktop.left_click(x=100, y=200)
157
+ desktop.right_click()
158
+ desktop.right_click(x=100, y=200)
159
+ desktop.middle_click()
160
+ desktop.middle_click(x=100, y=200)
161
+ desktop.scroll(10) # Scroll by the amount. Positive for up, negative for down.
162
+ desktop.move_mouse(100, 200) # Move to x, y coordinates
163
+ desktop.drag((100, 100), (200, 200)) # Drag using the mouse
164
+ desktop.mouse_press("left") # Press the mouse button
165
+ desktop.mouse_release("left") # Release the mouse button
166
+ ```
167
+
168
+ ### Keyboard control
169
+
170
+ ```python
171
+ from boxfab_desktop import Sandbox
172
+ desktop = Sandbox.create()
173
+
174
+ # Write text at the current cursor position with customizable typing speed
175
+ desktop.write("Hello, world!") # Default: chunk_size=25, delay_in_ms=75
176
+ desktop.write("Fast typing!", chunk_size=50, delay_in_ms=25) # Faster typing
177
+
178
+ # Press keys
179
+ desktop.press("enter")
180
+ desktop.press("space")
181
+ desktop.press("backspace")
182
+ desktop.press(["ctrl", "c"]) # Key combination
183
+ ```
184
+
185
+ ### Window control
186
+
187
+ ```python
188
+ from boxfab_desktop import Sandbox
189
+ desktop = Sandbox.create()
190
+
191
+ # Get current (active) window ID
192
+ window_id = desktop.get_current_window_id()
193
+
194
+ # Get all windows of the application
195
+ window_ids = desktop.get_application_windows("Firefox")
196
+
197
+ # Get window title
198
+ title = desktop.get_window_title(window_id)
199
+ ```
200
+
201
+ ### Screenshot
202
+
203
+ ```python
204
+ from boxfab_desktop import Sandbox
205
+ desktop = Sandbox.create()
206
+
207
+ # Take a screenshot and save it as "screenshot.png" locally
208
+ image = desktop.screenshot()
209
+ # Save the image to a file
210
+ with open("screenshot.png", "wb") as f:
211
+ f.write(image)
212
+ ```
213
+
214
+ ### Open file
215
+
216
+ ```python
217
+ from boxfab_desktop import Sandbox
218
+ desktop = Sandbox.create()
219
+
220
+ # Open file with default application
221
+ desktop.files.write("/home/user/index.js", "console.log('hello')") # First create the file
222
+ desktop.open("/home/user/index.js") # Then open it
223
+ ```
224
+
225
+ ### Launch applications
226
+
227
+ ```python
228
+ from boxfab_desktop import Sandbox
229
+ desktop = Sandbox.create()
230
+
231
+ # Launch the application
232
+ desktop.launch('google-chrome')
233
+ ```
234
+
235
+ ### Run any bash commands
236
+
237
+ ```python
238
+ from boxfab_desktop import Sandbox
239
+ desktop = Sandbox.create()
240
+
241
+ # Run any bash command
242
+ out = desktop.commands.run("ls -la /home/user")
243
+ print(out)
244
+ ```
245
+
246
+ ### Wait
247
+
248
+ ```python
249
+ from boxfab_desktop import Sandbox
250
+ desktop = Sandbox.create()
251
+
252
+ desktop.wait(1000) # Wait for 1 second
253
+ ```
254
+
255
+ ## Under the hood
256
+
257
+ The desktop-like environment is based on Linux and [Xfce](https://www.xfce.org/) at the moment. We chose Xfce because it's a fast and lightweight environment that's also popular and actively supported. However, this Sandbox template is fully customizable and you can create your own desktop environment.
258
+ BoxFab's template is in [boxfab/desktop](https://github.com/boxfab/desktop/tree/main/template), generated from [e2b-dev/desktop](https://github.com/e2b-dev/desktop/tree/main/template).
259
+
260
+ BoxFab sandboxes are [Firecracker](https://firecracker-microvm.github.io/) microVMs, run on the
261
+ open-source [E2B](https://github.com/e2b-dev/infra) sandbox infrastructure. This package is derived
262
+ from the open-source E2B Desktop SDK;
263
+ [UPSTREAM.md](https://github.com/boxfab/python-sdk/blob/main/UPSTREAM.md) lists every change.
264
+
265
+ ## License
266
+
267
+ MIT, as the E2B Desktop SDK it is derived from; see [LICENSE](LICENSE).
268
+
269
+ BoxFab is a product of ContextFab, Inc.
@@ -0,0 +1,4 @@
1
+ from boxfab import *
2
+
3
+ from .client import BoxFab, BoxFabClientParams, E2B, E2BClientParams
4
+ from .main import Sandbox
@@ -0,0 +1,91 @@
1
+ from typing import Dict, Type, TypeVar, Unpack, cast
2
+
3
+ from boxfab import ApiParams, BoxFabClientParams, E2BClientParams
4
+ from boxfab import BoxFab as CoreBoxFab
5
+ from boxfab_desktop.main import Sandbox
6
+
7
+ T = TypeVar("T")
8
+
9
+ __all__ = ["BoxFab", "BoxFabClientParams", "E2B", "E2BClientParams"]
10
+
11
+
12
+ def _bind(cls: Type[T], api_params: ApiParams) -> Type[T]:
13
+ """Generate a subclass of ``cls`` carrying ``api_params`` as its bound params."""
14
+ return cast(
15
+ Type[T],
16
+ type(cls.__name__, (cls,), {"_bound_api_params": api_params}),
17
+ )
18
+
19
+
20
+ class BoxFab:
21
+ """
22
+ BoxFab client with an explicitly bound connection configuration.
23
+
24
+ The resource classes exposed by the client (`Sandbox`, `Volume`,
25
+ `AsyncVolume`, `Template`, `AsyncTemplate`, `Secret`, `AsyncSecret`) behave
26
+ exactly like the top-level exports of the same name, except the params
27
+ passed to the client are used as the defaults instead of the environment
28
+ variables.
29
+ Per-call params still take precedence over the client's params.
30
+
31
+ Multiple clients are fully isolated from each other and from the top-level
32
+ env-configured exports.
33
+
34
+ Example:
35
+ ```python
36
+ from boxfab_desktop import BoxFab
37
+
38
+ client = BoxFab(api_key="e2b_...", domain="boxfab.io")
39
+
40
+ desktop = client.Sandbox.create()
41
+ desktop.stream.start()
42
+ ```
43
+ """
44
+
45
+ def __init__(self, **opts: Unpack[BoxFabClientParams]):
46
+ """
47
+ Create a new client with the API params bound to it.
48
+
49
+ :param opts: API params used as the defaults for every call made
50
+ through this client's resource classes.
51
+ """
52
+ # Params are copied so later mutations of the caller's dicts cannot
53
+ # change the bound configuration.
54
+ api_params = cast(ApiParams, dict(cast(Dict[str, object], opts)))
55
+
56
+ headers = api_params.get("headers")
57
+ if headers is not None:
58
+ api_params["headers"] = dict(headers)
59
+
60
+ api_headers = api_params.get("api_headers")
61
+ if api_headers is not None:
62
+ api_params["api_headers"] = dict(api_headers)
63
+
64
+ self.Sandbox = _bind(Sandbox, api_params)
65
+ """Desktop `Sandbox` class bound to this client's connection configuration."""
66
+
67
+ # The resources that are not specific to the Desktop are bound by the
68
+ # core client.
69
+ core = CoreBoxFab(**api_params)
70
+
71
+ self.Volume = core.Volume
72
+ """`Volume` class bound to this client's connection configuration."""
73
+
74
+ self.AsyncVolume = core.AsyncVolume
75
+ """`AsyncVolume` class bound to this client's connection configuration."""
76
+
77
+ self.Template = core.Template
78
+ """`Template` class bound to this client's connection configuration."""
79
+
80
+ self.AsyncTemplate = core.AsyncTemplate
81
+ """`AsyncTemplate` class bound to this client's connection configuration."""
82
+
83
+ self.Secret = core.Secret
84
+ """`Secret` class bound to this client's connection configuration."""
85
+
86
+ self.AsyncSecret = core.AsyncSecret
87
+ """`AsyncSecret` class bound to this client's connection configuration."""
88
+
89
+
90
+ # Upstream name, kept so code written for the upstream package ports by changing only the import.
91
+ E2B = BoxFab
@@ -0,0 +1,585 @@
1
+ import logging
2
+ import time
3
+ from re import search as re_search
4
+ from shlex import quote as quote_string
5
+ from typing import (
6
+ Callable,
7
+ Dict,
8
+ Iterator,
9
+ Literal,
10
+ Optional,
11
+ Self,
12
+ Unpack,
13
+ overload,
14
+ Tuple,
15
+ Union,
16
+ )
17
+ from uuid import uuid4
18
+
19
+ from boxfab import (
20
+ Sandbox as SandboxBase,
21
+ CommandHandle,
22
+ CommandResult,
23
+ TimeoutException,
24
+ CommandExitException,
25
+ SandboxIamOpts,
26
+ SandboxLifecycle,
27
+ SandboxNetworkOpts,
28
+ Volume,
29
+ )
30
+ from boxfab.connection_config import ApiParams
31
+
32
+ MOUSE_BUTTONS = {"left": 1, "right": 3, "middle": 2}
33
+
34
+ KEYS = {
35
+ "alt": "Alt_L",
36
+ "alt_left": "Alt_L",
37
+ "alt_right": "Alt_R",
38
+ "backspace": "BackSpace",
39
+ "break": "Pause",
40
+ "caps_lock": "Caps_Lock",
41
+ "cmd": "Super_L",
42
+ "command": "Super_L",
43
+ "control": "Control_L",
44
+ "control_left": "Control_L",
45
+ "control_right": "Control_R",
46
+ "ctrl": "Control_L",
47
+ "del": "Delete",
48
+ "delete": "Delete",
49
+ "down": "Down",
50
+ "end": "End",
51
+ "enter": "Return",
52
+ "esc": "Escape",
53
+ "escape": "Escape",
54
+ "f1": "F1",
55
+ "f2": "F2",
56
+ "f3": "F3",
57
+ "f4": "F4",
58
+ "f5": "F5",
59
+ "f6": "F6",
60
+ "f7": "F7",
61
+ "f8": "F8",
62
+ "f9": "F9",
63
+ "f10": "F10",
64
+ "f11": "F11",
65
+ "f12": "F12",
66
+ "home": "Home",
67
+ "insert": "Insert",
68
+ "left": "Left",
69
+ "menu": "Menu",
70
+ "meta": "Meta_L",
71
+ "num_lock": "Num_Lock",
72
+ "page_down": "Page_Down",
73
+ "page_up": "Page_Up",
74
+ "pause": "Pause",
75
+ "print": "Print",
76
+ "right": "Right",
77
+ "scroll_lock": "Scroll_Lock",
78
+ "shift": "Shift_L",
79
+ "shift_left": "Shift_L",
80
+ "shift_right": "Shift_R",
81
+ "space": "space",
82
+ "super": "Super_L",
83
+ "super_left": "Super_L",
84
+ "super_right": "Super_R",
85
+ "tab": "Tab",
86
+ "up": "Up",
87
+ "win": "Super_L",
88
+ "windows": "Super_L",
89
+ }
90
+
91
+
92
+ def map_key(key: str) -> str:
93
+ lower_key = key.lower()
94
+ if lower_key in KEYS:
95
+ return KEYS[lower_key]
96
+ return lower_key
97
+
98
+
99
+ class _VNCServer:
100
+ def __init__(self, desktop: "Sandbox") -> None:
101
+ self.__novnc_handle: Optional[CommandHandle] = None
102
+
103
+ self._vnc_port = 5900
104
+ self._port = 6080
105
+ self._novnc_auth_enabled = False
106
+ self._novnc_password = None
107
+
108
+ self._url = f"https://{desktop.get_host(self._port)}/vnc.html"
109
+
110
+ self.__desktop = desktop
111
+
112
+ def _wait_for_port(self, port: int) -> bool:
113
+ return self.__desktop._wait_and_verify(
114
+ f'netstat -tuln | grep ":{port} "', lambda r: r.stdout.strip() != ""
115
+ )
116
+
117
+ def _check_vnc_running(self) -> bool:
118
+ try:
119
+ self.__desktop.commands.run("pgrep -x x11vnc")
120
+ return True
121
+ except CommandExitException:
122
+ return False
123
+
124
+ @staticmethod
125
+ def _generate_password(length: int = 16) -> str:
126
+ import secrets
127
+ import string
128
+
129
+ characters = string.ascii_letters + string.digits
130
+ return "".join(secrets.choice(characters) for _ in range(length))
131
+
132
+ def get_url(
133
+ self,
134
+ auto_connect: bool = True,
135
+ view_only: bool = False,
136
+ resize: str = "scale",
137
+ auth_key: Optional[str] = None,
138
+ ) -> str:
139
+ params = []
140
+ if auto_connect:
141
+ params.append("autoconnect=true")
142
+ if view_only:
143
+ params.append("view_only=true")
144
+ if resize:
145
+ params.append(f"resize={resize}")
146
+ if auth_key:
147
+ params.append(f"password={auth_key}")
148
+ if params:
149
+ return f"{self._url}?{'&'.join(params)}"
150
+ return self._url
151
+
152
+ def get_auth_key(self) -> str:
153
+ if not self._novnc_password:
154
+ raise RuntimeError(
155
+ "Unable to retrieve stream auth key, check if require_auth is enabled"
156
+ )
157
+ return self._novnc_password
158
+
159
+ def start(
160
+ self,
161
+ vnc_port: Optional[int] = None,
162
+ port: Optional[int] = None,
163
+ require_auth: bool = False,
164
+ window_id: Optional[str] = None,
165
+ ) -> None:
166
+ # If stream is already running, throw an error
167
+ if self._check_vnc_running():
168
+ raise RuntimeError("Stream is already running")
169
+
170
+ # Update parameters if provided
171
+ self._vnc_port = vnc_port or self._vnc_port
172
+ self._port = port or self._port
173
+ self._novnc_auth_enabled = require_auth or self._novnc_auth_enabled
174
+ self._novnc_password = self._generate_password() if require_auth else None
175
+
176
+ # Update URL with new port
177
+ self._url = f"https://{self.__desktop.get_host(self._port)}/vnc.html"
178
+
179
+ # Set up VNC command
180
+ pwd_flag = "-nopw"
181
+ if self._novnc_auth_enabled:
182
+ self.__desktop.commands.run("mkdir -p ~/.vnc")
183
+ self.__desktop.commands.run(
184
+ f"x11vnc -storepasswd {self._novnc_password} ~/.vnc/passwd"
185
+ )
186
+ pwd_flag = "-usepw"
187
+
188
+ window_id_flag = ""
189
+ if window_id:
190
+ window_id_flag = f"-id {window_id}"
191
+
192
+ vnc_command = (
193
+ f"x11vnc -bg -display {self.__desktop._display} -forever -wait 50 -shared "
194
+ f"-rfbport {self._vnc_port} {pwd_flag} 2>/tmp/x11vnc_stderr.log {window_id_flag}"
195
+ )
196
+
197
+ novnc_command = (
198
+ f"cd /opt/noVNC/utils && ./novnc_proxy --vnc localhost:{self._vnc_port} "
199
+ f"--listen {self._port} --web /opt/noVNC > /tmp/novnc.log 2>&1"
200
+ )
201
+
202
+ self.__desktop.commands.run(vnc_command)
203
+
204
+ self.__novnc_handle = self.__desktop.commands.run(
205
+ novnc_command, background=True, timeout=0
206
+ )
207
+ if not self._wait_for_port(self._port):
208
+ raise TimeoutException("Could not start noVNC server")
209
+
210
+ def stop(self) -> None:
211
+ if self._check_vnc_running():
212
+ self.__desktop.commands.run("pkill x11vnc")
213
+
214
+ if self.__novnc_handle:
215
+ self.__novnc_handle.kill()
216
+ self.__novnc_handle = None
217
+
218
+
219
+ class Sandbox(SandboxBase):
220
+ default_template = "desktop"
221
+ __vnc_server: _VNCServer
222
+ _last_xfce4_pid: Optional[str] = None
223
+ _display: str
224
+
225
+ @classmethod
226
+ def create(
227
+ cls,
228
+ template: Optional[str] = None,
229
+ resolution: Optional[Tuple[int, int]] = None,
230
+ dpi: Optional[int] = None,
231
+ display: Optional[str] = None,
232
+ timeout: Optional[int] = None,
233
+ metadata: Optional[Dict[str, str]] = None,
234
+ envs: Optional[Dict[str, str]] = None,
235
+ secure: Optional[bool] = None,
236
+ allow_internet_access: Optional[bool] = None,
237
+ network: Optional[SandboxNetworkOpts] = None,
238
+ iam: Optional[SandboxIamOpts] = None,
239
+ lifecycle: Optional[SandboxLifecycle] = None,
240
+ volume_mounts: Optional[Dict[str, Union[Volume, str]]] = None,
241
+ logger: Optional[logging.Logger] = None,
242
+ **opts: Unpack[ApiParams],
243
+ ) -> Self:
244
+ """
245
+ Create a new sandbox.
246
+
247
+ By default, the sandbox is created from the default `desktop` sandbox template.
248
+
249
+
250
+ :param template: Sandbox template name or ID
251
+ :param resolution: Startup the desktop with custom screen resolution. Defaults to (1024, 768)
252
+ :param dpi: Startup the desktop with custom DPI. Defaults to 96
253
+ :param display: Startup the desktop with custom display. Defaults to ":0"
254
+ :param timeout: Timeout for the sandbox in **seconds**, default to 300 seconds. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users.
255
+ :param metadata: Custom metadata for the sandbox
256
+ :param envs: Custom environment variables for the sandbox
257
+ :param secure: Deprecated — every sandbox secures envd access; accepted for backward compatibility and ignored
258
+ :param allow_internet_access: Allow sandbox to access the internet
259
+
260
+ :return: A Sandbox instance for the new sandbox
261
+
262
+ Use this method instead of using the constructor to create a new sandbox.
263
+ """
264
+
265
+ # Initialize environment variables with DISPLAY
266
+ display = display or ":0"
267
+ if envs is None:
268
+ envs = {}
269
+ envs["DISPLAY"] = display
270
+
271
+ sbx = super().create(
272
+ template=template,
273
+ timeout=timeout,
274
+ metadata=metadata,
275
+ envs=envs,
276
+ allow_internet_access=allow_internet_access,
277
+ network=network,
278
+ iam=iam,
279
+ lifecycle=lifecycle,
280
+ volume_mounts=volume_mounts,
281
+ logger=logger,
282
+ **opts,
283
+ )
284
+
285
+ try:
286
+ sbx._display = display
287
+ width, height = resolution or (1024, 768)
288
+ xvfb_handle = sbx.commands.run(
289
+ f"Xvfb {display} -ac -screen 0 {width}x{height}x24"
290
+ f" -retro -dpi {dpi or 96} -nolisten tcp -nolisten unix",
291
+ background=True,
292
+ timeout=0,
293
+ )
294
+ xvfb_handle.disconnect()
295
+
296
+ if not sbx._wait_and_verify(
297
+ f"xdpyinfo -display {display}", lambda r: r.exit_code == 0
298
+ ):
299
+ raise TimeoutException("Could not start Xvfb")
300
+
301
+ sbx.__vnc_server = _VNCServer(sbx)
302
+ sbx._start_xfce4()
303
+ except Exception:
304
+ try:
305
+ sbx.kill()
306
+ except Exception:
307
+ pass
308
+ raise
309
+
310
+ return sbx
311
+
312
+ def _wait_and_verify(
313
+ self,
314
+ cmd: str,
315
+ on_result: Callable[[CommandResult], bool],
316
+ timeout: int = 10,
317
+ interval: float = 0.5,
318
+ ) -> bool:
319
+ elapsed = 0
320
+ while elapsed < timeout:
321
+ try:
322
+ if on_result(self.commands.run(cmd)):
323
+ return True
324
+ except CommandExitException:
325
+ pass
326
+
327
+ time.sleep(interval)
328
+ elapsed += interval
329
+
330
+ return False
331
+
332
+ def _start_xfce4(self):
333
+ """
334
+ Start xfce4 session if logged out or not running.
335
+ """
336
+ if self._last_xfce4_pid is None or "[xfce4-session] <defunct>" in (
337
+ self.commands.run(
338
+ f"ps aux | grep {self._last_xfce4_pid} | grep -v grep | head -n 1"
339
+ ).stdout.strip()
340
+ ):
341
+ xfce4_handle = self.commands.run("startxfce4", background=True, timeout=0)
342
+ self._last_xfce4_pid = xfce4_handle.pid
343
+ xfce4_handle.disconnect()
344
+
345
+ if not self._wait_and_verify(
346
+ "pgrep -x xfce4-session >/dev/null && pgrep -x xfwm4 >/dev/null && pgrep -x xfdesktop >/dev/null",
347
+ lambda r: r.exit_code == 0,
348
+ 60,
349
+ ):
350
+ raise TimeoutException("Could not start XFCE")
351
+
352
+ @property
353
+ def stream(self) -> _VNCServer:
354
+ return self.__vnc_server
355
+
356
+ @overload
357
+ def screenshot(self, format: Literal["stream"]) -> Iterator[bytes]:
358
+ """
359
+ Take a screenshot and return it as a stream of bytes.
360
+ """
361
+
362
+ @overload
363
+ def screenshot(
364
+ self,
365
+ format: Literal["bytes"] = "bytes",
366
+ ) -> bytearray:
367
+ """
368
+ Take a screenshot and return it as a bytearray.
369
+ """
370
+
371
+ def screenshot(
372
+ self,
373
+ format: Literal["bytes", "stream"] = "bytes",
374
+ ):
375
+ """
376
+ Take a screenshot and return it in the specified format.
377
+
378
+ :param format: The format of the screenshot. Can be 'bytes', 'blob', or 'stream'.
379
+ :returns: The screenshot in the specified format.
380
+ """
381
+ screenshot_path = f"/tmp/screenshot-{uuid4()}.png"
382
+
383
+ self.commands.run(f"scrot --pointer {screenshot_path}")
384
+
385
+ file = self.files.read(screenshot_path, format=format)
386
+ self.files.remove(screenshot_path)
387
+ return file
388
+
389
+ def left_click(self, x: Optional[int] = None, y: Optional[int] = None):
390
+ """
391
+ Left click on the mouse position.
392
+ """
393
+ if x and y:
394
+ self.move_mouse(x, y)
395
+ self.commands.run("xdotool click 1")
396
+
397
+ def double_click(self, x: Optional[int] = None, y: Optional[int] = None):
398
+ """
399
+ Double left click on the mouse position.
400
+ """
401
+ if x and y:
402
+ self.move_mouse(x, y)
403
+ self.commands.run("xdotool click --repeat 2 1")
404
+
405
+ def right_click(self, x: Optional[int] = None, y: Optional[int] = None):
406
+ if (x is None) != (y is None):
407
+ raise ValueError("Both x and y must be provided together")
408
+ """
409
+ Right click on the mouse position.
410
+ """
411
+ if x and y:
412
+ self.move_mouse(x, y)
413
+ self.commands.run("xdotool click 3")
414
+
415
+ def middle_click(self, x: Optional[int] = None, y: Optional[int] = None):
416
+ """
417
+ Middle click on the mouse position.
418
+ """
419
+ if x and y:
420
+ self.move_mouse(x, y)
421
+ self.commands.run("xdotool click 2")
422
+
423
+ def scroll(self, direction: Literal["up", "down"] = "down", amount: int = 1):
424
+ """
425
+ Scroll the mouse wheel by the given amount.
426
+
427
+ :param direction: The direction to scroll. Can be "up" or "down".
428
+ :param amount: The amount to scroll.
429
+ """
430
+ self.commands.run(
431
+ f"xdotool click --repeat {amount} {'4' if direction == 'up' else '5'}"
432
+ )
433
+
434
+ def move_mouse(self, x: int, y: int):
435
+ """
436
+ Move the mouse to the given coordinates.
437
+
438
+ :param x: The x coordinate.
439
+ :param y: The y coordinate.
440
+ """
441
+ self.commands.run(f"xdotool mousemove --sync {x} {y}")
442
+
443
+ def mouse_press(self, button: Literal["left", "right", "middle"] = "left"):
444
+ """
445
+ Press the mouse button.
446
+ """
447
+ self.commands.run(f"xdotool mousedown {MOUSE_BUTTONS[button]}")
448
+
449
+ def mouse_release(self, button: Literal["left", "right", "middle"] = "left"):
450
+ """
451
+ Release the mouse button.
452
+ """
453
+ self.commands.run(f"xdotool mouseup {MOUSE_BUTTONS[button]}")
454
+
455
+ def get_cursor_position(self) -> tuple[int, int]:
456
+ """
457
+ Get the current cursor position.
458
+
459
+ :return: A tuple with the x and y coordinates
460
+ :raises RuntimeError: If the cursor position cannot be determined
461
+ """
462
+ result = self.commands.run("xdotool getmouselocation")
463
+
464
+ groups = re_search(r"x:(\d+)\s+y:(\d+)", result.stdout)
465
+ if not groups:
466
+ raise RuntimeError(
467
+ f"Failed to parse cursor position from output: {result.stdout}"
468
+ )
469
+
470
+ x, y = groups.group(1), groups.group(2)
471
+ if not x or not y:
472
+ raise RuntimeError(f"Invalid cursor position values: x={x}, y={y}")
473
+
474
+ return int(x), int(y)
475
+
476
+ def get_screen_size(self) -> tuple[int, int]:
477
+ """
478
+ Get the current screen size.
479
+
480
+ :return: A tuple with the width and height
481
+ :raises RuntimeError: If the screen size cannot be determined
482
+ """
483
+ result = self.commands.run("xrandr")
484
+
485
+ _match = re_search(r"(\d+x\d+)", result.stdout)
486
+ if not _match:
487
+ raise RuntimeError(
488
+ f"Failed to parse screen size from output: {result.stdout}"
489
+ )
490
+
491
+ try:
492
+ return tuple(map(int, _match.group(1).split("x"))) # type: ignore
493
+ except (ValueError, IndexError) as e:
494
+ raise RuntimeError(f"Invalid screen size format: {_match.group(1)}") from e
495
+
496
+ def write(self, text: str, *, chunk_size: int = 25, delay_in_ms: int = 75) -> None:
497
+ """
498
+ Write the given text at the current cursor position.
499
+
500
+ :param text: The text to write.
501
+ :param chunk_size: The size of each chunk of text to write.
502
+ :param delay_in_ms: The delay between each chunk of text.
503
+ """
504
+
505
+ def break_into_chunks(text: str, n: int):
506
+ for i in range(0, len(text), n):
507
+ yield text[i : i + n]
508
+
509
+ for chunk in break_into_chunks(text, chunk_size):
510
+ self.commands.run(
511
+ f"xdotool type --delay {delay_in_ms} -- {quote_string(chunk)}"
512
+ )
513
+
514
+ def press(self, key: Union[str, list[str]]):
515
+ """
516
+ Press a key.
517
+
518
+ :param key: The key to press (e.g. "enter", "space", "backspace", etc.).
519
+ """
520
+ if isinstance(key, list):
521
+ key = "+".join(map_key(k) for k in key)
522
+ else:
523
+ key = map_key(key)
524
+
525
+ self.commands.run(f"xdotool key {key}")
526
+
527
+ def drag(self, fr: tuple[int, int], to: tuple[int, int]):
528
+ """
529
+ Drag the mouse from the given position to the given position.
530
+
531
+ :param from: The starting position.
532
+ :param to: The ending position.
533
+ """
534
+ self.move_mouse(fr[0], fr[1])
535
+ self.mouse_press()
536
+ self.move_mouse(to[0], to[1])
537
+ self.mouse_release()
538
+
539
+ def wait(self, ms: int):
540
+ """
541
+ Wait for the given amount of time.
542
+
543
+ :param ms: The amount of time to wait in milliseconds.
544
+ """
545
+ self.commands.run(f"sleep {ms / 1000}")
546
+
547
+ def open(self, file_or_url: str):
548
+ """
549
+ Open a file or a URL in the default application.
550
+
551
+ :param file_or_url: The file or URL to open.
552
+ """
553
+ handle = self.commands.run(f"xdg-open {file_or_url}", background=True)
554
+ handle.disconnect()
555
+
556
+ def get_current_window_id(self) -> str:
557
+ """
558
+ Get the current window ID.
559
+ """
560
+ return self.commands.run("xdotool getwindowfocus").stdout.strip()
561
+
562
+ def get_application_windows(self, application: str) -> list[str]:
563
+ """
564
+ Get the window IDs of all windows for the given application.
565
+ """
566
+ return (
567
+ self.commands.run(f"xdotool search --onlyvisible --class {application}")
568
+ .stdout.strip()
569
+ .split("\n")
570
+ )
571
+
572
+ def get_window_title(self, window_id: str) -> str:
573
+ """
574
+ Get the title of the window with the given ID.
575
+ """
576
+ return self.commands.run(f"xdotool getwindowname {window_id}").stdout.strip()
577
+
578
+ def launch(self, application: str, uri: Optional[str] = None):
579
+ """
580
+ Launch an application.
581
+ """
582
+ handle = self.commands.run(
583
+ f"gtk-launch {application} {uri or ''}", background=True, timeout=0
584
+ )
585
+ handle.disconnect()
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "boxfab-desktop"
3
+ version = "0.1.0a1"
4
+ description = "BoxFab Desktop: a virtual Linux desktop in a BoxFab sandbox, for computer-use agents"
5
+ license = "MIT"
6
+ license-files = ["LICENSE"]
7
+ readme = "README.md"
8
+ requires-python = ">=3.11"
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ ]
17
+ dependencies = [
18
+ "boxfab>=0.1.0a3,<0.2",
19
+ "requests>=2.32.3,<3",
20
+ "urllib3>=2.8.0,<3",
21
+ "pillow>=13.0.0,<14",
22
+ ]
23
+
24
+ [[project.authors]]
25
+ name = "ContextFab, Inc."
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/boxfab/python-sdk"
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "pytest>=9.0.3,<10",
33
+ "python-dotenv>=1.0.0,<2",
34
+ "pytest-dotenv>=0.5.2,<0.6",
35
+ "pytest-xdist>=3.6.1,<4",
36
+ ]
37
+
38
+ [tool.uv.sources.boxfab]
39
+ path = ".."
40
+ editable = true
41
+
42
+ [tool.uv.build-backend]
43
+ module-name = ["boxfab_desktop"]
44
+ module-root = ""
45
+ source-exclude = ["tests"]
46
+
47
+ [build-system]
48
+ requires = ["uv_build>=0.13.4,<0.14.0"]
49
+ build-backend = "uv_build"
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "boxfab-desktop"
3
+ version = "0.1.0a1"
4
+ description = "BoxFab Desktop: a virtual Linux desktop in a BoxFab sandbox, for computer-use agents"
5
+ authors = [{ name = "ContextFab, Inc." }]
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ readme = "README.md"
9
+ requires-python = ">=3.11"
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ ]
18
+ # The core SDK in this repository is generated from the same upstream commit. The other ranges
19
+ # are upstream's (e2b-desktop 2.7.2).
20
+ dependencies = [
21
+ "boxfab>=0.1.0a3,<0.2",
22
+ "requests>=2.32.3,<3",
23
+ "urllib3>=2.8.0,<3",
24
+ "pillow>=13.0.0,<14",
25
+ ]
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/boxfab/python-sdk"
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "pytest>=9.0.3,<10",
33
+ "python-dotenv>=1.0.0,<2",
34
+ "pytest-dotenv>=0.5.2,<0.6",
35
+ "pytest-xdist>=3.6.1,<4",
36
+ ]
37
+
38
+ # Development uses the core SDK from this repository; the published package depends on the
39
+ # released `boxfab` (range above).
40
+ [tool.uv.sources]
41
+ boxfab = { path = "..", editable = true }
42
+
43
+ [build-system]
44
+ requires = ["uv_build>=0.13.4,<0.14.0"]
45
+ build-backend = "uv_build"
46
+
47
+ [tool.uv.build-backend]
48
+ module-name = ["boxfab_desktop"]
49
+ module-root = ""
50
+ source-exclude = ["tests"]