Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 0 additions & 20 deletions .github/workflows/release-doctor.yml

This file was deleted.

2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.1.0"
".": "0.2.0"
}
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
# Changelog

## 0.2.0 (2026-04-17)

Full Changelog: [v0.1.0...v0.2.0](https://github.com/dedalus-labs/dedalus-python/compare/v0.1.0...v0.2.0)

### Features

* **client:** add event handler implementation for websockets ([d46b70f](https://github.com/dedalus-labs/dedalus-python/commit/d46b70f7330c7441113cc3aa3cb61b41880c2894))
* **client:** add path parameters for web sockets ([a805582](https://github.com/dedalus-labs/dedalus-python/commit/a8055824a4bf357d1e8394f02995eb9811df4b18))
* **client:** allow enqueuing to websockets even when not connected ([3c790cf](https://github.com/dedalus-labs/dedalus-python/commit/3c790cf35c3a28645cf65b2fa199fceb53677990))
* **client:** support reconnection in websockets ([e5e6c38](https://github.com/dedalus-labs/dedalus-python/commit/e5e6c38b62bf19f8d85538555251650f6ab1dd91))
* **client:** support sending raw data over websockets ([235869c](https://github.com/dedalus-labs/dedalus-python/commit/235869ccaad5bd8d9c7cffc82df7902b2e324205))


### Bug Fixes

* **client:** preserve hardcoded query params when merging with user params ([9d1b3a5](https://github.com/dedalus-labs/dedalus-python/commit/9d1b3a5b6c3a44600ea52faa57188fc7c8b6a56c))
* ensure file data are only sent as 1 parameter ([41853cb](https://github.com/dedalus-labs/dedalus-python/commit/41853cbc3978376af90de18138b9aaa6518a8c28))


### Performance Improvements

* **client:** optimize file structure copying in multipart requests ([f1a1920](https://github.com/dedalus-labs/dedalus-python/commit/f1a1920db2ae8618db678ca4a48ed4bac1c07c4a))


### Chores

* **ci:** remove release-doctor workflow ([20dab67](https://github.com/dedalus-labs/dedalus-python/commit/20dab671a0fef62c936a1fd1c23c71ece9101b31))
* **internal:** codegen related update ([126141f](https://github.com/dedalus-labs/dedalus-python/commit/126141f2387570bbff3e841a011a5ccd4e75e1ae))
* **tests:** bump steady to v0.22.1 ([b1e0be9](https://github.com/dedalus-labs/dedalus-python/commit/b1e0be98f18c4d06c5f46abaf3c3491b875ad7da))

## 0.1.0 (2026-04-02)

Full Changelog: [v0.0.4...v0.1.0](https://github.com/dedalus-labs/dedalus-python/compare/v0.0.4...v0.1.0)
Expand Down
17 changes: 0 additions & 17 deletions bin/check-release-environment

This file was deleted.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "dedalus-sdk"
version = "0.1.0"
version = "0.2.0"
description = "The official Python library for the Dedalus API"
dynamic = ["readme"]
license = "MIT"
Expand Down
6 changes: 3 additions & 3 deletions scripts/mock
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}"
# 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=@stdy/cli@0.20.2 -- steady --version
npm exec --package=@stdy/cli@0.22.1 -- steady --version

npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log &
npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log &

# Wait for server to come online via health endpoint (max 30s)
echo -n "Waiting for server"
Expand All @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then

echo
else
npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL"
npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL"
fi
2 changes: 1 addition & 1 deletion scripts/test
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ elif ! steady_is_running ; then
echo -e "To run the server, pass in the path or url of your OpenAPI"
echo -e "spec to the steady command:"
echo
echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.2 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}"
echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.22.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}"
echo

exit 1
Expand Down
7 changes: 7 additions & 0 deletions src/dedalus_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@
AuthenticationError,
InternalServerError,
PermissionDeniedError,
WebSocketQueueFullError,
UnprocessableEntityError,
APIResponseValidationError,
WebSocketConnectionClosedError,
)
from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient
from ._utils._logs import setup_logging as _setup_logging
from .types.websocket_reconnection import ReconnectingEvent, ReconnectingOverrides

__all__ = [
"types",
Expand Down Expand Up @@ -71,6 +74,10 @@
"DefaultHttpxClient",
"DefaultAsyncHttpxClient",
"DefaultAioHttpClient",
"ReconnectingEvent",
"ReconnectingOverrides",
"WebSocketQueueFullError",
"WebSocketConnectionClosedError",
]

if not _t.TYPE_CHECKING:
Expand Down
4 changes: 4 additions & 0 deletions src/dedalus_sdk/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,10 @@ def _build_request(
files = cast(HttpxRequestFiles, ForceMultipartDict())

prepared_url = self._prepare_url(options.url)
# preserve hard-coded query params from the url
if params and prepared_url.query:
params = {**dict(prepared_url.params.items()), **params}
prepared_url = prepared_url.copy_with(raw_path=prepared_url.raw_path.split(b"?", 1)[0])
if "_" in prepared_url.host:
# work around https://github.com/encode/httpx/discussions/2880
kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")}
Expand Down
85 changes: 85 additions & 0 deletions src/dedalus_sdk/_event_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

from __future__ import annotations

import threading
from typing import Any, Callable

EventHandler = Callable[..., Any]


class EventHandlerRegistry:
"""Thread-safe (optional) registry of event handlers."""

def __init__(self, *, use_lock: bool = False) -> None:
self._handlers: dict[str, list[EventHandler]] = {}
self._once_ids: set[int] = set()
self._lock: threading.Lock | None = threading.Lock() if use_lock else None

def _acquire(self) -> None:
if self._lock is not None:
self._lock.acquire()

def _release(self) -> None:
if self._lock is not None:
self._lock.release()

def add(self, event_type: str, handler: EventHandler, *, once: bool = False) -> None:
self._acquire()
try:
handlers = self._handlers.setdefault(event_type, [])
handlers.append(handler)
if once:
self._once_ids.add(id(handler))
finally:
self._release()

def remove(self, event_type: str, handler: EventHandler) -> None:
self._acquire()
try:
handlers = self._handlers.get(event_type)
if handlers is not None:
try:
handlers.remove(handler)
except ValueError:
pass
self._once_ids.discard(id(handler))
finally:
self._release()

def get_handlers(self, event_type: str) -> list[EventHandler]:
"""Return a snapshot of handlers for the given event type, removing once-handlers."""
self._acquire()
try:
handlers = self._handlers.get(event_type)
if not handlers:
return []
result = list(handlers)
to_remove = [h for h in result if id(h) in self._once_ids]
for h in to_remove:
handlers.remove(h)
self._once_ids.discard(id(h))
return result
finally:
self._release()

def has_handlers(self, event_type: str) -> bool:
self._acquire()
try:
handlers = self._handlers.get(event_type)
return bool(handlers)
finally:
self._release()

def merge_into(self, target: EventHandlerRegistry) -> None:
"""Move all handlers from this registry into *target*, then clear self."""
self._acquire()
try:
for event_type, handlers in self._handlers.items():
for handler in handlers:
once = id(handler) in self._once_ids
target.add(event_type, handler, once=once)
self._handlers.clear()
self._once_ids.clear()
finally:
self._release()
18 changes: 18 additions & 0 deletions src/dedalus_sdk/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"UnprocessableEntityError",
"RateLimitError",
"InternalServerError",
"WebSocketConnectionClosedError",
"WebSocketQueueFullError",
]


Expand Down Expand Up @@ -106,3 +108,19 @@ class RateLimitError(APIStatusError):

class InternalServerError(APIStatusError):
pass


class WebSocketConnectionClosedError(DedalusError):
"""Raised when a WebSocket connection closes with unsent messages."""

unsent_messages: list[str]

def __init__(self, message: str, *, unsent_messages: list[str]) -> None:
super().__init__(message)
self.unsent_messages = unsent_messages


class WebSocketQueueFullError(DedalusError):
"""Raised when the outgoing WebSocket message queue exceeds its byte-size limit."""

pass
56 changes: 53 additions & 3 deletions src/dedalus_sdk/_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import io
import os
import pathlib
from typing import overload
from typing_extensions import TypeGuard
from typing import Sequence, cast, overload
from typing_extensions import TypeVar, TypeGuard

import anyio

Expand All @@ -17,7 +17,9 @@
HttpxFileContent,
HttpxRequestFiles,
)
from ._utils import is_tuple_t, is_mapping_t, is_sequence_t
from ._utils import is_list, is_mapping, is_tuple_t, is_mapping_t, is_sequence_t

_T = TypeVar("_T")


def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]:
Expand Down Expand Up @@ -121,3 +123,51 @@ async def async_read_file_content(file: FileContent) -> HttpxFileContent:
return await anyio.Path(file).read_bytes()

return file


def deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]]) -> _T:
"""Copy only the containers along the given paths.

Used to guard against mutation by extract_files without copying the entire structure.
Only dicts and lists that lie on a path are copied; everything else
is returned by reference.

For example, given paths=[["foo", "files", "file"]] and the structure:
{
"foo": {
"bar": {"baz": {}},
"files": {"file": <content>}
}
}
The root dict, "foo", and "files" are copied (they lie on the path).
"bar" and "baz" are returned by reference (off the path).
"""
return _deepcopy_with_paths(item, paths, 0)


def _deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]], index: int) -> _T:
if not paths:
return item
if is_mapping(item):
key_to_paths: dict[str, list[Sequence[str]]] = {}
for path in paths:
if index < len(path):
key_to_paths.setdefault(path[index], []).append(path)

# if no path continues through this mapping, it won't be mutated and copying it is redundant
if not key_to_paths:
return item

result = dict(item)
for key, subpaths in key_to_paths.items():
if key in result:
result[key] = _deepcopy_with_paths(result[key], subpaths, index + 1)
return cast(_T, result)
if is_list(item):
array_paths = [path for path in paths if index < len(path) and path[index] == "<array>"]

# if no path expects a list here, nothing will be mutated inside it - return by reference
if not array_paths:
return cast(_T, item)
return cast(_T, [_deepcopy_with_paths(entry, array_paths, index + 1) for entry in item])
return item
Loading
Loading