diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 1332969..c7159c1 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.0.1"
+ ".": "0.0.2"
}
\ No newline at end of file
diff --git a/.stats.yml b/.stats.yml
index 5dd9376..a16d1b5 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
-configured_endpoints: 26
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/dedalus-labs%2Fdedalus-2412fc9544367895508dc1f8377c71b177df7bbfdf0888087edb84e8067f62d9.yml
-openapi_spec_hash: c3563b6ed3c5a0f8ff3a09f8c8725bc4
-config_hash: d7156d9b45faaeba89feda6169c2c86a
+configured_endpoints: 27
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/dedalus-labs%2Fdedalus-a2b38be63dcddaea1a314843f9685b8e26c1f584b1696712f6a9668014afc0a7.yml
+openapi_spec_hash: ba6a5b38ed5fa9d49b03b154e3b99b53
+config_hash: a71704446fb82d83c7357258c182bdb5
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7dbaa04..8a7deb1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,25 @@
# Changelog
+## 0.0.2 (2026-03-20)
+
+Full Changelog: [v0.0.1...v0.0.2](https://github.com/dedalus-labs/dedalus-python/compare/v0.0.1...v0.0.2)
+
+### Bug Fixes
+
+* **api:** add stream_status SSE endpoint, websocket terminals, websocket_base_url param ([b377c15](https://github.com/dedalus-labs/dedalus-python/commit/b377c15509fd024a3a26d24907ddb1a6426eca79))
+* sanitize endpoint path params ([ddb7735](https://github.com/dedalus-labs/dedalus-python/commit/ddb7735faa56c28c05775dc625f2db7fede23c7c))
+
+
+### Chores
+
+* **tests:** bump steady to v0.19.4 ([c6813af](https://github.com/dedalus-labs/dedalus-python/commit/c6813af7f4f6c8995ca644160b30aac303160bee))
+* **tests:** bump steady to v0.19.5 ([6b3f3df](https://github.com/dedalus-labs/dedalus-python/commit/6b3f3dff25d9416ebd57e1be69cbb6b94747018f))
+
+
+### Refactors
+
+* **tests:** switch from prism to steady ([1766adf](https://github.com/dedalus-labs/dedalus-python/commit/1766adfac04d3544abff0e45830865dca9fbec38))
+
## 0.0.1 (2026-03-18)
Full Changelog: [v0.0.1...v0.0.1](https://github.com/dedalus-labs/dedalus-python/compare/v0.0.1...v0.0.1)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5794a3b..965581e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -85,7 +85,7 @@ $ pip install ./path-to-wheel-file.whl
## Running tests
-Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests.
+Most tests require you to [set up a mock server](https://github.com/dgellow/steady) against the OpenAPI spec to run the tests.
```sh
$ ./scripts/mock
diff --git a/README.md b/README.md
index 2abfa3d..128250a 100644
--- a/README.md
+++ b/README.md
@@ -118,6 +118,36 @@ async def main() -> None:
asyncio.run(main())
```
+## Streaming responses
+
+We provide support for streaming responses using Server Side Events (SSE).
+
+```python
+from dedalus_sdk import Dedalus
+
+client = Dedalus()
+
+stream = client.workspaces.stream_status(
+ workspace_id="workspace_id",
+)
+for workspace in stream:
+ print(workspace.workspace_id)
+```
+
+The async client uses the exact same interface.
+
+```python
+from dedalus_sdk import AsyncDedalus
+
+client = AsyncDedalus()
+
+stream = await client.workspaces.stream_status(
+ workspace_id="workspace_id",
+)
+async for workspace in stream:
+ print(workspace.workspace_id)
+```
+
## Using types
Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev) which also provide helper methods for things like:
diff --git a/api.md b/api.md
index 52a93ed..fce5d81 100644
--- a/api.md
+++ b/api.md
@@ -13,6 +13,7 @@ Methods:
- client.workspaces.update(workspace_id, \*\*params) -> Workspace
- client.workspaces.list(\*\*params) -> SyncCursorPage[Item]
- client.workspaces.delete(workspace_id) -> Workspace
+- client.workspaces.stream_status(workspace_id) -> Workspace
## Artifacts
diff --git a/pyproject.toml b/pyproject.toml
index 9e031a9..43ffdb5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "dedalus-sdk"
-version = "0.0.1"
+version = "0.0.2"
description = "The official Python library for the Dedalus API"
dynamic = ["readme"]
license = "MIT"
@@ -42,6 +42,7 @@ Repository = "https://github.com/dedalus-labs/dedalus-python"
[project.optional-dependencies]
aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"]
+websockets = ["websockets >= 13, < 16"]
[tool.uv]
managed = true
diff --git a/scripts/mock b/scripts/mock
index bcf3b39..ab814d3 100755
--- a/scripts/mock
+++ b/scripts/mock
@@ -19,34 +19,34 @@ fi
echo "==> Starting mock server with URL ${URL}"
-# Run prism mock on the given spec
+# Run steady mock on the given spec
if [ "$1" == "--daemon" ]; then
# Pre-install the package so the download doesn't eat into the startup timeout
- npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism --version
+ npm exec --package=@stdy/cli@0.19.5 -- steady --version
- npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log &
+ npm exec --package=@stdy/cli@0.19.5 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=comma --validator-query-array-format=comma --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log &
- # Wait for server to come online (max 30s)
+ # Wait for server to come online via health endpoint (max 30s)
echo -n "Waiting for server"
attempts=0
- while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do
+ while ! curl --silent --fail "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1; do
+ if ! kill -0 $! 2>/dev/null; then
+ echo
+ cat .stdy.log
+ exit 1
+ fi
attempts=$((attempts + 1))
if [ "$attempts" -ge 300 ]; then
echo
- echo "Timed out waiting for Prism server to start"
- cat .prism.log
+ echo "Timed out waiting for Steady server to start"
+ cat .stdy.log
exit 1
fi
echo -n "."
sleep 0.1
done
- if grep -q "✖ fatal" ".prism.log"; then
- cat .prism.log
- exit 1
- fi
-
echo
else
- npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL"
+ npm exec --package=@stdy/cli@0.19.5 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=comma --validator-query-array-format=comma --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL"
fi
diff --git a/scripts/test b/scripts/test
index b56970b..5105f92 100755
--- a/scripts/test
+++ b/scripts/test
@@ -9,8 +9,8 @@ GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
-function prism_is_running() {
- curl --silent "http://localhost:4010" >/dev/null 2>&1
+function steady_is_running() {
+ curl --silent "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1
}
kill_server_on_port() {
@@ -25,7 +25,7 @@ function is_overriding_api_base_url() {
[ -n "$TEST_API_BASE_URL" ]
}
-if ! is_overriding_api_base_url && ! prism_is_running ; then
+if ! is_overriding_api_base_url && ! steady_is_running ; then
# When we exit this script, make sure to kill the background mock server process
trap 'kill_server_on_port 4010' EXIT
@@ -36,19 +36,19 @@ fi
if is_overriding_api_base_url ; then
echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}"
echo
-elif ! prism_is_running ; then
- echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server"
+elif ! steady_is_running ; then
+ echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Steady server"
echo -e "running against your OpenAPI spec."
echo
echo -e "To run the server, pass in the path or url of your OpenAPI"
- echo -e "spec to the prism command:"
+ echo -e "spec to the steady command:"
echo
- echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}"
+ echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.5 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=comma --validator-query-array-format=comma --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}"
echo
exit 1
else
- echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}"
+ echo -e "${GREEN}✔ Mock steady server is running with your OpenAPI spec${NC}"
echo
fi
diff --git a/src/dedalus_sdk/_client.py b/src/dedalus_sdk/_client.py
index eba2edf..7eea679 100644
--- a/src/dedalus_sdk/_client.py
+++ b/src/dedalus_sdk/_client.py
@@ -45,6 +45,14 @@ class Dedalus(SyncAPIClient):
x_api_key: str | None
dedalus_org_id: str | None
+ websocket_base_url: str | httpx.URL | None
+ """Base URL for WebSocket connections.
+
+ If not specified, the default base URL will be used, with 'wss://' replacing the
+ 'http://' or 'https://' scheme. For example: 'http://example.com' becomes
+ 'wss://example.com'
+ """
+
def __init__(
self,
*,
@@ -52,6 +60,7 @@ def __init__(
x_api_key: str | None = None,
dedalus_org_id: str | None = None,
base_url: str | httpx.URL | None = None,
+ websocket_base_url: str | httpx.URL | None = None,
timeout: float | Timeout | None | NotGiven = not_given,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
@@ -89,6 +98,8 @@ def __init__(
dedalus_org_id = os.environ.get("DEDALUS_ORG_ID")
self.dedalus_org_id = dedalus_org_id
+ self.websocket_base_url = websocket_base_url
+
if base_url is None:
base_url = os.environ.get("DEDALUS_BASE_URL")
if base_url is None:
@@ -107,6 +118,8 @@ def __init__(
self._idempotency_header = "Idempotency-Key"
+ self._default_stream_cls = Stream
+
@cached_property
def workspaces(self) -> WorkspacesResource:
from .resources.workspaces import WorkspacesResource
@@ -175,6 +188,7 @@ def copy(
api_key: str | None = None,
x_api_key: str | None = None,
dedalus_org_id: str | None = None,
+ websocket_base_url: str | httpx.URL | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | Timeout | None | NotGiven = not_given,
http_client: httpx.Client | None = None,
@@ -211,6 +225,7 @@ def copy(
api_key=api_key or self.api_key,
x_api_key=x_api_key or self.x_api_key,
dedalus_org_id=dedalus_org_id or self.dedalus_org_id,
+ websocket_base_url=websocket_base_url or self.websocket_base_url,
base_url=base_url or self.base_url,
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
http_client=http_client,
@@ -264,6 +279,14 @@ class AsyncDedalus(AsyncAPIClient):
x_api_key: str | None
dedalus_org_id: str | None
+ websocket_base_url: str | httpx.URL | None
+ """Base URL for WebSocket connections.
+
+ If not specified, the default base URL will be used, with 'wss://' replacing the
+ 'http://' or 'https://' scheme. For example: 'http://example.com' becomes
+ 'wss://example.com'
+ """
+
def __init__(
self,
*,
@@ -271,6 +294,7 @@ def __init__(
x_api_key: str | None = None,
dedalus_org_id: str | None = None,
base_url: str | httpx.URL | None = None,
+ websocket_base_url: str | httpx.URL | None = None,
timeout: float | Timeout | None | NotGiven = not_given,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
@@ -308,6 +332,8 @@ def __init__(
dedalus_org_id = os.environ.get("DEDALUS_ORG_ID")
self.dedalus_org_id = dedalus_org_id
+ self.websocket_base_url = websocket_base_url
+
if base_url is None:
base_url = os.environ.get("DEDALUS_BASE_URL")
if base_url is None:
@@ -326,6 +352,8 @@ def __init__(
self._idempotency_header = "Idempotency-Key"
+ self._default_stream_cls = AsyncStream
+
@cached_property
def workspaces(self) -> AsyncWorkspacesResource:
from .resources.workspaces import AsyncWorkspacesResource
@@ -394,6 +422,7 @@ def copy(
api_key: str | None = None,
x_api_key: str | None = None,
dedalus_org_id: str | None = None,
+ websocket_base_url: str | httpx.URL | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | Timeout | None | NotGiven = not_given,
http_client: httpx.AsyncClient | None = None,
@@ -430,6 +459,7 @@ def copy(
api_key=api_key or self.api_key,
x_api_key=x_api_key or self.x_api_key,
dedalus_org_id=dedalus_org_id or self.dedalus_org_id,
+ websocket_base_url=websocket_base_url or self.websocket_base_url,
base_url=base_url or self.base_url,
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
http_client=http_client,
diff --git a/src/dedalus_sdk/_streaming.py b/src/dedalus_sdk/_streaming.py
index d71126c..d7c0ff2 100644
--- a/src/dedalus_sdk/_streaming.py
+++ b/src/dedalus_sdk/_streaming.py
@@ -59,7 +59,26 @@ def __stream__(self) -> Iterator[_T]:
try:
for sse in iterator:
- yield process_data(data=sse.json(), cast_to=cast_to, response=response)
+ if sse.event == "bookmark":
+ continue
+
+ if sse.event == "error":
+ body = sse.data
+
+ try:
+ body = sse.json()
+ err_msg = f"{body}"
+ except Exception:
+ err_msg = sse.data or f"Error code: {response.status_code}"
+
+ raise self._client._make_status_error(
+ err_msg,
+ body=body,
+ response=self.response,
+ )
+
+ if sse.event == "status":
+ yield process_data(data=sse.json(), cast_to=cast_to, response=response)
finally:
# Ensure the response is closed even if the consumer doesn't read all data
response.close()
@@ -125,7 +144,26 @@ async def __stream__(self) -> AsyncIterator[_T]:
try:
async for sse in iterator:
- yield process_data(data=sse.json(), cast_to=cast_to, response=response)
+ if sse.event == "bookmark":
+ continue
+
+ if sse.event == "error":
+ body = sse.data
+
+ try:
+ body = sse.json()
+ err_msg = f"{body}"
+ except Exception:
+ err_msg = sse.data or f"Error code: {response.status_code}"
+
+ raise self._client._make_status_error(
+ err_msg,
+ body=body,
+ response=self.response,
+ )
+
+ if sse.event == "status":
+ yield process_data(data=sse.json(), cast_to=cast_to, response=response)
finally:
# Ensure the response is closed even if the consumer doesn't read all data
await response.aclose()
diff --git a/src/dedalus_sdk/_utils/__init__.py b/src/dedalus_sdk/_utils/__init__.py
index dc64e29..10cb66d 100644
--- a/src/dedalus_sdk/_utils/__init__.py
+++ b/src/dedalus_sdk/_utils/__init__.py
@@ -1,3 +1,4 @@
+from ._path import path_template as path_template
from ._sync import asyncify as asyncify
from ._proxy import LazyProxy as LazyProxy
from ._utils import (
diff --git a/src/dedalus_sdk/_utils/_path.py b/src/dedalus_sdk/_utils/_path.py
new file mode 100644
index 0000000..4d6e1e4
--- /dev/null
+++ b/src/dedalus_sdk/_utils/_path.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+import re
+from typing import (
+ Any,
+ Mapping,
+ Callable,
+)
+from urllib.parse import quote
+
+# Matches '.' or '..' where each dot is either literal or percent-encoded (%2e / %2E).
+_DOT_SEGMENT_RE = re.compile(r"^(?:\.|%2[eE]){1,2}$")
+
+_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
+
+
+def _quote_path_segment_part(value: str) -> str:
+ """Percent-encode `value` for use in a URI path segment.
+
+ Considers characters not in `pchar` set from RFC 3986 §3.3 to be unsafe.
+ https://datatracker.ietf.org/doc/html/rfc3986#section-3.3
+ """
+ # quote() already treats unreserved characters (letters, digits, and -._~)
+ # as safe, so we only need to add sub-delims, ':', and '@'.
+ # Notably, unlike the default `safe` for quote(), / is unsafe and must be quoted.
+ return quote(value, safe="!$&'()*+,;=:@")
+
+
+def _quote_query_part(value: str) -> str:
+ """Percent-encode `value` for use in a URI query string.
+
+ Considers &, = and characters not in `query` set from RFC 3986 §3.4 to be unsafe.
+ https://datatracker.ietf.org/doc/html/rfc3986#section-3.4
+ """
+ return quote(value, safe="!$'()*+,;:@/?")
+
+
+def _quote_fragment_part(value: str) -> str:
+ """Percent-encode `value` for use in a URI fragment.
+
+ Considers characters not in `fragment` set from RFC 3986 §3.5 to be unsafe.
+ https://datatracker.ietf.org/doc/html/rfc3986#section-3.5
+ """
+ return quote(value, safe="!$&'()*+,;=:@/?")
+
+
+def _interpolate(
+ template: str,
+ values: Mapping[str, Any],
+ quoter: Callable[[str], str],
+) -> str:
+ """Replace {name} placeholders in `template`, quoting each value with `quoter`.
+
+ Placeholder names are looked up in `values`.
+
+ Raises:
+ KeyError: If a placeholder is not found in `values`.
+ """
+ # re.split with a capturing group returns alternating
+ # [text, name, text, name, ..., text] elements.
+ parts = _PLACEHOLDER_RE.split(template)
+
+ for i in range(1, len(parts), 2):
+ name = parts[i]
+ if name not in values:
+ raise KeyError(f"a value for placeholder {{{name}}} was not provided")
+ val = values[name]
+ if val is None:
+ parts[i] = "null"
+ elif isinstance(val, bool):
+ parts[i] = "true" if val else "false"
+ else:
+ parts[i] = quoter(str(values[name]))
+
+ return "".join(parts)
+
+
+def path_template(template: str, /, **kwargs: Any) -> str:
+ """Interpolate {name} placeholders in `template` from keyword arguments.
+
+ Args:
+ template: The template string containing {name} placeholders.
+ **kwargs: Keyword arguments to interpolate into the template.
+
+ Returns:
+ The template with placeholders interpolated and percent-encoded.
+
+ Safe characters for percent-encoding are dependent on the URI component.
+ Placeholders in path and fragment portions are percent-encoded where the `segment`
+ and `fragment` sets from RFC 3986 respectively are considered safe.
+ Placeholders in the query portion are percent-encoded where the `query` set from
+ RFC 3986 §3.3 is considered safe except for = and & characters.
+
+ Raises:
+ KeyError: If a placeholder is not found in `kwargs`.
+ ValueError: If resulting path contains /./ or /../ segments (including percent-encoded dot-segments).
+ """
+ # Split the template into path, query, and fragment portions.
+ fragment_template: str | None = None
+ query_template: str | None = None
+
+ rest = template
+ if "#" in rest:
+ rest, fragment_template = rest.split("#", 1)
+ if "?" in rest:
+ rest, query_template = rest.split("?", 1)
+ path_template = rest
+
+ # Interpolate each portion with the appropriate quoting rules.
+ path_result = _interpolate(path_template, kwargs, _quote_path_segment_part)
+
+ # Reject dot-segments (. and ..) in the final assembled path. The check
+ # runs after interpolation so that adjacent placeholders or a mix of static
+ # text and placeholders that together form a dot-segment are caught.
+ # Also reject percent-encoded dot-segments to protect against incorrectly
+ # implemented normalization in servers/proxies.
+ for segment in path_result.split("/"):
+ if _DOT_SEGMENT_RE.match(segment):
+ raise ValueError(f"Constructed path {path_result!r} contains dot-segment {segment!r} which is not allowed")
+
+ result = path_result
+ if query_template is not None:
+ result += "?" + _interpolate(query_template, kwargs, _quote_query_part)
+ if fragment_template is not None:
+ result += "#" + _interpolate(fragment_template, kwargs, _quote_fragment_part)
+
+ return result
diff --git a/src/dedalus_sdk/_version.py b/src/dedalus_sdk/_version.py
index 02910e4..96bd39b 100644
--- a/src/dedalus_sdk/_version.py
+++ b/src/dedalus_sdk/_version.py
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
__title__ = "dedalus_sdk"
-__version__ = "0.0.1" # x-release-please-version
+__version__ = "0.0.2" # x-release-please-version
diff --git a/src/dedalus_sdk/resources/workspaces/artifacts.py b/src/dedalus_sdk/resources/workspaces/artifacts.py
index cdad039..068e0dc 100644
--- a/src/dedalus_sdk/resources/workspaces/artifacts.py
+++ b/src/dedalus_sdk/resources/workspaces/artifacts.py
@@ -5,7 +5,7 @@
import httpx
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
-from ..._utils import maybe_transform
+from ..._utils import path_template, maybe_transform
from ..._compat import cached_property
from ..._resource import SyncAPIResource, AsyncAPIResource
from ..._response import (
@@ -71,7 +71,11 @@ def retrieve(
if not artifact_id:
raise ValueError(f"Expected a non-empty value for `artifact_id` but received {artifact_id!r}")
return self._get(
- f"/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
+ workspace_id=workspace_id,
+ artifact_id=artifact_id,
+ ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -106,7 +110,7 @@ def list(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return self._get_api_list(
- f"/v1/workspaces/{workspace_id}/artifacts",
+ path_template("/v1/workspaces/{workspace_id}/artifacts", workspace_id=workspace_id),
page=SyncCursorPage[Artifact],
options=make_request_options(
extra_headers=extra_headers,
@@ -156,7 +160,11 @@ def delete(
if not artifact_id:
raise ValueError(f"Expected a non-empty value for `artifact_id` but received {artifact_id!r}")
return self._delete(
- f"/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
+ workspace_id=workspace_id,
+ artifact_id=artifact_id,
+ ),
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
@@ -217,7 +225,11 @@ async def retrieve(
if not artifact_id:
raise ValueError(f"Expected a non-empty value for `artifact_id` but received {artifact_id!r}")
return await self._get(
- f"/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
+ workspace_id=workspace_id,
+ artifact_id=artifact_id,
+ ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -252,7 +264,7 @@ def list(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return self._get_api_list(
- f"/v1/workspaces/{workspace_id}/artifacts",
+ path_template("/v1/workspaces/{workspace_id}/artifacts", workspace_id=workspace_id),
page=AsyncCursorPage[Artifact],
options=make_request_options(
extra_headers=extra_headers,
@@ -302,7 +314,11 @@ async def delete(
if not artifact_id:
raise ValueError(f"Expected a non-empty value for `artifact_id` but received {artifact_id!r}")
return await self._delete(
- f"/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
+ workspace_id=workspace_id,
+ artifact_id=artifact_id,
+ ),
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
diff --git a/src/dedalus_sdk/resources/workspaces/executions.py b/src/dedalus_sdk/resources/workspaces/executions.py
index 2378f1f..1839bb7 100644
--- a/src/dedalus_sdk/resources/workspaces/executions.py
+++ b/src/dedalus_sdk/resources/workspaces/executions.py
@@ -7,7 +7,7 @@
import httpx
from ..._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given
-from ..._utils import maybe_transform, async_maybe_transform
+from ..._utils import path_template, maybe_transform, async_maybe_transform
from ..._compat import cached_property
from ..._resource import SyncAPIResource, AsyncAPIResource
from ..._response import (
@@ -81,7 +81,7 @@ def create(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return self._post(
- f"/v1/workspaces/{workspace_id}/executions",
+ path_template("/v1/workspaces/{workspace_id}/executions", workspace_id=workspace_id),
body=maybe_transform(
{
"command": command,
@@ -132,7 +132,11 @@ def retrieve(
if not execution_id:
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
return self._get(
- f"/v1/workspaces/{workspace_id}/executions/{execution_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/executions/{execution_id}",
+ workspace_id=workspace_id,
+ execution_id=execution_id,
+ ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -167,7 +171,7 @@ def list(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return self._get_api_list(
- f"/v1/workspaces/{workspace_id}/executions",
+ path_template("/v1/workspaces/{workspace_id}/executions", workspace_id=workspace_id),
page=SyncCursorPage[Execution],
options=make_request_options(
extra_headers=extra_headers,
@@ -217,7 +221,11 @@ def delete(
if not execution_id:
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
return self._delete(
- f"/v1/workspaces/{workspace_id}/executions/{execution_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/executions/{execution_id}",
+ workspace_id=workspace_id,
+ execution_id=execution_id,
+ ),
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
@@ -259,7 +267,11 @@ def events(
if not execution_id:
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
return self._get_api_list(
- f"/v1/workspaces/{workspace_id}/executions/{execution_id}/events",
+ path_template(
+ "/v1/workspaces/{workspace_id}/executions/{execution_id}/events",
+ workspace_id=workspace_id,
+ execution_id=execution_id,
+ ),
page=SyncCursorPage[ExecutionEvent],
options=make_request_options(
extra_headers=extra_headers,
@@ -306,7 +318,11 @@ def output(
if not execution_id:
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
return self._get(
- f"/v1/workspaces/{workspace_id}/executions/{execution_id}/output",
+ path_template(
+ "/v1/workspaces/{workspace_id}/executions/{execution_id}/output",
+ workspace_id=workspace_id,
+ execution_id=execution_id,
+ ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -369,7 +385,7 @@ async def create(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return await self._post(
- f"/v1/workspaces/{workspace_id}/executions",
+ path_template("/v1/workspaces/{workspace_id}/executions", workspace_id=workspace_id),
body=await async_maybe_transform(
{
"command": command,
@@ -420,7 +436,11 @@ async def retrieve(
if not execution_id:
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
return await self._get(
- f"/v1/workspaces/{workspace_id}/executions/{execution_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/executions/{execution_id}",
+ workspace_id=workspace_id,
+ execution_id=execution_id,
+ ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -455,7 +475,7 @@ def list(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return self._get_api_list(
- f"/v1/workspaces/{workspace_id}/executions",
+ path_template("/v1/workspaces/{workspace_id}/executions", workspace_id=workspace_id),
page=AsyncCursorPage[Execution],
options=make_request_options(
extra_headers=extra_headers,
@@ -505,7 +525,11 @@ async def delete(
if not execution_id:
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
return await self._delete(
- f"/v1/workspaces/{workspace_id}/executions/{execution_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/executions/{execution_id}",
+ workspace_id=workspace_id,
+ execution_id=execution_id,
+ ),
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
@@ -547,7 +571,11 @@ def events(
if not execution_id:
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
return self._get_api_list(
- f"/v1/workspaces/{workspace_id}/executions/{execution_id}/events",
+ path_template(
+ "/v1/workspaces/{workspace_id}/executions/{execution_id}/events",
+ workspace_id=workspace_id,
+ execution_id=execution_id,
+ ),
page=AsyncCursorPage[ExecutionEvent],
options=make_request_options(
extra_headers=extra_headers,
@@ -594,7 +622,11 @@ async def output(
if not execution_id:
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
return await self._get(
- f"/v1/workspaces/{workspace_id}/executions/{execution_id}/output",
+ path_template(
+ "/v1/workspaces/{workspace_id}/executions/{execution_id}/output",
+ workspace_id=workspace_id,
+ execution_id=execution_id,
+ ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
diff --git a/src/dedalus_sdk/resources/workspaces/previews.py b/src/dedalus_sdk/resources/workspaces/previews.py
index 517f899..29af311 100644
--- a/src/dedalus_sdk/resources/workspaces/previews.py
+++ b/src/dedalus_sdk/resources/workspaces/previews.py
@@ -7,7 +7,7 @@
import httpx
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
-from ..._utils import maybe_transform, async_maybe_transform
+from ..._utils import path_template, maybe_transform, async_maybe_transform
from ..._compat import cached_property
from ..._resource import SyncAPIResource, AsyncAPIResource
from ..._response import (
@@ -76,7 +76,7 @@ def create(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return self._post(
- f"/v1/workspaces/{workspace_id}/previews",
+ path_template("/v1/workspaces/{workspace_id}/previews", workspace_id=workspace_id),
body=maybe_transform(
{
"port": port,
@@ -124,7 +124,9 @@ def retrieve(
if not preview_id:
raise ValueError(f"Expected a non-empty value for `preview_id` but received {preview_id!r}")
return self._get(
- f"/v1/workspaces/{workspace_id}/previews/{preview_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/previews/{preview_id}", workspace_id=workspace_id, preview_id=preview_id
+ ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -159,7 +161,7 @@ def list(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return self._get_api_list(
- f"/v1/workspaces/{workspace_id}/previews",
+ path_template("/v1/workspaces/{workspace_id}/previews", workspace_id=workspace_id),
page=SyncCursorPage[Preview],
options=make_request_options(
extra_headers=extra_headers,
@@ -209,7 +211,9 @@ def delete(
if not preview_id:
raise ValueError(f"Expected a non-empty value for `preview_id` but received {preview_id!r}")
return self._delete(
- f"/v1/workspaces/{workspace_id}/previews/{preview_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/previews/{preview_id}", workspace_id=workspace_id, preview_id=preview_id
+ ),
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
@@ -273,7 +277,7 @@ async def create(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return await self._post(
- f"/v1/workspaces/{workspace_id}/previews",
+ path_template("/v1/workspaces/{workspace_id}/previews", workspace_id=workspace_id),
body=await async_maybe_transform(
{
"port": port,
@@ -321,7 +325,9 @@ async def retrieve(
if not preview_id:
raise ValueError(f"Expected a non-empty value for `preview_id` but received {preview_id!r}")
return await self._get(
- f"/v1/workspaces/{workspace_id}/previews/{preview_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/previews/{preview_id}", workspace_id=workspace_id, preview_id=preview_id
+ ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -356,7 +362,7 @@ def list(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return self._get_api_list(
- f"/v1/workspaces/{workspace_id}/previews",
+ path_template("/v1/workspaces/{workspace_id}/previews", workspace_id=workspace_id),
page=AsyncCursorPage[Preview],
options=make_request_options(
extra_headers=extra_headers,
@@ -406,7 +412,9 @@ async def delete(
if not preview_id:
raise ValueError(f"Expected a non-empty value for `preview_id` but received {preview_id!r}")
return await self._delete(
- f"/v1/workspaces/{workspace_id}/previews/{preview_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/previews/{preview_id}", workspace_id=workspace_id, preview_id=preview_id
+ ),
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
diff --git a/src/dedalus_sdk/resources/workspaces/ssh.py b/src/dedalus_sdk/resources/workspaces/ssh.py
index fe55df9..a66de23 100644
--- a/src/dedalus_sdk/resources/workspaces/ssh.py
+++ b/src/dedalus_sdk/resources/workspaces/ssh.py
@@ -5,7 +5,7 @@
import httpx
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
-from ..._utils import maybe_transform, async_maybe_transform
+from ..._utils import path_template, maybe_transform, async_maybe_transform
from ..._compat import cached_property
from ..._resource import SyncAPIResource, AsyncAPIResource
from ..._response import (
@@ -73,7 +73,7 @@ def create(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return self._post(
- f"/v1/workspaces/{workspace_id}/ssh",
+ path_template("/v1/workspaces/{workspace_id}/ssh", workspace_id=workspace_id),
body=maybe_transform(
{
"public_key": public_key,
@@ -120,7 +120,9 @@ def retrieve(
if not session_id:
raise ValueError(f"Expected a non-empty value for `session_id` but received {session_id!r}")
return self._get(
- f"/v1/workspaces/{workspace_id}/ssh/{session_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/ssh/{session_id}", workspace_id=workspace_id, session_id=session_id
+ ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -155,7 +157,7 @@ def list(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return self._get_api_list(
- f"/v1/workspaces/{workspace_id}/ssh",
+ path_template("/v1/workspaces/{workspace_id}/ssh", workspace_id=workspace_id),
page=SyncCursorPage[SSHSession],
options=make_request_options(
extra_headers=extra_headers,
@@ -205,7 +207,9 @@ def delete(
if not session_id:
raise ValueError(f"Expected a non-empty value for `session_id` but received {session_id!r}")
return self._delete(
- f"/v1/workspaces/{workspace_id}/ssh/{session_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/ssh/{session_id}", workspace_id=workspace_id, session_id=session_id
+ ),
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
@@ -268,7 +272,7 @@ async def create(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return await self._post(
- f"/v1/workspaces/{workspace_id}/ssh",
+ path_template("/v1/workspaces/{workspace_id}/ssh", workspace_id=workspace_id),
body=await async_maybe_transform(
{
"public_key": public_key,
@@ -315,7 +319,9 @@ async def retrieve(
if not session_id:
raise ValueError(f"Expected a non-empty value for `session_id` but received {session_id!r}")
return await self._get(
- f"/v1/workspaces/{workspace_id}/ssh/{session_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/ssh/{session_id}", workspace_id=workspace_id, session_id=session_id
+ ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -350,7 +356,7 @@ def list(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return self._get_api_list(
- f"/v1/workspaces/{workspace_id}/ssh",
+ path_template("/v1/workspaces/{workspace_id}/ssh", workspace_id=workspace_id),
page=AsyncCursorPage[SSHSession],
options=make_request_options(
extra_headers=extra_headers,
@@ -400,7 +406,9 @@ async def delete(
if not session_id:
raise ValueError(f"Expected a non-empty value for `session_id` but received {session_id!r}")
return await self._delete(
- f"/v1/workspaces/{workspace_id}/ssh/{session_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/ssh/{session_id}", workspace_id=workspace_id, session_id=session_id
+ ),
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
diff --git a/src/dedalus_sdk/resources/workspaces/terminals.py b/src/dedalus_sdk/resources/workspaces/terminals.py
index 0284f9d..135a419 100644
--- a/src/dedalus_sdk/resources/workspaces/terminals.py
+++ b/src/dedalus_sdk/resources/workspaces/terminals.py
@@ -2,13 +2,19 @@
from __future__ import annotations
-from typing import Dict
+import json
+import logging
+from types import TracebackType
+from typing import TYPE_CHECKING, Any, Dict, Iterator, cast
+from typing_extensions import AsyncIterator
import httpx
+from pydantic import BaseModel
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
-from ..._utils import maybe_transform, async_maybe_transform
+from ..._utils import path_template, maybe_transform, async_maybe_transform
from ..._compat import cached_property
+from ..._models import construct_type_unchecked
from ..._resource import SyncAPIResource, AsyncAPIResource
from ..._response import (
to_raw_response_wrapper,
@@ -17,12 +23,25 @@
async_to_streamed_response_wrapper,
)
from ...pagination import SyncCursorPage, AsyncCursorPage
-from ..._base_client import AsyncPaginator, make_request_options
+from ..._exceptions import DedalusError
+from ..._base_client import AsyncPaginator, _merge_mappings, make_request_options
from ...types.workspaces import terminal_list_params, terminal_create_params
from ...types.workspaces.terminal import Terminal
+from ...types.websocket_connection_options import WebSocketConnectionOptions
+from ...types.workspaces.terminal_client_event import TerminalClientEvent
+from ...types.workspaces.terminal_server_event import TerminalServerEvent
+from ...types.workspaces.terminal_client_event_param import TerminalClientEventParam
+
+if TYPE_CHECKING:
+ from websockets.sync.client import ClientConnection as WebSocketConnection
+ from websockets.asyncio.client import ClientConnection as AsyncWebSocketConnection
+
+ from ..._client import Dedalus, AsyncDedalus
__all__ = ["TerminalsResource", "AsyncTerminalsResource"]
+log: logging.Logger = logging.getLogger(__name__)
+
class TerminalsResource(SyncAPIResource):
@cached_property
@@ -79,7 +98,7 @@ def create(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return self._post(
- f"/v1/workspaces/{workspace_id}/terminals",
+ path_template("/v1/workspaces/{workspace_id}/terminals", workspace_id=workspace_id),
body=maybe_transform(
{
"height": height,
@@ -130,7 +149,11 @@ def retrieve(
if not terminal_id:
raise ValueError(f"Expected a non-empty value for `terminal_id` but received {terminal_id!r}")
return self._get(
- f"/v1/workspaces/{workspace_id}/terminals/{terminal_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/terminals/{terminal_id}",
+ workspace_id=workspace_id,
+ terminal_id=terminal_id,
+ ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -165,7 +188,7 @@ def list(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return self._get_api_list(
- f"/v1/workspaces/{workspace_id}/terminals",
+ path_template("/v1/workspaces/{workspace_id}/terminals", workspace_id=workspace_id),
page=SyncCursorPage[Terminal],
options=make_request_options(
extra_headers=extra_headers,
@@ -215,7 +238,11 @@ def delete(
if not terminal_id:
raise ValueError(f"Expected a non-empty value for `terminal_id` but received {terminal_id!r}")
return self._delete(
- f"/v1/workspaces/{workspace_id}/terminals/{terminal_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/terminals/{terminal_id}",
+ workspace_id=workspace_id,
+ terminal_id=terminal_id,
+ ),
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
@@ -226,6 +253,19 @@ def delete(
cast_to=Terminal,
)
+ def connect(
+ self,
+ extra_query: Query = {},
+ extra_headers: Headers = {},
+ websocket_connection_options: WebSocketConnectionOptions = {},
+ ) -> TerminalsResourceConnectionManager:
+ return TerminalsResourceConnectionManager(
+ client=self._client,
+ extra_query=extra_query,
+ extra_headers=extra_headers,
+ websocket_connection_options=websocket_connection_options,
+ )
+
class AsyncTerminalsResource(AsyncAPIResource):
@cached_property
@@ -282,7 +322,7 @@ async def create(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return await self._post(
- f"/v1/workspaces/{workspace_id}/terminals",
+ path_template("/v1/workspaces/{workspace_id}/terminals", workspace_id=workspace_id),
body=await async_maybe_transform(
{
"height": height,
@@ -333,7 +373,11 @@ async def retrieve(
if not terminal_id:
raise ValueError(f"Expected a non-empty value for `terminal_id` but received {terminal_id!r}")
return await self._get(
- f"/v1/workspaces/{workspace_id}/terminals/{terminal_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/terminals/{terminal_id}",
+ workspace_id=workspace_id,
+ terminal_id=terminal_id,
+ ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -368,7 +412,7 @@ def list(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return self._get_api_list(
- f"/v1/workspaces/{workspace_id}/terminals",
+ path_template("/v1/workspaces/{workspace_id}/terminals", workspace_id=workspace_id),
page=AsyncCursorPage[Terminal],
options=make_request_options(
extra_headers=extra_headers,
@@ -418,7 +462,11 @@ async def delete(
if not terminal_id:
raise ValueError(f"Expected a non-empty value for `terminal_id` but received {terminal_id!r}")
return await self._delete(
- f"/v1/workspaces/{workspace_id}/terminals/{terminal_id}",
+ path_template(
+ "/v1/workspaces/{workspace_id}/terminals/{terminal_id}",
+ workspace_id=workspace_id,
+ terminal_id=terminal_id,
+ ),
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
@@ -429,6 +477,19 @@ async def delete(
cast_to=Terminal,
)
+ def connect(
+ self,
+ extra_query: Query = {},
+ extra_headers: Headers = {},
+ websocket_connection_options: WebSocketConnectionOptions = {},
+ ) -> AsyncTerminalsResourceConnectionManager:
+ return AsyncTerminalsResourceConnectionManager(
+ client=self._client,
+ extra_query=extra_query,
+ extra_headers=extra_headers,
+ websocket_connection_options=websocket_connection_options,
+ )
+
class TerminalsResourceWithRawResponse:
def __init__(self, terminals: TerminalsResource) -> None:
@@ -500,3 +561,329 @@ def __init__(self, terminals: AsyncTerminalsResource) -> None:
self.delete = async_to_streamed_response_wrapper(
terminals.delete,
)
+
+
+class AsyncTerminalsResourceConnection:
+ """Represents a live WebSocket connection to the Terminals API"""
+
+ _connection: AsyncWebSocketConnection
+
+ def __init__(self, connection: AsyncWebSocketConnection) -> None:
+ self._connection = connection
+
+ async def __aiter__(self) -> AsyncIterator[TerminalServerEvent]:
+ """
+ An infinite-iterator that will continue to yield events until
+ the connection is closed.
+ """
+ from websockets.exceptions import ConnectionClosedOK
+
+ try:
+ while True:
+ yield await self.recv()
+ except ConnectionClosedOK:
+ return
+
+ async def recv(self) -> TerminalServerEvent:
+ """
+ Receive the next message from the connection and parses it into a `TerminalServerEvent` object.
+
+ Canceling this method is safe. There's no risk of losing data.
+ """
+ return self.parse_event(await self.recv_bytes())
+
+ async def recv_bytes(self) -> bytes:
+ """Receive the next message from the connection as raw bytes.
+
+ Canceling this method is safe. There's no risk of losing data.
+
+ If you want to parse the message into a `TerminalServerEvent` object like `.recv()` does,
+ then you can call `.parse_event(data)`.
+ """
+ message = await self._connection.recv(decode=False)
+ log.debug(f"Received WebSocket message: %s", message)
+ return message
+
+ async def send(self, event: TerminalClientEvent | TerminalClientEventParam) -> None:
+ data = (
+ event.to_json(use_api_names=True, exclude_defaults=True, exclude_unset=True)
+ if isinstance(event, BaseModel)
+ else json.dumps(await async_maybe_transform(event, TerminalClientEventParam))
+ )
+ await self._connection.send(data)
+
+ async def close(self, *, code: int = 1000, reason: str = "") -> None:
+ await self._connection.close(code=code, reason=reason)
+
+ def parse_event(self, data: str | bytes) -> TerminalServerEvent:
+ """
+ Converts a raw `str` or `bytes` message into a `TerminalServerEvent` object.
+
+ This is helpful if you're using `.recv_bytes()`.
+ """
+ return cast(
+ TerminalServerEvent, construct_type_unchecked(value=json.loads(data), type_=cast(Any, TerminalServerEvent))
+ )
+
+
+class AsyncTerminalsResourceConnectionManager:
+ """
+ Context manager over a `AsyncTerminalsResourceConnection` that is returned by `workspaces.terminals.connect()`
+
+ This context manager ensures that the connection will be closed when it exits.
+
+ ---
+
+ Note that if your application doesn't work well with the context manager approach then you
+ can call the `.enter()` method directly to initiate a connection.
+
+ **Warning**: You must remember to close the connection with `.close()`.
+
+ ```py
+ connection = await client.workspaces.terminals.connect(...).enter()
+ # ...
+ await connection.close()
+ ```
+ """
+
+ def __init__(
+ self,
+ *,
+ client: AsyncDedalus,
+ extra_query: Query,
+ extra_headers: Headers,
+ websocket_connection_options: WebSocketConnectionOptions,
+ ) -> None:
+ self.__client = client
+ self.__connection: AsyncTerminalsResourceConnection | None = None
+ self.__extra_query = extra_query
+ self.__extra_headers = extra_headers
+ self.__websocket_connection_options = websocket_connection_options
+
+ async def __aenter__(self) -> AsyncTerminalsResourceConnection:
+ """
+ 👋 If your application doesn't work well with the context manager approach then you
+ can call this method directly to initiate a connection.
+
+ **Warning**: You must remember to close the connection with `.close()`.
+
+ ```py
+ connection = await client.workspaces.terminals.connect(...).enter()
+ # ...
+ await connection.close()
+ ```
+ """
+ try:
+ from websockets.asyncio.client import connect
+ except ImportError as exc:
+ raise DedalusError("You need to install `dedalus-sdk[websockets]` to use this method") from exc
+
+ url = self._prepare_url().copy_with(
+ params={
+ **self.__client.base_url.params,
+ **self.__extra_query,
+ },
+ )
+ log.debug("Connecting to %s", url)
+ if self.__websocket_connection_options:
+ log.debug("Connection options: %s", self.__websocket_connection_options)
+
+ self.__connection = AsyncTerminalsResourceConnection(
+ await connect(
+ str(url),
+ user_agent_header=self.__client.user_agent,
+ additional_headers=_merge_mappings(
+ {
+ **self.__client.auth_headers,
+ },
+ self.__extra_headers,
+ ),
+ **self.__websocket_connection_options,
+ )
+ )
+
+ return self.__connection
+
+ enter = __aenter__
+
+ def _prepare_url(self) -> httpx.URL:
+ if self.__client.websocket_base_url is not None:
+ base_url = httpx.URL(self.__client.websocket_base_url)
+ else:
+ scheme = self.__client._base_url.scheme
+ ws_scheme = "ws" if scheme == "http" else "wss"
+ base_url = self.__client._base_url.copy_with(scheme=ws_scheme)
+
+ merge_raw_path = (
+ base_url.raw_path.rstrip(b"/") + b"/v1/workspaces/{workspace_id}/terminals/{terminal_id}/stream"
+ )
+ return base_url.copy_with(raw_path=merge_raw_path)
+
+ async def __aexit__(
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, exc_tb: TracebackType | None
+ ) -> None:
+ if self.__connection is not None:
+ await self.__connection.close()
+
+
+class TerminalsResourceConnection:
+ """Represents a live WebSocket connection to the Terminals API"""
+
+ _connection: WebSocketConnection
+
+ def __init__(self, connection: WebSocketConnection) -> None:
+ self._connection = connection
+
+ def __iter__(self) -> Iterator[TerminalServerEvent]:
+ """
+ An infinite-iterator that will continue to yield events until
+ the connection is closed.
+ """
+ from websockets.exceptions import ConnectionClosedOK
+
+ try:
+ while True:
+ yield self.recv()
+ except ConnectionClosedOK:
+ return
+
+ def recv(self) -> TerminalServerEvent:
+ """
+ Receive the next message from the connection and parses it into a `TerminalServerEvent` object.
+
+ Canceling this method is safe. There's no risk of losing data.
+ """
+ return self.parse_event(self.recv_bytes())
+
+ def recv_bytes(self) -> bytes:
+ """Receive the next message from the connection as raw bytes.
+
+ Canceling this method is safe. There's no risk of losing data.
+
+ If you want to parse the message into a `TerminalServerEvent` object like `.recv()` does,
+ then you can call `.parse_event(data)`.
+ """
+ message = self._connection.recv(decode=False)
+ log.debug(f"Received WebSocket message: %s", message)
+ return message
+
+ def send(self, event: TerminalClientEvent | TerminalClientEventParam) -> None:
+ data = (
+ event.to_json(use_api_names=True, exclude_defaults=True, exclude_unset=True)
+ if isinstance(event, BaseModel)
+ else json.dumps(maybe_transform(event, TerminalClientEventParam))
+ )
+ self._connection.send(data)
+
+ def close(self, *, code: int = 1000, reason: str = "") -> None:
+ self._connection.close(code=code, reason=reason)
+
+ def parse_event(self, data: str | bytes) -> TerminalServerEvent:
+ """
+ Converts a raw `str` or `bytes` message into a `TerminalServerEvent` object.
+
+ This is helpful if you're using `.recv_bytes()`.
+ """
+ return cast(
+ TerminalServerEvent, construct_type_unchecked(value=json.loads(data), type_=cast(Any, TerminalServerEvent))
+ )
+
+
+class TerminalsResourceConnectionManager:
+ """
+ Context manager over a `TerminalsResourceConnection` that is returned by `workspaces.terminals.connect()`
+
+ This context manager ensures that the connection will be closed when it exits.
+
+ ---
+
+ Note that if your application doesn't work well with the context manager approach then you
+ can call the `.enter()` method directly to initiate a connection.
+
+ **Warning**: You must remember to close the connection with `.close()`.
+
+ ```py
+ connection = client.workspaces.terminals.connect(...).enter()
+ # ...
+ connection.close()
+ ```
+ """
+
+ def __init__(
+ self,
+ *,
+ client: Dedalus,
+ extra_query: Query,
+ extra_headers: Headers,
+ websocket_connection_options: WebSocketConnectionOptions,
+ ) -> None:
+ self.__client = client
+ self.__connection: TerminalsResourceConnection | None = None
+ self.__extra_query = extra_query
+ self.__extra_headers = extra_headers
+ self.__websocket_connection_options = websocket_connection_options
+
+ def __enter__(self) -> TerminalsResourceConnection:
+ """
+ 👋 If your application doesn't work well with the context manager approach then you
+ can call this method directly to initiate a connection.
+
+ **Warning**: You must remember to close the connection with `.close()`.
+
+ ```py
+ connection = client.workspaces.terminals.connect(...).enter()
+ # ...
+ connection.close()
+ ```
+ """
+ try:
+ from websockets.sync.client import connect
+ except ImportError as exc:
+ raise DedalusError("You need to install `dedalus-sdk[websockets]` to use this method") from exc
+
+ url = self._prepare_url().copy_with(
+ params={
+ **self.__client.base_url.params,
+ **self.__extra_query,
+ },
+ )
+ log.debug("Connecting to %s", url)
+ if self.__websocket_connection_options:
+ log.debug("Connection options: %s", self.__websocket_connection_options)
+
+ self.__connection = TerminalsResourceConnection(
+ connect(
+ str(url),
+ user_agent_header=self.__client.user_agent,
+ additional_headers=_merge_mappings(
+ {
+ **self.__client.auth_headers,
+ },
+ self.__extra_headers,
+ ),
+ **self.__websocket_connection_options,
+ )
+ )
+
+ return self.__connection
+
+ enter = __enter__
+
+ def _prepare_url(self) -> httpx.URL:
+ if self.__client.websocket_base_url is not None:
+ base_url = httpx.URL(self.__client.websocket_base_url)
+ else:
+ scheme = self.__client._base_url.scheme
+ ws_scheme = "ws" if scheme == "http" else "wss"
+ base_url = self.__client._base_url.copy_with(scheme=ws_scheme)
+
+ merge_raw_path = (
+ base_url.raw_path.rstrip(b"/") + b"/v1/workspaces/{workspace_id}/terminals/{terminal_id}/stream"
+ )
+ return base_url.copy_with(raw_path=merge_raw_path)
+
+ def __exit__(
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, exc_tb: TracebackType | None
+ ) -> None:
+ if self.__connection is not None:
+ self.__connection.close()
diff --git a/src/dedalus_sdk/resources/workspaces/workspaces.py b/src/dedalus_sdk/resources/workspaces/workspaces.py
index e4ca9a3..0f0c387 100644
--- a/src/dedalus_sdk/resources/workspaces/workspaces.py
+++ b/src/dedalus_sdk/resources/workspaces/workspaces.py
@@ -14,7 +14,7 @@
)
from ...types import workspace_list_params, workspace_create_params, workspace_update_params
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
-from ..._utils import maybe_transform, async_maybe_transform
+from ..._utils import path_template, maybe_transform, strip_not_given, async_maybe_transform
from .previews import (
PreviewsResource,
AsyncPreviewsResource,
@@ -55,6 +55,7 @@
async_to_raw_response_wrapper,
async_to_streamed_response_wrapper,
)
+from ..._streaming import Stream, AsyncStream
from ...pagination import SyncCursorPage, AsyncCursorPage
from ..._base_client import AsyncPaginator, make_request_options
from ...types.workspace import Workspace
@@ -181,7 +182,7 @@ def retrieve(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return self._get(
- f"/v1/workspaces/{workspace_id}",
+ path_template("/v1/workspaces/{workspace_id}", workspace_id=workspace_id),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -226,7 +227,7 @@ def update(
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
extra_headers = {"If-Match": if_match, **(extra_headers or {})}
return self._patch(
- f"/v1/workspaces/{workspace_id}",
+ path_template("/v1/workspaces/{workspace_id}", workspace_id=workspace_id),
body=maybe_transform(
{
"memory_mib": memory_mib,
@@ -319,7 +320,7 @@ def delete(
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
extra_headers = {"If-Match": if_match, **(extra_headers or {})}
return self._delete(
- f"/v1/workspaces/{workspace_id}",
+ path_template("/v1/workspaces/{workspace_id}", workspace_id=workspace_id),
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
@@ -330,6 +331,47 @@ def delete(
cast_to=Workspace,
)
+ def stream_status(
+ self,
+ workspace_id: str,
+ *,
+ last_event_id: str | Omit = omit,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> Stream[Workspace]:
+ """Streams workspace lifecycle updates over Server-Sent Events.
+
+ Each `status` event
+ contains a full `LifecycleResponse` payload. The stream closes after the
+ workspace reaches its current desired state.
+
+ Args:
+ extra_headers: Send extra headers
+
+ extra_query: Add additional query parameters to the request
+
+ extra_body: Add additional JSON properties to the request
+
+ timeout: Override the client-level default timeout for this request, in seconds
+ """
+ if not workspace_id:
+ raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
+ extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})}
+ extra_headers = {**strip_not_given({"Last-Event-ID": last_event_id}), **(extra_headers or {})}
+ return self._get(
+ path_template("/v1/workspaces/{workspace_id}/status/stream", workspace_id=workspace_id),
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=Workspace,
+ stream=True,
+ stream_cls=Stream[Workspace],
+ )
+
class AsyncWorkspacesResource(AsyncAPIResource):
@cached_property
@@ -449,7 +491,7 @@ async def retrieve(
if not workspace_id:
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
return await self._get(
- f"/v1/workspaces/{workspace_id}",
+ path_template("/v1/workspaces/{workspace_id}", workspace_id=workspace_id),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -494,7 +536,7 @@ async def update(
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
extra_headers = {"If-Match": if_match, **(extra_headers or {})}
return await self._patch(
- f"/v1/workspaces/{workspace_id}",
+ path_template("/v1/workspaces/{workspace_id}", workspace_id=workspace_id),
body=await async_maybe_transform(
{
"memory_mib": memory_mib,
@@ -587,7 +629,7 @@ async def delete(
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
extra_headers = {"If-Match": if_match, **(extra_headers or {})}
return await self._delete(
- f"/v1/workspaces/{workspace_id}",
+ path_template("/v1/workspaces/{workspace_id}", workspace_id=workspace_id),
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
@@ -598,6 +640,47 @@ async def delete(
cast_to=Workspace,
)
+ async def stream_status(
+ self,
+ workspace_id: str,
+ *,
+ last_event_id: str | Omit = omit,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> AsyncStream[Workspace]:
+ """Streams workspace lifecycle updates over Server-Sent Events.
+
+ Each `status` event
+ contains a full `LifecycleResponse` payload. The stream closes after the
+ workspace reaches its current desired state.
+
+ Args:
+ extra_headers: Send extra headers
+
+ extra_query: Add additional query parameters to the request
+
+ extra_body: Add additional JSON properties to the request
+
+ timeout: Override the client-level default timeout for this request, in seconds
+ """
+ if not workspace_id:
+ raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
+ extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})}
+ extra_headers = {**strip_not_given({"Last-Event-ID": last_event_id}), **(extra_headers or {})}
+ return await self._get(
+ path_template("/v1/workspaces/{workspace_id}/status/stream", workspace_id=workspace_id),
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=Workspace,
+ stream=True,
+ stream_cls=AsyncStream[Workspace],
+ )
+
class WorkspacesResourceWithRawResponse:
def __init__(self, workspaces: WorkspacesResource) -> None:
@@ -618,6 +701,9 @@ def __init__(self, workspaces: WorkspacesResource) -> None:
self.delete = to_raw_response_wrapper(
workspaces.delete,
)
+ self.stream_status = to_raw_response_wrapper(
+ workspaces.stream_status,
+ )
@cached_property
def artifacts(self) -> ArtifactsResourceWithRawResponse:
@@ -659,6 +745,9 @@ def __init__(self, workspaces: AsyncWorkspacesResource) -> None:
self.delete = async_to_raw_response_wrapper(
workspaces.delete,
)
+ self.stream_status = async_to_raw_response_wrapper(
+ workspaces.stream_status,
+ )
@cached_property
def artifacts(self) -> AsyncArtifactsResourceWithRawResponse:
@@ -700,6 +789,9 @@ def __init__(self, workspaces: WorkspacesResource) -> None:
self.delete = to_streamed_response_wrapper(
workspaces.delete,
)
+ self.stream_status = to_streamed_response_wrapper(
+ workspaces.stream_status,
+ )
@cached_property
def artifacts(self) -> ArtifactsResourceWithStreamingResponse:
@@ -741,6 +833,9 @@ def __init__(self, workspaces: AsyncWorkspacesResource) -> None:
self.delete = async_to_streamed_response_wrapper(
workspaces.delete,
)
+ self.stream_status = async_to_streamed_response_wrapper(
+ workspaces.stream_status,
+ )
@cached_property
def artifacts(self) -> AsyncArtifactsResourceWithStreamingResponse:
diff --git a/src/dedalus_sdk/types/__init__.py b/src/dedalus_sdk/types/__init__.py
index c2e59c4..2fcb234 100644
--- a/src/dedalus_sdk/types/__init__.py
+++ b/src/dedalus_sdk/types/__init__.py
@@ -8,3 +8,4 @@
from .workspace_list_params import WorkspaceListParams as WorkspaceListParams
from .workspace_create_params import WorkspaceCreateParams as WorkspaceCreateParams
from .workspace_update_params import WorkspaceUpdateParams as WorkspaceUpdateParams
+from .websocket_connection_options import WebSocketConnectionOptions as WebSocketConnectionOptions
diff --git a/src/dedalus_sdk/types/websocket_connection_options.py b/src/dedalus_sdk/types/websocket_connection_options.py
new file mode 100644
index 0000000..36fcfa9
--- /dev/null
+++ b/src/dedalus_sdk/types/websocket_connection_options.py
@@ -0,0 +1,36 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing_extensions import Sequence, TypedDict
+
+if TYPE_CHECKING:
+ from websockets import Subprotocol
+ from websockets.extensions import ClientExtensionFactory
+
+
+class WebSocketConnectionOptions(TypedDict, total=False):
+ """WebSocket connection options copied from `websockets`.
+
+ For example: https://websockets.readthedocs.io/en/stable/reference/asyncio/client.html#websockets.asyncio.client.connect
+ """
+
+ extensions: Sequence[ClientExtensionFactory] | None
+ """List of supported extensions, in order in which they should be negotiated and run."""
+
+ subprotocols: Sequence[Subprotocol] | None
+ """List of supported subprotocols, in order of decreasing preference."""
+
+ compression: str | None
+ """The “permessage-deflate” extension is enabled by default. Set compression to None to disable it. See the [compression guide](https://websockets.readthedocs.io/en/stable/topics/compression.html) for details."""
+
+ # limits
+ max_size: int | None
+ """Maximum size of incoming messages in bytes. None disables the limit."""
+
+ max_queue: int | None | tuple[int | None, int | None]
+ """High-water mark of the buffer where frames are received. It defaults to 16 frames. The low-water mark defaults to max_queue // 4. You may pass a (high, low) tuple to set the high-water and low-water marks. If you want to disable flow control entirely, you may set it to None, although that’s a bad idea."""
+
+ write_limit: int | tuple[int, int | None]
+ """High-water mark of write buffer in bytes. It is passed to set_write_buffer_limits(). It defaults to 32 KiB. You may pass a (high, low) tuple to set the high-water and low-water marks."""
diff --git a/src/dedalus_sdk/types/workspaces/__init__.py b/src/dedalus_sdk/types/workspaces/__init__.py
index f6bfc86..56ca47f 100644
--- a/src/dedalus_sdk/types/workspaces/__init__.py
+++ b/src/dedalus_sdk/types/workspaces/__init__.py
@@ -22,9 +22,19 @@
from .ssh_create_params import SSHCreateParams as SSHCreateParams
from .preview_list_params import PreviewListParams as PreviewListParams
from .artifact_list_params import ArtifactListParams as ArtifactListParams
+from .terminal_error_event import TerminalErrorEvent as TerminalErrorEvent
+from .terminal_input_event import TerminalInputEvent as TerminalInputEvent
from .terminal_list_params import TerminalListParams as TerminalListParams
from .execution_list_params import ExecutionListParams as ExecutionListParams
from .preview_create_params import PreviewCreateParams as PreviewCreateParams
+from .terminal_client_event import TerminalClientEvent as TerminalClientEvent
+from .terminal_closed_event import TerminalClosedEvent as TerminalClosedEvent
+from .terminal_output_event import TerminalOutputEvent as TerminalOutputEvent
+from .terminal_resize_event import TerminalResizeEvent as TerminalResizeEvent
+from .terminal_server_event import TerminalServerEvent as TerminalServerEvent
from .terminal_create_params import TerminalCreateParams as TerminalCreateParams
from .execution_create_params import ExecutionCreateParams as ExecutionCreateParams
from .execution_events_params import ExecutionEventsParams as ExecutionEventsParams
+from .terminal_input_event_param import TerminalInputEventParam as TerminalInputEventParam
+from .terminal_client_event_param import TerminalClientEventParam as TerminalClientEventParam
+from .terminal_resize_event_param import TerminalResizeEventParam as TerminalResizeEventParam
diff --git a/src/dedalus_sdk/types/workspaces/terminal_client_event.py b/src/dedalus_sdk/types/workspaces/terminal_client_event.py
new file mode 100644
index 0000000..94fb770
--- /dev/null
+++ b/src/dedalus_sdk/types/workspaces/terminal_client_event.py
@@ -0,0 +1,14 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing import Union
+from typing_extensions import Annotated, TypeAlias
+
+from ..._utils import PropertyInfo
+from .terminal_input_event import TerminalInputEvent
+from .terminal_resize_event import TerminalResizeEvent
+
+__all__ = ["TerminalClientEvent"]
+
+TerminalClientEvent: TypeAlias = Annotated[
+ Union[TerminalInputEvent, TerminalResizeEvent], PropertyInfo(discriminator="type")
+]
diff --git a/src/dedalus_sdk/types/workspaces/terminal_client_event_param.py b/src/dedalus_sdk/types/workspaces/terminal_client_event_param.py
new file mode 100644
index 0000000..d309564
--- /dev/null
+++ b/src/dedalus_sdk/types/workspaces/terminal_client_event_param.py
@@ -0,0 +1,13 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from __future__ import annotations
+
+from typing import Union
+from typing_extensions import TypeAlias
+
+from .terminal_input_event_param import TerminalInputEventParam
+from .terminal_resize_event_param import TerminalResizeEventParam
+
+__all__ = ["TerminalClientEventParam"]
+
+TerminalClientEventParam: TypeAlias = Union[TerminalInputEventParam, TerminalResizeEventParam]
diff --git a/src/dedalus_sdk/types/workspaces/terminal_closed_event.py b/src/dedalus_sdk/types/workspaces/terminal_closed_event.py
new file mode 100644
index 0000000..62c9e93
--- /dev/null
+++ b/src/dedalus_sdk/types/workspaces/terminal_closed_event.py
@@ -0,0 +1,11 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing_extensions import Literal
+
+from ..._models import BaseModel
+
+__all__ = ["TerminalClosedEvent"]
+
+
+class TerminalClosedEvent(BaseModel):
+ type: Literal["closed"]
diff --git a/src/dedalus_sdk/types/workspaces/terminal_error_event.py b/src/dedalus_sdk/types/workspaces/terminal_error_event.py
new file mode 100644
index 0000000..d1dd0d1
--- /dev/null
+++ b/src/dedalus_sdk/types/workspaces/terminal_error_event.py
@@ -0,0 +1,16 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing import Optional
+from typing_extensions import Literal
+
+from ..._models import BaseModel
+
+__all__ = ["TerminalErrorEvent"]
+
+
+class TerminalErrorEvent(BaseModel):
+ type: Literal["error"]
+
+ error_code: Optional[str] = None
+
+ error_message: Optional[str] = None
diff --git a/src/dedalus_sdk/types/workspaces/terminal_input_event.py b/src/dedalus_sdk/types/workspaces/terminal_input_event.py
new file mode 100644
index 0000000..259f896
--- /dev/null
+++ b/src/dedalus_sdk/types/workspaces/terminal_input_event.py
@@ -0,0 +1,14 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing_extensions import Literal
+
+from ..._models import BaseModel
+
+__all__ = ["TerminalInputEvent"]
+
+
+class TerminalInputEvent(BaseModel):
+ data: str
+ """Base64-encoded terminal input."""
+
+ type: Literal["input"]
diff --git a/src/dedalus_sdk/types/workspaces/terminal_input_event_param.py b/src/dedalus_sdk/types/workspaces/terminal_input_event_param.py
new file mode 100644
index 0000000..17f1984
--- /dev/null
+++ b/src/dedalus_sdk/types/workspaces/terminal_input_event_param.py
@@ -0,0 +1,22 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from __future__ import annotations
+
+from typing import Union
+from typing_extensions import Literal, Required, Annotated, TypedDict
+
+from ..._types import Base64FileInput
+from ..._utils import PropertyInfo
+from ..._models import set_pydantic_config
+
+__all__ = ["TerminalInputEventParam"]
+
+
+class TerminalInputEventParam(TypedDict, total=False):
+ data: Required[Annotated[Union[str, Base64FileInput], PropertyInfo(format="base64")]]
+ """Base64-encoded terminal input."""
+
+ type: Required[Literal["input"]]
+
+
+set_pydantic_config(TerminalInputEventParam, {"arbitrary_types_allowed": True})
diff --git a/src/dedalus_sdk/types/workspaces/terminal_output_event.py b/src/dedalus_sdk/types/workspaces/terminal_output_event.py
new file mode 100644
index 0000000..a6fe0c5
--- /dev/null
+++ b/src/dedalus_sdk/types/workspaces/terminal_output_event.py
@@ -0,0 +1,14 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing_extensions import Literal
+
+from ..._models import BaseModel
+
+__all__ = ["TerminalOutputEvent"]
+
+
+class TerminalOutputEvent(BaseModel):
+ data: str
+ """Base64-encoded terminal output."""
+
+ type: Literal["output"]
diff --git a/src/dedalus_sdk/types/workspaces/terminal_resize_event.py b/src/dedalus_sdk/types/workspaces/terminal_resize_event.py
new file mode 100644
index 0000000..b69dd07
--- /dev/null
+++ b/src/dedalus_sdk/types/workspaces/terminal_resize_event.py
@@ -0,0 +1,15 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing_extensions import Literal
+
+from ..._models import BaseModel
+
+__all__ = ["TerminalResizeEvent"]
+
+
+class TerminalResizeEvent(BaseModel):
+ height: int
+
+ type: Literal["resize"]
+
+ width: int
diff --git a/src/dedalus_sdk/types/workspaces/terminal_resize_event_param.py b/src/dedalus_sdk/types/workspaces/terminal_resize_event_param.py
new file mode 100644
index 0000000..3b03e5e
--- /dev/null
+++ b/src/dedalus_sdk/types/workspaces/terminal_resize_event_param.py
@@ -0,0 +1,15 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from __future__ import annotations
+
+from typing_extensions import Literal, Required, TypedDict
+
+__all__ = ["TerminalResizeEventParam"]
+
+
+class TerminalResizeEventParam(TypedDict, total=False):
+ height: Required[int]
+
+ type: Required[Literal["resize"]]
+
+ width: Required[int]
diff --git a/src/dedalus_sdk/types/workspaces/terminal_server_event.py b/src/dedalus_sdk/types/workspaces/terminal_server_event.py
new file mode 100644
index 0000000..d51df44
--- /dev/null
+++ b/src/dedalus_sdk/types/workspaces/terminal_server_event.py
@@ -0,0 +1,15 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing import Union
+from typing_extensions import Annotated, TypeAlias
+
+from ..._utils import PropertyInfo
+from .terminal_error_event import TerminalErrorEvent
+from .terminal_closed_event import TerminalClosedEvent
+from .terminal_output_event import TerminalOutputEvent
+
+__all__ = ["TerminalServerEvent"]
+
+TerminalServerEvent: TypeAlias = Annotated[
+ Union[TerminalOutputEvent, TerminalErrorEvent, TerminalClosedEvent], PropertyInfo(discriminator="type")
+]
diff --git a/tests/api_resources/test_workspaces.py b/tests/api_resources/test_workspaces.py
index 3f9f3ae..f898002 100644
--- a/tests/api_resources/test_workspaces.py
+++ b/tests/api_resources/test_workspaces.py
@@ -222,6 +222,51 @@ def test_path_params_delete(self, client: Dedalus) -> None:
if_match="If-Match",
)
+ @parametrize
+ def test_method_stream_status(self, client: Dedalus) -> None:
+ workspace_stream = client.workspaces.stream_status(
+ workspace_id="workspace_id",
+ )
+ workspace_stream.response.close()
+
+ @parametrize
+ def test_method_stream_status_with_all_params(self, client: Dedalus) -> None:
+ workspace_stream = client.workspaces.stream_status(
+ workspace_id="workspace_id",
+ last_event_id="Last-Event-ID",
+ )
+ workspace_stream.response.close()
+
+ @parametrize
+ def test_raw_response_stream_status(self, client: Dedalus) -> None:
+ response = client.workspaces.with_raw_response.stream_status(
+ workspace_id="workspace_id",
+ )
+
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ stream = response.parse()
+ stream.close()
+
+ @parametrize
+ def test_streaming_response_stream_status(self, client: Dedalus) -> None:
+ with client.workspaces.with_streaming_response.stream_status(
+ workspace_id="workspace_id",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ stream = response.parse()
+ stream.close()
+
+ assert cast(Any, response.is_closed) is True
+
+ @parametrize
+ def test_path_params_stream_status(self, client: Dedalus) -> None:
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `workspace_id` but received ''"):
+ client.workspaces.with_raw_response.stream_status(
+ workspace_id="",
+ )
+
class TestAsyncWorkspaces:
parametrize = pytest.mark.parametrize(
@@ -430,3 +475,48 @@ async def test_path_params_delete(self, async_client: AsyncDedalus) -> None:
workspace_id="",
if_match="If-Match",
)
+
+ @parametrize
+ async def test_method_stream_status(self, async_client: AsyncDedalus) -> None:
+ workspace_stream = await async_client.workspaces.stream_status(
+ workspace_id="workspace_id",
+ )
+ await workspace_stream.response.aclose()
+
+ @parametrize
+ async def test_method_stream_status_with_all_params(self, async_client: AsyncDedalus) -> None:
+ workspace_stream = await async_client.workspaces.stream_status(
+ workspace_id="workspace_id",
+ last_event_id="Last-Event-ID",
+ )
+ await workspace_stream.response.aclose()
+
+ @parametrize
+ async def test_raw_response_stream_status(self, async_client: AsyncDedalus) -> None:
+ response = await async_client.workspaces.with_raw_response.stream_status(
+ workspace_id="workspace_id",
+ )
+
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ stream = await response.parse()
+ await stream.close()
+
+ @parametrize
+ async def test_streaming_response_stream_status(self, async_client: AsyncDedalus) -> None:
+ async with async_client.workspaces.with_streaming_response.stream_status(
+ workspace_id="workspace_id",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ stream = await response.parse()
+ await stream.close()
+
+ assert cast(Any, response.is_closed) is True
+
+ @parametrize
+ async def test_path_params_stream_status(self, async_client: AsyncDedalus) -> None:
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `workspace_id` but received ''"):
+ await async_client.workspaces.with_raw_response.stream_status(
+ workspace_id="",
+ )
diff --git a/tests/test_client.py b/tests/test_client.py
index e15d38e..a08e370 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -23,6 +23,7 @@
from dedalus_sdk._types import Omit
from dedalus_sdk._utils import asyncify
from dedalus_sdk._models import BaseModel, FinalRequestOptions
+from dedalus_sdk._streaming import Stream, AsyncStream
from dedalus_sdk._exceptions import APIStatusError, APITimeoutError, APIResponseValidationError
from dedalus_sdk._base_client import (
DEFAULT_TIMEOUT,
@@ -836,6 +837,17 @@ def test_client_max_retries_validation(self) -> None:
with pytest.raises(TypeError, match=r"max_retries cannot be None"):
Dedalus(base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None))
+ @pytest.mark.respx(base_url=base_url)
+ def test_default_stream_cls(self, respx_mock: MockRouter, client: Dedalus) -> None:
+ class Model(BaseModel):
+ name: str
+
+ respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+
+ stream = client.post("/foo", cast_to=Model, stream=True, stream_cls=Stream[Model])
+ assert isinstance(stream, Stream)
+ stream.response.close()
+
@pytest.mark.respx(base_url=base_url)
def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
class Model(BaseModel):
@@ -1786,6 +1798,17 @@ async def test_client_max_retries_validation(self) -> None:
base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None)
)
+ @pytest.mark.respx(base_url=base_url)
+ async def test_default_stream_cls(self, respx_mock: MockRouter, async_client: AsyncDedalus) -> None:
+ class Model(BaseModel):
+ name: str
+
+ respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+
+ stream = await async_client.post("/foo", cast_to=Model, stream=True, stream_cls=AsyncStream[Model])
+ assert isinstance(stream, AsyncStream)
+ await stream.response.aclose()
+
@pytest.mark.respx(base_url=base_url)
async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
class Model(BaseModel):
diff --git a/tests/test_utils/test_path.py b/tests/test_utils/test_path.py
new file mode 100644
index 0000000..9f0b3e8
--- /dev/null
+++ b/tests/test_utils/test_path.py
@@ -0,0 +1,89 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from dedalus_sdk._utils._path import path_template
+
+
+@pytest.mark.parametrize(
+ "template, kwargs, expected",
+ [
+ ("/v1/{id}", dict(id="abc"), "/v1/abc"),
+ ("/v1/{a}/{b}", dict(a="x", b="y"), "/v1/x/y"),
+ ("/v1/{a}{b}/path/{c}?val={d}#{e}", dict(a="x", b="y", c="z", d="u", e="v"), "/v1/xy/path/z?val=u#v"),
+ ("/{w}/{w}", dict(w="echo"), "/echo/echo"),
+ ("/v1/static", {}, "/v1/static"),
+ ("", {}, ""),
+ ("/v1/?q={n}&count=10", dict(n=42), "/v1/?q=42&count=10"),
+ ("/v1/{v}", dict(v=None), "/v1/null"),
+ ("/v1/{v}", dict(v=True), "/v1/true"),
+ ("/v1/{v}", dict(v=False), "/v1/false"),
+ ("/v1/{v}", dict(v=".hidden"), "/v1/.hidden"), # dot prefix ok
+ ("/v1/{v}", dict(v="file.txt"), "/v1/file.txt"), # dot in middle ok
+ ("/v1/{v}", dict(v="..."), "/v1/..."), # triple dot ok
+ ("/v1/{a}{b}", dict(a=".", b="txt"), "/v1/.txt"), # dot var combining with adjacent to be ok
+ ("/items?q={v}#{f}", dict(v=".", f=".."), "/items?q=.#.."), # dots in query/fragment are fine
+ (
+ "/v1/{a}?query={b}",
+ dict(a="../../other/endpoint", b="a&bad=true"),
+ "/v1/..%2F..%2Fother%2Fendpoint?query=a%26bad%3Dtrue",
+ ),
+ ("/v1/{val}", dict(val="a/b/c"), "/v1/a%2Fb%2Fc"),
+ ("/v1/{val}", dict(val="a/b/c?query=value"), "/v1/a%2Fb%2Fc%3Fquery=value"),
+ ("/v1/{val}", dict(val="a/b/c?query=value&bad=true"), "/v1/a%2Fb%2Fc%3Fquery=value&bad=true"),
+ ("/v1/{val}", dict(val="%20"), "/v1/%2520"), # escapes escape sequences in input
+ # Query: slash and ? are safe, # is not
+ ("/items?q={v}", dict(v="a/b"), "/items?q=a/b"),
+ ("/items?q={v}", dict(v="a?b"), "/items?q=a?b"),
+ ("/items?q={v}", dict(v="a#b"), "/items?q=a%23b"),
+ ("/items?q={v}", dict(v="a b"), "/items?q=a%20b"),
+ # Fragment: slash and ? are safe
+ ("/docs#{v}", dict(v="a/b"), "/docs#a/b"),
+ ("/docs#{v}", dict(v="a?b"), "/docs#a?b"),
+ # Path: slash, ? and # are all encoded
+ ("/v1/{v}", dict(v="a/b"), "/v1/a%2Fb"),
+ ("/v1/{v}", dict(v="a?b"), "/v1/a%3Fb"),
+ ("/v1/{v}", dict(v="a#b"), "/v1/a%23b"),
+ # same var encoded differently by component
+ (
+ "/v1/{v}?q={v}#{v}",
+ dict(v="a/b?c#d"),
+ "/v1/a%2Fb%3Fc%23d?q=a/b?c%23d#a/b?c%23d",
+ ),
+ ("/v1/{val}", dict(val="x?admin=true"), "/v1/x%3Fadmin=true"), # query injection
+ ("/v1/{val}", dict(val="x#admin"), "/v1/x%23admin"), # fragment injection
+ ],
+)
+def test_interpolation(template: str, kwargs: dict[str, Any], expected: str) -> None:
+ assert path_template(template, **kwargs) == expected
+
+
+def test_missing_kwarg_raises_key_error() -> None:
+ with pytest.raises(KeyError, match="org_id"):
+ path_template("/v1/{org_id}")
+
+
+@pytest.mark.parametrize(
+ "template, kwargs",
+ [
+ ("{a}/path", dict(a=".")),
+ ("{a}/path", dict(a="..")),
+ ("/v1/{a}", dict(a=".")),
+ ("/v1/{a}", dict(a="..")),
+ ("/v1/{a}/path", dict(a=".")),
+ ("/v1/{a}/path", dict(a="..")),
+ ("/v1/{a}{b}", dict(a=".", b=".")), # adjacent vars → ".."
+ ("/v1/{a}.", dict(a=".")), # var + static → ".."
+ ("/v1/{a}{b}", dict(a="", b=".")), # empty + dot → "."
+ ("/v1/%2e/{x}", dict(x="ok")), # encoded dot in static text
+ ("/v1/%2e./{x}", dict(x="ok")), # mixed encoded ".." in static
+ ("/v1/.%2E/{x}", dict(x="ok")), # mixed encoded ".." in static
+ ("/v1/{v}?q=1", dict(v="..")),
+ ("/v1/{v}#frag", dict(v="..")),
+ ],
+)
+def test_dot_segment_rejected(template: str, kwargs: dict[str, Any]) -> None:
+ with pytest.raises(ValueError, match="dot-segment"):
+ path_template(template, **kwargs)
diff --git a/uv.lock b/uv.lock
index 054f543..91129d1 100644
--- a/uv.lock
+++ b/uv.lock
@@ -261,6 +261,9 @@ aiohttp = [
{ name = "aiohttp" },
{ name = "httpx-aiohttp" },
]
+websockets = [
+ { name = "websockets" },
+]
[package.dev-dependencies]
dev = [
@@ -296,8 +299,9 @@ requires-dist = [
{ name = "pydantic", specifier = ">=1.9.0,<3" },
{ name = "sniffio" },
{ name = "typing-extensions", specifier = ">=4.14,<5" },
+ { name = "websockets", marker = "extra == 'websockets'", specifier = ">=13,<16" },
]
-provides-extras = ["aiohttp"]
+provides-extras = ["aiohttp", "websockets"]
[package.metadata.requires-dev]
dev = [
@@ -1677,6 +1681,82 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
+[[package]]
+name = "websockets"
+version = "15.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" },
+ { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" },
+ { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" },
+ { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
+ { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
+ { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
+ { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
+ { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
+ { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
+ { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
+ { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
+ { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
+ { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
+ { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
+ { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
+ { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
+ { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
+ { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
+ { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
+ { url = "https://files.pythonhosted.org/packages/36/db/3fff0bcbe339a6fa6a3b9e3fbc2bfb321ec2f4cd233692272c5a8d6cf801/websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5", size = 175424, upload-time = "2025-03-05T20:02:56.505Z" },
+ { url = "https://files.pythonhosted.org/packages/46/e6/519054c2f477def4165b0ec060ad664ed174e140b0d1cbb9fafa4a54f6db/websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a", size = 173077, upload-time = "2025-03-05T20:02:58.37Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/21/c0712e382df64c93a0d16449ecbf87b647163485ca1cc3f6cbadb36d2b03/websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b", size = 173324, upload-time = "2025-03-05T20:02:59.773Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/cb/51ba82e59b3a664df54beed8ad95517c1b4dc1a913730e7a7db778f21291/websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770", size = 182094, upload-time = "2025-03-05T20:03:01.827Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/0f/bf3788c03fec679bcdaef787518dbe60d12fe5615a544a6d4cf82f045193/websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb", size = 181094, upload-time = "2025-03-05T20:03:03.123Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/da/9fb8c21edbc719b66763a571afbaf206cb6d3736d28255a46fc2fe20f902/websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054", size = 181397, upload-time = "2025-03-05T20:03:04.443Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/65/65f379525a2719e91d9d90c38fe8b8bc62bd3c702ac651b7278609b696c4/websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee", size = 181794, upload-time = "2025-03-05T20:03:06.708Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/26/31ac2d08f8e9304d81a1a7ed2851c0300f636019a57cbaa91342015c72cc/websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed", size = 181194, upload-time = "2025-03-05T20:03:08.844Z" },
+ { url = "https://files.pythonhosted.org/packages/98/72/1090de20d6c91994cd4b357c3f75a4f25ee231b63e03adea89671cc12a3f/websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880", size = 181164, upload-time = "2025-03-05T20:03:10.242Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/37/098f2e1c103ae8ed79b0e77f08d83b0ec0b241cf4b7f2f10edd0126472e1/websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411", size = 176381, upload-time = "2025-03-05T20:03:12.77Z" },
+ { url = "https://files.pythonhosted.org/packages/75/8b/a32978a3ab42cebb2ebdd5b05df0696a09f4d436ce69def11893afa301f0/websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4", size = 176841, upload-time = "2025-03-05T20:03:14.367Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" },
+ { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/48/4b67623bac4d79beb3a6bb27b803ba75c1bdedc06bd827e465803690a4b2/websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940", size = 173106, upload-time = "2025-03-05T20:03:29.404Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/f0/adb07514a49fe5728192764e04295be78859e4a537ab8fcc518a3dbb3281/websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e", size = 173339, upload-time = "2025-03-05T20:03:30.755Z" },
+ { url = "https://files.pythonhosted.org/packages/87/28/bd23c6344b18fb43df40d0700f6d3fffcd7cef14a6995b4f976978b52e62/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9", size = 174597, upload-time = "2025-03-05T20:03:32.247Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/79/ca288495863d0f23a60f546f0905ae8f3ed467ad87f8b6aceb65f4c013e4/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b", size = 174205, upload-time = "2025-03-05T20:03:33.731Z" },
+ { url = "https://files.pythonhosted.org/packages/04/e4/120ff3180b0872b1fe6637f6f995bcb009fb5c87d597c1fc21456f50c848/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f", size = 174150, upload-time = "2025-03-05T20:03:35.757Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/c3/30e2f9c539b8da8b1d76f64012f3b19253271a63413b2d3adb94b143407f/websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123", size = 176877, upload-time = "2025-03-05T20:03:37.199Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
+]
+
[[package]]
name = "yarl"
version = "1.22.0"