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"