From 6d2b359f052c26523a7975d2928237dba3ecd88c Mon Sep 17 00:00:00 2001 From: parnexcodes Date: Sun, 11 Oct 2026 06:37:32 +0530 Subject: [PATCH 1/2] feat(sdk): connectors on create, connectors and images lists Adds `connectors=` on docks.create and `DockInfo.connectors`, `client.connectors.list/connect/disconnect`, `client.images.list`, and typed DockError subclasses for connector_not_connected, connector_admin_only, image_unknown and image_unavailable. Bumps SCENARIOS_REF to 05d0182 and maps the new ops in the scenario runner. Closes #17 Refs usedock/dock#59 Co-Authored-By: Claude Opus 5.5 --- README.md | 29 +++++++++++- SCENARIOS_REF | 2 +- src/usedock/__init__.py | 25 +++++++++- src/usedock/client.py | 48 +++++++++++++++++-- src/usedock/errors.py | 31 ++++++++++++ src/usedock/models.py | 77 +++++++++++++++++++++++++++++- tests/test_client.py | 102 +++++++++++++++++++++++++++++++++++++++- tests/test_scenarios.py | 13 ++++- 8 files changed, 316 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index b96963a..b79c389 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ with Dock() as client: # reads DOCK_API_KEY and DOCK_API_URL Methods on `client.docks`: `create`, `list`, `get`, `update`, `exec`, `stop`, `resume`, `branch`, `delete`, and `files.write` / `files.read` / `files.read_bytes` / `files.list`. -`client.operations.get(id)` polls an operation. Create, stop, resume and branch +`client.operations.get(id)` polls an operation. `client.connectors` and `client.images` are described below. Create, stop, resume and branch return when the change is done. Sizes use `cpus`, `memory_mib`, `disk_gib`. Results are dataclasses (`DockInfo`, `DockState`, `ExecResult`, `Operation`). @@ -65,6 +65,28 @@ File helpers raise `DockError` (status 0) with these codes: | `output_truncated` | the API clipped the output at 1 MiB (for example a read of a file over about 768 KB), so partial content is never returned | | `file_not_utf8` | `files.read` got bytes that are not valid UTF-8 | +## Connectors and images + +Connect a workspace to a tool once (workspace admins only), then attach it to +docks by kind. Tokens are never returned. + +```python +client.connectors.connect(workspace_id, "linear", token="lin_api_...") +for c in client.connectors.list(workspace_id): # kind, type, status, read_only, ... + print(c.kind, c.status) + +dock = client.docks.create(workspace_id=workspace_id, connectors=["linear"]) +print(dock.connectors) # ["linear"] + +client.connectors.disconnect(workspace_id, "linear") # -> ConnectorState(kind, status) + +for image in client.images.list(): # the Dockyard catalog + print(image.name, image.size_mib, image.available) +``` + +`github` and `model-keys` are read-only kinds: they are listed but cannot be +connected by token. + ## Errors Every failure raises `DockError` with `code` (the API code such as @@ -80,6 +102,11 @@ except DockError as err: print(err.code, err.status) ``` +Some codes raise a subclass of `DockError`, so you can catch them directly: +`ConnectorNotConnectedError` (`connector_not_connected`, 409), +`ConnectorAdminOnlyError` (`connector_admin_only`, 403), `ImageUnknownError` +(`image_unknown`, 400) and `ImageUnavailableError` (`image_unavailable`, 400). + ## Configuration | Variable | Meaning | Default | diff --git a/SCENARIOS_REF b/SCENARIOS_REF index 1d9aa5a..6c6e2b5 100644 --- a/SCENARIOS_REF +++ b/SCENARIOS_REF @@ -1 +1 @@ -7b86b8de18c838a2d1e5a2de7e15b999c9799d4f +05d0182f83f3f7675567cc812c7801659f9cd3b6 diff --git a/src/usedock/__init__.py b/src/usedock/__init__.py index 0fbcb75..2bf97a2 100644 --- a/src/usedock/__init__.py +++ b/src/usedock/__init__.py @@ -1,14 +1,35 @@ from .client import MAX_WRITE_BYTES, UNSET, Dock -from .errors import DockError -from .models import DockInfo, DockState, ExecResult, Operation +from .errors import ( + ConnectorAdminOnlyError, + ConnectorNotConnectedError, + DockError, + ImageUnavailableError, + ImageUnknownError, +) +from .models import ( + Connector, + ConnectorState, + DockInfo, + DockState, + ExecResult, + Image, + Operation, +) __all__ = [ "MAX_WRITE_BYTES", "UNSET", + "Connector", + "ConnectorAdminOnlyError", + "ConnectorNotConnectedError", + "ConnectorState", "Dock", "DockError", "DockInfo", "DockState", "ExecResult", + "Image", + "ImageUnavailableError", + "ImageUnknownError", "Operation", ] diff --git a/src/usedock/client.py b/src/usedock/client.py index 7bb6204..81e6f89 100644 --- a/src/usedock/client.py +++ b/src/usedock/client.py @@ -3,13 +3,13 @@ import base64 import os from types import TracebackType -from typing import Any, Dict, List, Mapping, Optional, Type, Union +from typing import Any, Dict, List, Mapping, Optional, Sequence, Type, Union from urllib.parse import quote import httpx -from .errors import DockError -from .models import DockInfo, DockState, ExecResult, Operation +from .errors import DockError, error_for +from .models import Connector, ConnectorState, DockInfo, DockState, ExecResult, Image, Operation DEFAULT_BASE_URL = "https://api.usedock.io" @@ -80,6 +80,8 @@ def __init__( ) self.docks = Docks(self) self.operations = Operations(self) + self.connectors = Connectors(self) + self.images = Images(self) def close(self) -> None: self._http.close() @@ -122,7 +124,7 @@ def _request( raise DockError("invalid_response", response.status_code, "response was not JSON") if envelope.get("ok") is True and response.is_success: return envelope.get("data") - raise DockError( + raise error_for( str(envelope.get("code") or "unknown_error"), response.status_code, str(envelope.get("message") or response.reason_phrase), @@ -145,6 +147,7 @@ def create( disk_gib: Optional[int] = None, ttl_seconds: Optional[int] = None, env: Optional[Mapping[str, str]] = None, + connectors: Optional[Sequence[str]] = None, environment: Optional[str] = None, no_env: Optional[bool] = None, idempotency_key: Optional[str] = None, @@ -161,6 +164,7 @@ def create( diskGiB=disk_gib, ttlSeconds=ttl_seconds, env=dict(env) if env is not None else None, + connectors=list(connectors) if connectors is not None else None, environment=environment, noEnv=no_env, ), @@ -363,3 +367,39 @@ def __init__(self, client: Dock) -> None: def get(self, operation_id: str) -> Operation: data = self._c._request("GET", f"/v1/operations/{_seg(operation_id)}") return Operation.from_dict(data) + + +class Connectors: + """Workspace tool connections. Admins connect and disconnect; members can list. + Tokens are never returned.""" + + def __init__(self, client: Dock) -> None: + self._c = client + + def list(self, workspace_id: str) -> List[Connector]: + data = self._c._request("GET", f"/v1/workspaces/{_seg(workspace_id)}/connectors") + return [Connector.from_dict(d) for d in data] + + def connect(self, workspace_id: str, kind: str, token: str) -> Connector: + data = self._c._request( + "PUT", + f"/v1/workspaces/{_seg(workspace_id)}/connectors/{_seg(kind)}", + body={"token": token}, + ) + return Connector.from_dict(data) + + def disconnect(self, workspace_id: str, kind: str) -> ConnectorState: + data = self._c._request( + "DELETE", f"/v1/workspaces/{_seg(workspace_id)}/connectors/{_seg(kind)}" + ) + return ConnectorState.from_dict(data) + + +class Images: + """The Dockyard image catalog.""" + + def __init__(self, client: Dock) -> None: + self._c = client + + def list(self) -> List[Image]: + return [Image.from_dict(d) for d in self._c._request("GET", "/v1/images")] diff --git a/src/usedock/errors.py b/src/usedock/errors.py index dd99354..4da8bb2 100644 --- a/src/usedock/errors.py +++ b/src/usedock/errors.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Dict, Type + class DockError(Exception): """An error response from the Dock API, or a failure the SDK detected itself. @@ -14,3 +16,32 @@ def __init__(self, code: str, status: int, message: str) -> None: self.code = code self.status = status self.message = message + + +class ConnectorNotConnectedError(DockError): + """``connector_not_connected`` (409): a requested connector is not connected.""" + + +class ConnectorAdminOnlyError(DockError): + """``connector_admin_only`` (403): only workspace admins can do this with connectors.""" + + +class ImageUnknownError(DockError): + """``image_unknown`` (400): the image is not in the Dockyard catalog.""" + + +class ImageUnavailableError(DockError): + """``image_unavailable`` (400): the image is in the catalog but no host serves it right now.""" + + +_BY_CODE: Dict[str, Type[DockError]] = { + "connector_not_connected": ConnectorNotConnectedError, + "connector_admin_only": ConnectorAdminOnlyError, + "image_unknown": ImageUnknownError, + "image_unavailable": ImageUnavailableError, +} + + +def error_for(code: str, status: int, message: str) -> DockError: + """The typed subclass for a known API code, else a plain ``DockError``.""" + return _BY_CODE.get(code, DockError)(code, status, message) diff --git a/src/usedock/models.py b/src/usedock/models.py index 2256de9..692e0c2 100644 --- a/src/usedock/models.py +++ b/src/usedock/models.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional def _opt_int(data: Dict[str, Any], key: str) -> Optional[int]: @@ -20,6 +20,11 @@ def _opt_str(data: Dict[str, Any], key: str) -> Optional[str]: return value if isinstance(value, str) else None +def _str_list(data: Dict[str, Any], key: str) -> List[str]: + value = data.get(key) + return [v for v in value if isinstance(v, str)] if isinstance(value, list) else [] + + @dataclass class DockInfo: """A dock. Only ``id``, ``name`` and ``state`` are guaranteed; the full @@ -34,6 +39,7 @@ class DockInfo: cpus: Optional[int] = None memory_mib: Optional[int] = None disk_gib: Optional[int] = None + connectors: List[str] = field(default_factory=list) raw: Dict[str, Any] = field(default_factory=dict, repr=False) @classmethod @@ -48,6 +54,7 @@ def from_dict(cls, data: Dict[str, Any]) -> DockInfo: cpus=_opt_int(data, "cpus"), memory_mib=_opt_int(data, "memoryMiB"), disk_gib=_opt_int(data, "diskGiB"), + connectors=_str_list(data, "connectors"), raw=data, ) @@ -103,3 +110,71 @@ def from_dict(cls, data: Dict[str, Any]) -> Operation: target_id=_opt_str(data, "targetId"), raw=data, ) + + +@dataclass +class Connector: + """A workspace connector. ``status`` is ``connected``, ``not_connected`` or + ``reauth_required``; ``read_only`` kinds (github, model-keys) cannot be connected by token.""" + + kind: str + type: str + status: str + account_label: Optional[str] = None + scopes: List[str] = field(default_factory=list) + oauth_available: bool = False + read_only: bool = False + updated_at: Optional[str] = None + raw: Dict[str, Any] = field(default_factory=dict, repr=False) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> Connector: + return cls( + kind=_str(data, "kind"), + type=_str(data, "type"), + status=_str(data, "status"), + account_label=_opt_str(data, "accountLabel"), + scopes=_str_list(data, "scopes"), + oauth_available=bool(data.get("oauthAvailable", False)), + read_only=bool(data.get("readOnly", False)), + updated_at=_opt_str(data, "updatedAt"), + raw=data, + ) + + +@dataclass +class ConnectorState: + """The ``{kind, status}`` answer of ``connectors.disconnect``.""" + + kind: str + status: str + raw: Dict[str, Any] = field(default_factory=dict, repr=False) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> ConnectorState: + return cls(kind=_str(data, "kind"), status=_str(data, "status"), raw=data) + + +@dataclass +class Image: + """A Dockyard catalog image; ``available`` is whether a host serves it right now.""" + + name: str + description: str = "" + arch: List[str] = field(default_factory=list) + size_mib: Optional[int] = None + contents: str = "" + available: bool = False + raw: Dict[str, Any] = field(default_factory=dict, repr=False) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> Image: + return cls( + name=_str(data, "name"), + description=_str(data, "description"), + arch=_str_list(data, "arch"), + size_mib=_opt_int(data, "sizeMiB"), + contents=_str(data, "contents"), + available=bool(data.get("available", False)), + raw=data, + ) diff --git a/tests/test_client.py b/tests/test_client.py index cdff6b4..91c173b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -12,7 +12,17 @@ import pytest from bash_resolver import resolve_bash -from usedock import MAX_WRITE_BYTES, UNSET, Dock, DockError, DockState +from usedock import ( + MAX_WRITE_BYTES, + UNSET, + ConnectorAdminOnlyError, + ConnectorNotConnectedError, + Dock, + DockError, + DockState, + ImageUnavailableError, + ImageUnknownError, +) from usedock.client import MAX_COMMAND_BYTES, shell_quote DOCK = {"id": "d1", "name": "n", "state": "ready", "cpus": 2, "extraUnknown": {"a": 1}} @@ -483,3 +493,93 @@ def test_missing_scenarios_skip_by_default_but_fail_when_required(tmp_path: Path assert default.returncode == 0 and "skipped" in default.stdout required = _run_scenarios(True, tmp_path) assert required.returncode != 0 and "DOCK_SCENARIOS_REQUIRED" in required.stdout + + +CONNECTOR = { + "kind": "linear", + "type": "tool", + "status": "connected", + "accountLabel": "Acme", + "scopes": ["read"], + "oauthAvailable": False, + "readOnly": False, + "updatedAt": "2026-01-01T00:00:00.000Z", +} + + +def test_create_sends_connectors_and_dock_returns_them() -> None: + client, seen = recorder(ok({**DOCK, "connectors": ["linear", "figma"]}, 201)) + dock = client.docks.create(workspace_id="w", connectors=["linear", "figma"]) + assert body_of(seen[0]) == {"workspaceId": "w", "connectors": ["linear", "figma"]} + assert dock.connectors == ["linear", "figma"] + client, seen = recorder(ok(DOCK, 201)) + assert client.docks.create(workspace_id="w").connectors == [] + assert "connectors" not in body_of(seen[0]) + + +def test_connectors_list() -> None: + client, seen = recorder(ok([CONNECTOR])) + items = client.connectors.list("w 1") + assert (seen[0].method, seen[0].url.raw_path) == ("GET", b"/v1/workspaces/w%201/connectors") + assert items[0].kind == "linear" + assert items[0].status == "connected" + assert items[0].account_label == "Acme" + assert items[0].scopes == ["read"] + assert items[0].read_only is False + assert items[0].raw["type"] == "tool" + + +def test_connectors_connect_and_disconnect() -> None: + client, seen = recorder(ok(CONNECTOR)) + connector = client.connectors.connect("w", "linear", "lin_key") + assert (seen[0].method, seen[0].url.path) == ("PUT", "/v1/workspaces/w/connectors/linear") + assert body_of(seen[0]) == {"token": "lin_key"} + assert connector.status == "connected" + client, seen = recorder(ok({"kind": "linear", "status": "not_connected"})) + state = client.connectors.disconnect("w", "linear") + assert (seen[0].method, seen[0].url.path) == ("DELETE", "/v1/workspaces/w/connectors/linear") + assert (state.kind, state.status) == ("linear", "not_connected") + + +def test_images_list() -> None: + image = { + "name": "node-22", + "description": "d", + "arch": ["amd64"], + "sizeMiB": 2048, + "contents": "c", + "available": True, + } + client, seen = recorder(ok([image])) + images = client.images.list() + assert (seen[0].method, seen[0].url.path) == ("GET", "/v1/images") + assert images[0].name == "node-22" + assert images[0].arch == ["amd64"] + assert images[0].size_mib == 2048 + assert images[0].available is True + + +@pytest.mark.parametrize( + "code,status,cls", + [ + ("connector_not_connected", 409, ConnectorNotConnectedError), + ("connector_admin_only", 403, ConnectorAdminOnlyError), + ("image_unknown", 400, ImageUnknownError), + ("image_unavailable", 400, ImageUnavailableError), + ], +) +def test_new_error_codes_raise_typed_errors(code: str, status: int, cls: type) -> None: + client, _ = recorder(httpx.Response(status, json={"ok": False, "code": code, "message": "m"})) + with pytest.raises(cls) as info: + client.docks.create(workspace_id="w") + assert isinstance(info.value, DockError) + assert (info.value.code, info.value.status) == (code, status) + + +def test_other_codes_stay_plain_dock_error() -> None: + client, _ = recorder( + httpx.Response(404, json={"ok": False, "code": "dock_not_found", "message": "m"}) + ) + with pytest.raises(DockError) as info: + client.docks.get("d") + assert type(info.value) is DockError diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index a935807..2602fe3 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -71,7 +71,7 @@ def _handle(self) -> None: self.end_headers() self.wfile.write(data) - do_GET = do_POST = do_PATCH = do_DELETE = _handle # noqa: N815 + do_GET = do_POST = do_PATCH = do_PUT = do_DELETE = _handle # noqa: N815 def log_message(self, *args: Any) -> None: pass @@ -129,10 +129,21 @@ def call(client: Dock, ops: Dict[str, Any], op: str, args: Dict[str, Any]) -> Tu """Call the SDK for an op; return (status, data).""" args = dict(args) kwargs = {ARG_NAMES.get(k, k): v for k, v in args.items() if k not in ("dockId", "path")} + if op.startswith("connectors."): + kwargs = {} dock_id = args.get("dockId") status = 200 if op in ops["http"]: status = ops["http"][op]["status"] + if op == "images.list": + return status, [i.raw for i in client.images.list()] + if op == "connectors.list": + return status, [c.raw for c in client.connectors.list(args["workspaceId"])] + if op == "connectors.connect": + connector = client.connectors.connect(args["workspaceId"], args["kind"], args["token"]) + return status, connector.raw + if op == "connectors.disconnect": + return status, client.connectors.disconnect(args["workspaceId"], args["kind"]).raw if op == "docks.create": return status, client.docks.create(**kwargs).raw if op == "docks.list": From 35d21d0f358762af9b2eb8d5397d6649795288f3 Mon Sep 17 00:00:00 2001 From: parnexcodes Date: Sun, 11 Oct 2026 06:42:29 +0530 Subject: [PATCH 2/2] feat(sdk): raise ConnectorNotFoundError for connector_not_found Disconnecting a connector that is not connected answers 404 connector_not_found; the JS and Go SDKs expose that code, so Python gets a typed subclass too. Also fix the admin wording: connecting needs an organization admin, not a workspace admin. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++++--- src/usedock/__init__.py | 2 ++ src/usedock/errors.py | 8 +++++++- tests/test_client.py | 12 ++++++++++++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b79c389..b1851ba 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,8 @@ File helpers raise `DockError` (status 0) with these codes: ## Connectors and images -Connect a workspace to a tool once (workspace admins only), then attach it to -docks by kind. Tokens are never returned. +Connect a workspace to a tool once (organization admins only; members get +`forbidden`), then attach it to docks by kind. Tokens are never returned. ```python client.connectors.connect(workspace_id, "linear", token="lin_api_...") @@ -104,7 +104,8 @@ except DockError as err: Some codes raise a subclass of `DockError`, so you can catch them directly: `ConnectorNotConnectedError` (`connector_not_connected`, 409), -`ConnectorAdminOnlyError` (`connector_admin_only`, 403), `ImageUnknownError` +`ConnectorAdminOnlyError` (`connector_admin_only`, 403), +`ConnectorNotFoundError` (`connector_not_found`, 404), `ImageUnknownError` (`image_unknown`, 400) and `ImageUnavailableError` (`image_unavailable`, 400). ## Configuration diff --git a/src/usedock/__init__.py b/src/usedock/__init__.py index 2bf97a2..481608f 100644 --- a/src/usedock/__init__.py +++ b/src/usedock/__init__.py @@ -2,6 +2,7 @@ from .errors import ( ConnectorAdminOnlyError, ConnectorNotConnectedError, + ConnectorNotFoundError, DockError, ImageUnavailableError, ImageUnknownError, @@ -22,6 +23,7 @@ "Connector", "ConnectorAdminOnlyError", "ConnectorNotConnectedError", + "ConnectorNotFoundError", "ConnectorState", "Dock", "DockError", diff --git a/src/usedock/errors.py b/src/usedock/errors.py index 4da8bb2..f6450f1 100644 --- a/src/usedock/errors.py +++ b/src/usedock/errors.py @@ -23,7 +23,12 @@ class ConnectorNotConnectedError(DockError): class ConnectorAdminOnlyError(DockError): - """``connector_admin_only`` (403): only workspace admins can do this with connectors.""" + """``connector_admin_only`` (403): only organization admins can launch or open docks + that use connectors.""" + + +class ConnectorNotFoundError(DockError): + """``connector_not_found`` (404): disconnecting a connector that is not connected.""" class ImageUnknownError(DockError): @@ -37,6 +42,7 @@ class ImageUnavailableError(DockError): _BY_CODE: Dict[str, Type[DockError]] = { "connector_not_connected": ConnectorNotConnectedError, "connector_admin_only": ConnectorAdminOnlyError, + "connector_not_found": ConnectorNotFoundError, "image_unknown": ImageUnknownError, "image_unavailable": ImageUnavailableError, } diff --git a/tests/test_client.py b/tests/test_client.py index 91c173b..c2d9c52 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -17,6 +17,7 @@ UNSET, ConnectorAdminOnlyError, ConnectorNotConnectedError, + ConnectorNotFoundError, Dock, DockError, DockState, @@ -576,6 +577,17 @@ def test_new_error_codes_raise_typed_errors(code: str, status: int, cls: type) - assert (info.value.code, info.value.status) == (code, status) +def test_disconnect_not_connected_raises_connector_not_found() -> None: + client, _ = recorder( + httpx.Response( + 404, json={"ok": False, "code": "connector_not_found", "message": "Connector not found"} + ) + ) + with pytest.raises(ConnectorNotFoundError) as info: + client.connectors.disconnect("w", "slack") + assert (info.value.code, info.value.status) == ("connector_not_found", 404) + + def test_other_codes_stay_plain_dock_error() -> None: client, _ = recorder( httpx.Response(404, json={"ok": False, "code": "dock_not_found", "message": "m"})