Skip to content
Merged
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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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 (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_...")
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
Expand All @@ -80,6 +102,12 @@ 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),
`ConnectorNotFoundError` (`connector_not_found`, 404), `ImageUnknownError`
(`image_unknown`, 400) and `ImageUnavailableError` (`image_unavailable`, 400).

## Configuration

| Variable | Meaning | Default |
Expand Down
2 changes: 1 addition & 1 deletion SCENARIOS_REF
Original file line number Diff line number Diff line change
@@ -1 +1 @@
7b86b8de18c838a2d1e5a2de7e15b999c9799d4f
05d0182f83f3f7675567cc812c7801659f9cd3b6
27 changes: 25 additions & 2 deletions src/usedock/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
from .client import MAX_WRITE_BYTES, UNSET, Dock
from .errors import DockError
from .models import DockInfo, DockState, ExecResult, Operation
from .errors import (
ConnectorAdminOnlyError,
ConnectorNotConnectedError,
ConnectorNotFoundError,
DockError,
ImageUnavailableError,
ImageUnknownError,
)
from .models import (
Connector,
ConnectorState,
DockInfo,
DockState,
ExecResult,
Image,
Operation,
)

__all__ = [
"MAX_WRITE_BYTES",
"UNSET",
"Connector",
"ConnectorAdminOnlyError",
"ConnectorNotConnectedError",
"ConnectorNotFoundError",
"ConnectorState",
"Dock",
"DockError",
"DockInfo",
"DockState",
"ExecResult",
"Image",
"ImageUnavailableError",
"ImageUnknownError",
"Operation",
]
48 changes: 44 additions & 4 deletions src/usedock/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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),
Expand All @@ -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,
Expand All @@ -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,
),
Expand Down Expand Up @@ -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")]
37 changes: 37 additions & 0 deletions src/usedock/errors.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -14,3 +16,38 @@ 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 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):
"""``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,
"connector_not_found": ConnectorNotFoundError,
"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)
77 changes: 76 additions & 1 deletion src/usedock/models.py
Original file line number Diff line number Diff line change
@@ -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]:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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,
)

Expand Down Expand Up @@ -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,
)
Loading
Loading