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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,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`.
`resume`, `branch`, `delete`, `ports.list` / `ports.expose` / `ports.remove`, and `files.write` / `files.read` / `files.read_bytes` / `files.list`.
`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`.

Expand Down Expand Up @@ -73,6 +73,21 @@ same statuses for the codes they share (`file_not_utf8` is Python only):
| `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 |

## Ports

New ports are private: the URL needs a dock-bound API key or an organization
member's sign-in. Pass `visibility="public"` to open it to anyone with the URL.

```python
port = client.docks.ports.expose(dock.id, 3000) # Port(port, visibility="private", url, created_at)
client.docks.ports.expose(dock.id, 3000, visibility="public") # exposing again changes it
print(client.docks.ports.list(dock.id))
client.docks.ports.remove(dock.id, 3000)
```

`url` is `None` while hosted ports are off. Leaving `visibility` out of a repeat
`expose` keeps the port's current visibility.

## Connectors and images

Connect a workspace to a tool once (organization admins only; members get
Expand Down
2 changes: 2 additions & 0 deletions src/usedock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
ExecResult,
Image,
Operation,
Port,
)

__all__ = [
Expand All @@ -34,4 +35,5 @@
"ImageUnavailableError",
"ImageUnknownError",
"Operation",
"Port",
]
38 changes: 37 additions & 1 deletion src/usedock/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@
import httpx

from .errors import DockError, error_for
from .models import Connector, ConnectorState, DockInfo, DockState, ExecResult, Image, Operation
from .models import (
Connector,
ConnectorState,
DockInfo,
DockState,
ExecResult,
Image,
Operation,
Port,
)

DEFAULT_BASE_URL = "https://api.usedock.io"

Expand Down Expand Up @@ -139,6 +148,7 @@ class Docks:
def __init__(self, client: Dock) -> None:
self._c = client
self.files = Files(self)
self.ports = Ports(self)

def create(
self,
Expand Down Expand Up @@ -304,6 +314,32 @@ def delete(self, dock_id: str) -> Operation:
return Operation.from_dict(data["operation"])


class Ports:
"""Exposed dock ports. New ports are ``private``; ``public`` opens the URL to anyone."""

def __init__(self, docks: Docks) -> None:
self._c = docks._c

def list(self, dock_id: str) -> List[Port]:
data = self._c._request("GET", f"/v1/docks/{_seg(dock_id)}/ports")
return [Port.from_dict(d) for d in data]

def expose(self, dock_id: str, port: int, *, visibility: Optional[str] = None) -> Port:
"""Expose ``port`` (``visibility`` ``"public"`` or ``"private"``, default private for a
new port). Exposing it again returns the same URL; passing ``visibility`` then
changes it, and omitting it keeps the current one."""
data = self._c._request(
"POST",
f"/v1/docks/{_seg(dock_id)}/ports",
body=_body(port=port, visibility=visibility),
)
return Port.from_dict(data)

def remove(self, dock_id: str, port: int) -> None:
"""Unexpose ``port``; raises ``port_not_found`` (404) when it is not exposed."""
self._c._request("DELETE", f"/v1/docks/{_seg(dock_id)}/ports/{int(port)}")


class Files:
"""File helpers. The API has no file routes, so each call is one ``exec``."""

Expand Down
22 changes: 22 additions & 0 deletions src/usedock/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,25 @@ def from_dict(cls, data: Dict[str, Any]) -> Image:
available=bool(data.get("available", False)),
raw=data,
)


@dataclass
class Port:
"""An exposed dock port. ``visibility`` is ``public`` or ``private``; ``url`` is
``None`` while hosted ports are off on the deployment."""

port: int
visibility: str
url: Optional[str] = None
created_at: Optional[str] = None
raw: Dict[str, Any] = field(default_factory=dict, repr=False)

@classmethod
def from_dict(cls, data: Dict[str, Any]) -> Port:
return cls(
port=_opt_int(data, "port") or 0,
visibility=_str(data, "visibility"),
url=_opt_str(data, "url"),
created_at=_opt_str(data, "createdAt"),
raw=data,
)
49 changes: 49 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,55 @@ def test_connectors_connect_and_disconnect() -> None:
assert (state.kind, state.status) == ("linear", "not_connected")


PORT = {
"port": 3000,
"visibility": "private",
"url": "https://p.example",
"createdAt": "2026-01-01T00:00:00Z",
"extraUnknown": 1,
}


def test_ports_list() -> None:
client, seen = recorder(ok([PORT]))
ports = client.docks.ports.list("d 1")
assert (seen[0].method, seen[0].url.raw_path) == ("GET", b"/v1/docks/d%201/ports")
assert ports[0].port == 3000
assert ports[0].visibility == "private"
assert ports[0].url == "https://p.example"
assert ports[0].raw["extraUnknown"] == 1


def test_ports_expose_sends_visibility() -> None:
client, seen = recorder(ok({**PORT, "visibility": "public"}, 201))
port = client.docks.ports.expose("d1", 3000, visibility="public")
assert (seen[0].method, seen[0].url.path) == ("POST", "/v1/docks/d1/ports")
assert body_of(seen[0]) == {"port": 3000, "visibility": "public"}
assert port.visibility == "public"


def test_ports_expose_omits_visibility_by_default() -> None:
client, seen = recorder(ok({**PORT, "url": None}, 201))
port = client.docks.ports.expose("d1", 3000)
assert body_of(seen[0]) == {"port": 3000}
assert port.url is None


def test_ports_remove() -> None:
client, seen = recorder(ok({"deleted": True}))
assert client.docks.ports.remove("d1", 3000) is None
assert (seen[0].method, seen[0].url.path) == ("DELETE", "/v1/docks/d1/ports/3000")


def test_ports_remove_not_exposed() -> None:
client, _ = recorder(
httpx.Response(404, json={"ok": False, "code": "port_not_found", "message": "no"})
)
with pytest.raises(DockError) as info:
client.docks.ports.remove("d1", 1)
assert (info.value.code, info.value.status) == ("port_not_found", 404)


def test_images_list() -> None:
image = {
"name": "node-22",
Expand Down
Loading