dothedev-sandbox 0.1.0__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.
@@ -0,0 +1,5 @@
1
+ """Deep agents backend for DoTheDev Agent Platform sandboxes (docs/sandboxes.md in the platform repo)."""
2
+
3
+ from dothedev_sandbox.client import DoTheDevSandbox, SandboxClient, SandboxError, ThreadSandbox, sandbox_for_thread
4
+
5
+ __all__ = ["DoTheDevSandbox", "SandboxClient", "SandboxError", "ThreadSandbox", "sandbox_for_thread"]
@@ -0,0 +1,455 @@
1
+ """A deep agents sandbox backend that runs on the DoTheDev Agent Platform.
2
+
3
+ The platform gives each deployed revision `DOTHEDEV_SANDBOX_URL` and `DOTHEDEV_SANDBOX_TOKEN`. The token
4
+ stays in the agent server: commands and files go to the platform, which runs them in a locked-down
5
+ container with no secrets and, by default, no network.
6
+
7
+ from deepagents import create_deep_agent
8
+ from dothedev_sandbox import ThreadSandbox
9
+
10
+ agent = create_deep_agent(model=..., backend=ThreadSandbox())
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import base64
17
+ import os
18
+ import threading
19
+ from collections import OrderedDict
20
+ from typing import Any
21
+
22
+ import httpx
23
+ from deepagents.backends.protocol import ExecuteResponse, FileDownloadResponse, FileUploadResponse
24
+ from deepagents.backends.sandbox import BaseSandbox
25
+
26
+ # Files per upload or download request; the platform takes at most 100
27
+ BATCH = 100
28
+ # Added to a command's own time limit for the HTTP request carrying it
29
+ SLACK_SECONDS = 30
30
+ # Sandboxes `sandbox_for_thread` remembers per process
31
+ CACHED_THREADS = 256
32
+
33
+
34
+ class SandboxError(RuntimeError):
35
+ """The platform refused or failed a sandbox call.
36
+
37
+ Attributes:
38
+ status_code: The HTTP status the platform answered with.
39
+ """
40
+
41
+ def __init__(self, status_code: int, message: str) -> None:
42
+ super().__init__(f"Sandbox API {status_code}: {message}")
43
+ self.status_code = status_code
44
+
45
+
46
+ def _detail(response: httpx.Response) -> str:
47
+ try:
48
+ detail = response.json().get("detail")
49
+ except ValueError:
50
+ detail = None
51
+ return str(detail or response.text or response.reason_phrase)
52
+
53
+
54
+ def _checked(response: httpx.Response) -> dict[str, Any]:
55
+ if response.status_code >= 400:
56
+ raise SandboxError(response.status_code, _detail(response))
57
+ return response.json() if response.content else {}
58
+
59
+
60
+ class SandboxClient:
61
+ """Talks to the platform's sandbox API as one deployment.
62
+
63
+ Args:
64
+ url: The platform API's base URL. Defaults to `DOTHEDEV_SANDBOX_URL`.
65
+ token: The deployment's sandbox token. Defaults to `DOTHEDEV_SANDBOX_TOKEN`.
66
+ default_timeout: Seconds a command may run when the caller gives no limit; None leaves it to the
67
+ platform's default.
68
+ transport: An httpx transport for the synchronous client (tests use a mock one).
69
+ async_transport: An httpx transport for the asynchronous client.
70
+
71
+ Raises:
72
+ ValueError: If no URL or token is given or set in the environment.
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ url: str | None = None,
78
+ token: str | None = None,
79
+ *,
80
+ default_timeout: int | None = None,
81
+ transport: httpx.BaseTransport | None = None,
82
+ async_transport: httpx.AsyncBaseTransport | None = None,
83
+ ) -> None:
84
+ url = url or os.environ.get("DOTHEDEV_SANDBOX_URL")
85
+ token = token or os.environ.get("DOTHEDEV_SANDBOX_TOKEN")
86
+ if not url or not token:
87
+ raise ValueError(
88
+ "Set DOTHEDEV_SANDBOX_URL and DOTHEDEV_SANDBOX_TOKEN (the platform sets both on deployed agents)"
89
+ )
90
+ self.base_url = f"{url.rstrip('/')}/sandbox/v1"
91
+ self.default_timeout = default_timeout
92
+ self._headers = {"authorization": f"Bearer {token}"}
93
+ self._transport = transport
94
+ self._async_transport = async_transport
95
+ self._http = httpx.Client(base_url=self.base_url, headers=self._headers, timeout=60, transport=transport)
96
+ self._async_http: httpx.AsyncClient | None = None
97
+
98
+ def _async(self) -> httpx.AsyncClient:
99
+ """The async client, made on first use; it belongs to the event loop that first used it."""
100
+ if self._async_http is None:
101
+ self._async_http = httpx.AsyncClient(
102
+ base_url=self.base_url, headers=self._headers, timeout=60, transport=self._async_transport
103
+ )
104
+ return self._async_http
105
+
106
+ def request(self, method: str, path: str, *, timeout: float | None = None, **kwargs: Any) -> dict[str, Any]:
107
+ """Send one request and return its JSON body.
108
+
109
+ Args:
110
+ method: The HTTP method.
111
+ path: The path under `/sandbox/v1`.
112
+ timeout: Seconds to wait for the answer; the client's default when None.
113
+ **kwargs: Passed on to httpx (`json`, `params`).
114
+
115
+ Returns:
116
+ The decoded JSON body; an empty dict for an empty one.
117
+
118
+ Raises:
119
+ SandboxError: If the platform answers with an error status.
120
+ """
121
+ options: dict[str, Any] = {"timeout": timeout} if timeout is not None else {}
122
+ return _checked(self._http.request(method, path, **options, **kwargs))
123
+
124
+ async def arequest(self, method: str, path: str, *, timeout: float | None = None, **kwargs: Any) -> dict[str, Any]:
125
+ """Async version of `request`.
126
+
127
+ Args:
128
+ method: The HTTP method.
129
+ path: The path under `/sandbox/v1`.
130
+ timeout: Seconds to wait for the answer; the client's default when None.
131
+ **kwargs: Passed on to httpx (`json`, `params`).
132
+
133
+ Returns:
134
+ The decoded JSON body; an empty dict for an empty one.
135
+
136
+ Raises:
137
+ SandboxError: If the platform answers with an error status.
138
+ """
139
+ options: dict[str, Any] = {"timeout": timeout} if timeout is not None else {}
140
+ return _checked(await self._async().request(method, path, **options, **kwargs))
141
+
142
+ def get_or_create(self, key: str, *, template: str | None = None, thread_id: str | None = None) -> DoTheDevSandbox:
143
+ """The deployment's sandbox with this key, created if new and running when this returns.
144
+
145
+ Args:
146
+ key: The agent's name for it, unique per deployment: `thread:<thread id>` for one per
147
+ conversation, anything else for a shared one.
148
+ template: A template the platform offers; its default when None.
149
+ thread_id: The thread it belongs to, so the console shows it on that thread.
150
+
151
+ Returns:
152
+ A deep agents backend for the sandbox.
153
+
154
+ Raises:
155
+ SandboxError: If the platform refuses (unknown template, org limit reached) or can't start it.
156
+ """
157
+ body = {"key": key, "template": template, "thread_id": thread_id}
158
+ info = self.request("POST", "/sandboxes", json=body)
159
+ return DoTheDevSandbox(self, info, template=template)
160
+
161
+ def close(self) -> None:
162
+ """Close the synchronous client's connections."""
163
+ self._http.close()
164
+
165
+ async def aclose(self) -> None:
166
+ """Close both clients' connections."""
167
+ self._http.close()
168
+ if self._async_http is not None:
169
+ await self._async_http.aclose()
170
+ self._async_http = None
171
+
172
+
173
+ class DoTheDevSandbox(BaseSandbox):
174
+ """One platform sandbox as a deep agents backend.
175
+
176
+ Files, `ls`, `grep`, `glob` and edits come from `BaseSandbox`, built on `execute`. A sandbox the platform
177
+ removed (after its retention period, or by a member) is got again under the same key, empty, and the
178
+ call retried once.
179
+
180
+ Args:
181
+ client: The client to call the platform with.
182
+ info: The sandbox as the platform returned it.
183
+ template: The template it was asked for, used when it has to be got again.
184
+ """
185
+
186
+ def __init__(self, client: SandboxClient, info: dict[str, Any], *, template: str | None = None) -> None:
187
+ self._client = client
188
+ self._info = info
189
+ self._template = template
190
+
191
+ @property
192
+ def id(self) -> str:
193
+ """The platform's id for the sandbox."""
194
+ return str(self._info["id"])
195
+
196
+ @property
197
+ def key(self) -> str:
198
+ """The key the sandbox was got by."""
199
+ return str(self._info["key"])
200
+
201
+ def _renew(self) -> None:
202
+ body = {"key": self.key, "template": self._template, "thread_id": self._info.get("thread_id")}
203
+ self._info = self._client.request("POST", "/sandboxes", json=body)
204
+
205
+ async def _arenew(self) -> None:
206
+ body = {"key": self.key, "template": self._template, "thread_id": self._info.get("thread_id")}
207
+ self._info = await self._client.arequest("POST", "/sandboxes", json=body)
208
+
209
+ def _call(self, method: str, suffix: str, *, timeout: float | None = None, **kwargs: Any) -> dict[str, Any]:
210
+ try:
211
+ return self._client.request(method, f"/sandboxes/{self.id}{suffix}", timeout=timeout, **kwargs)
212
+ except SandboxError as error:
213
+ if error.status_code != 404:
214
+ raise
215
+ self._renew()
216
+ return self._client.request(method, f"/sandboxes/{self.id}{suffix}", timeout=timeout, **kwargs)
217
+
218
+ async def _acall(self, method: str, suffix: str, *, timeout: float | None = None, **kwargs: Any) -> dict[str, Any]:
219
+ try:
220
+ return await self._client.arequest(method, f"/sandboxes/{self.id}{suffix}", timeout=timeout, **kwargs)
221
+ except SandboxError as error:
222
+ if error.status_code != 404:
223
+ raise
224
+ await self._arenew()
225
+ return await self._client.arequest(method, f"/sandboxes/{self.id}{suffix}", timeout=timeout, **kwargs)
226
+
227
+ def _exec_request(self, command: str, timeout: int | None) -> tuple[dict[str, Any], float | None]:
228
+ limit = timeout if timeout is not None else self._client.default_timeout
229
+ body: dict[str, Any] = {"command": command}
230
+ if limit:
231
+ body["timeout"] = limit
232
+ # Without a limit the platform's default applies (at most 30 minutes): wait as long as it may
233
+ wait = (limit or 1800) + SLACK_SECONDS
234
+ return body, wait
235
+
236
+ @staticmethod
237
+ def _response(result: dict[str, Any]) -> ExecuteResponse:
238
+ output = str(result.get("output", ""))
239
+ # Deep agents notes truncation itself, from `truncated`; it has no field for a timeout
240
+ if result.get("timed_out"):
241
+ output += "\n[Command timed out and was stopped]"
242
+ return ExecuteResponse(
243
+ output=output, exit_code=result.get("exit_code"), truncated=bool(result.get("truncated"))
244
+ )
245
+
246
+ def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
247
+ """Run a shell command in the sandbox's workspace (`/app`).
248
+
249
+ Args:
250
+ command: The shell command, run with `sh -c`.
251
+ timeout: Seconds before the command is stopped; the client's or platform's default when None.
252
+
253
+ Returns:
254
+ Combined stdout and stderr, the exit code, and whether output was cut.
255
+ """
256
+ body, wait = self._exec_request(command, timeout)
257
+ return self._response(self._call("POST", "/exec", json=body, timeout=wait))
258
+
259
+ async def aexecute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: # noqa: ASYNC109
260
+ """Async version of `execute`, on the client's async connection pool.
261
+
262
+ Args:
263
+ command: The shell command, run with `sh -c`.
264
+ timeout: Seconds before the command is stopped; the client's or platform's default when None.
265
+
266
+ Returns:
267
+ Combined stdout and stderr, the exit code, and whether output was cut.
268
+ """
269
+ body, wait = self._exec_request(command, timeout)
270
+ return self._response(await self._acall("POST", "/exec", json=body, timeout=wait))
271
+
272
+ def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
273
+ """Write files into the sandbox, creating parent directories.
274
+
275
+ Args:
276
+ files: (absolute path, content) pairs.
277
+
278
+ Returns:
279
+ One response per file, in order; `error` is set for the ones that failed.
280
+ """
281
+ responses: list[FileUploadResponse] = []
282
+ for start in range(0, len(files), BATCH):
283
+ batch = files[start : start + BATCH]
284
+ body = {"files": [{"path": path, "content": base64.b64encode(content).decode()} for path, content in batch]}
285
+ try:
286
+ results = self._call("POST", "/files/upload", json=body)["results"]
287
+ except SandboxError as error:
288
+ responses += [FileUploadResponse(path=path, error=str(error)) for path, _ in batch]
289
+ continue
290
+ responses += [FileUploadResponse(path=item["path"], error=item["error"]) for item in results]
291
+ return responses
292
+
293
+ def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
294
+ """Read files from the sandbox.
295
+
296
+ Args:
297
+ paths: Absolute paths.
298
+
299
+ Returns:
300
+ One response per path, in order, with `content` or `error`.
301
+ """
302
+ responses: list[FileDownloadResponse] = []
303
+ for start in range(0, len(paths), BATCH):
304
+ batch = paths[start : start + BATCH]
305
+ try:
306
+ results = self._call("POST", "/files/download", json={"paths": batch})["results"]
307
+ except SandboxError as error:
308
+ responses += [FileDownloadResponse(path=path, content=None, error=str(error)) for path in batch]
309
+ continue
310
+ responses += [
311
+ FileDownloadResponse(
312
+ path=item["path"],
313
+ content=base64.b64decode(item["content"]) if item["content"] is not None else None,
314
+ error=item["error"],
315
+ )
316
+ for item in results
317
+ ]
318
+ return responses
319
+
320
+ def destroy(self) -> None:
321
+ """Remove the sandbox and its files. The next call with its key gets a new, empty one."""
322
+ try:
323
+ self._client.request("DELETE", f"/sandboxes/{self.id}")
324
+ except SandboxError as error:
325
+ if error.status_code != 404:
326
+ raise
327
+
328
+
329
+ _lock = threading.Lock()
330
+ _client: SandboxClient | None = None
331
+ _threads: OrderedDict[tuple[str, str | None], DoTheDevSandbox] = OrderedDict()
332
+
333
+
334
+ def _shared_client() -> SandboxClient:
335
+ global _client
336
+ with _lock:
337
+ if _client is None:
338
+ _client = SandboxClient()
339
+ return _client
340
+
341
+
342
+ def sandbox_for_thread(thread_id: str | None = None, *, template: str | None = None) -> DoTheDevSandbox:
343
+ """The current thread's sandbox: one per conversation, kept across turns.
344
+
345
+ `ThreadSandbox` calls this for each command; call it directly for a sandbox outside the agent loop.
346
+ The platform resumes a stopped sandbox on its next command, so a remembered one stays usable.
347
+
348
+ Args:
349
+ thread_id: The thread; read from the run's config (`configurable.thread_id`) when None.
350
+ template: A template the platform offers; its default when None.
351
+
352
+ Returns:
353
+ The thread's sandbox backend.
354
+
355
+ Raises:
356
+ ValueError: If there is no thread id (the agent must run on a thread).
357
+ SandboxError: If the platform refuses or can't start the sandbox.
358
+ """
359
+ if thread_id is None:
360
+ from langgraph.config import get_config
361
+
362
+ thread_id = get_config().get("configurable", {}).get("thread_id")
363
+ if not thread_id:
364
+ raise ValueError("No thread_id: run the agent on a thread to give it a sandbox")
365
+ cache_key = (str(thread_id), template)
366
+ with _lock:
367
+ cached = _threads.get(cache_key)
368
+ if cached is not None:
369
+ _threads.move_to_end(cache_key)
370
+ return cached
371
+ sandbox = _shared_client().get_or_create(f"thread:{thread_id}", template=template, thread_id=str(thread_id))
372
+ with _lock:
373
+ _threads[cache_key] = sandbox
374
+ while len(_threads) > CACHED_THREADS:
375
+ _threads.popitem(last=False)
376
+ return sandbox
377
+
378
+
379
+ class ThreadSandbox(BaseSandbox):
380
+ """A deep agents backend that runs each call in the current thread's sandbox.
381
+
382
+ Deep agents 0.7 takes a backend instance, not a factory, so the agent is built once with this and every
383
+ command and file call is sent to `sandbox_for_thread()` for the run's `configurable.thread_id`: one
384
+ sandbox per conversation, got on its first call.
385
+
386
+ agent = create_deep_agent(model=..., backend=ThreadSandbox())
387
+
388
+ Args:
389
+ template: A template the platform offers; its default when None.
390
+ """
391
+
392
+ def __init__(self, *, template: str | None = None) -> None:
393
+ self._template = template
394
+
395
+ def _sandbox(self) -> DoTheDevSandbox:
396
+ return sandbox_for_thread(template=self._template)
397
+
398
+ @property
399
+ def id(self) -> str:
400
+ """The platform's id for the current thread's sandbox."""
401
+ return self._sandbox().id
402
+
403
+ def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
404
+ """Run a shell command in the current thread's sandbox.
405
+
406
+ Args:
407
+ command: The shell command, run with `sh -c`.
408
+ timeout: Seconds before the command is stopped; the client's or platform's default when None.
409
+
410
+ Returns:
411
+ Combined stdout and stderr, the exit code, and whether output was cut.
412
+
413
+ Raises:
414
+ RuntimeError: If called outside a run, so there is no thread.
415
+ """
416
+ return self._sandbox().execute(command, timeout=timeout)
417
+
418
+ async def aexecute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: # noqa: ASYNC109
419
+ """Async version of `execute`; the sandbox is got off the event loop.
420
+
421
+ Args:
422
+ command: The shell command, run with `sh -c`.
423
+ timeout: Seconds before the command is stopped; the client's or platform's default when None.
424
+
425
+ Returns:
426
+ Combined stdout and stderr, the exit code, and whether output was cut.
427
+
428
+ Raises:
429
+ RuntimeError: If called outside a run, so there is no thread.
430
+ """
431
+ # to_thread copies the run's context, so the thread id is still there
432
+ sandbox = await asyncio.to_thread(self._sandbox)
433
+ return await sandbox.aexecute(command, timeout=timeout)
434
+
435
+ def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
436
+ """Write files into the current thread's sandbox, creating parent directories.
437
+
438
+ Args:
439
+ files: (absolute path, content) pairs.
440
+
441
+ Returns:
442
+ One response per file, in order; `error` is set for the ones that failed.
443
+ """
444
+ return self._sandbox().upload_files(files)
445
+
446
+ def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
447
+ """Read files from the current thread's sandbox.
448
+
449
+ Args:
450
+ paths: Absolute paths.
451
+
452
+ Returns:
453
+ One response per path, in order, with `content` or `error`.
454
+ """
455
+ return self._sandbox().download_files(paths)
File without changes
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.5
2
+ Name: dothedev-sandbox
3
+ Version: 0.1.0
4
+ Summary: Deep agents sandbox backend for DoTheDev Agent Platform sandboxes
5
+ Author: shahraiz ali
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: agents,deepagents,langchain,langgraph,sandbox
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: deepagents>=0.7.28
18
+ Requires-Dist: httpx>=0.28.1
19
+ Description-Content-Type: text/markdown
20
+
21
+ # dothedev-sandbox
22
+
23
+ A [deep agents](https://docs.langchain.com/oss/python/deepagents/sandboxes) sandbox backend for agents
24
+ deployed on the DoTheDev Agent Platform. The platform runs each sandbox in a locked-down container (no
25
+ secrets, no network unless the template allows it) and gives deployed agents `DOTHEDEV_SANDBOX_URL` and
26
+ `DOTHEDEV_SANDBOX_TOKEN`. The token stays in the agent server; only commands and files reach the sandbox.
27
+
28
+ ```bash
29
+ pip install dothedev-sandbox
30
+ ```
31
+
32
+ ```python
33
+ from deepagents import create_deep_agent
34
+ from dothedev_sandbox import ThreadSandbox
35
+
36
+ # One sandbox per conversation, kept across turns
37
+ agent = create_deep_agent(
38
+ model="openai:gpt-4o-mini",
39
+ backend=ThreadSandbox(),
40
+ )
41
+ ```
42
+
43
+ `ThreadSandbox` sends each command and file call to the sandbox of the run's `configurable.thread_id`,
44
+ created on first use. Pass `template=` to pick one of the templates the platform offers.
45
+
46
+ A sandbox shared by every thread instead:
47
+
48
+ ```python
49
+ from dothedev_sandbox import SandboxClient
50
+
51
+ shared = SandboxClient().get_or_create("shared:research")
52
+ agent = create_deep_agent(model=..., backend=shared)
53
+ ```
54
+
55
+ Locally, point it at a platform API and a deployment's token:
56
+
57
+ ```bash
58
+ export DOTHEDEV_SANDBOX_URL=http://localhost:8100
59
+ export DOTHEDEV_SANDBOX_TOKEN=sbx_...
60
+ ```
61
+
62
+ ## License
63
+
64
+ MIT
@@ -0,0 +1,7 @@
1
+ dothedev_sandbox/__init__.py,sha256=DEaOOHTSrSWMp6mk33Qyns3n-6uoOlS2jpd1DGX-h50,326
2
+ dothedev_sandbox/client.py,sha256=VnVCqZjuZgNnMZsO2bsH19atlCWgbF7NVCpOHc1Bs9o,18525
3
+ dothedev_sandbox/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ dothedev_sandbox-0.1.0.dist-info/METADATA,sha256=5mOrc_qycyWGjC7qT2HXJnb_5c6sZKXMzj_dz4AZu8U,2008
5
+ dothedev_sandbox-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ dothedev_sandbox-0.1.0.dist-info/licenses/LICENSE,sha256=DuMG864ACkJ7EaVFmUYJu2AccXCv9np8worEmFFQ7bg,1069
7
+ dothedev_sandbox-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shahraiz ali
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.