diff --git a/README.md b/README.md index 83b22d4..a70303f 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -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 diff --git a/src/usedock/__init__.py b/src/usedock/__init__.py index 481608f..80ed42f 100644 --- a/src/usedock/__init__.py +++ b/src/usedock/__init__.py @@ -15,6 +15,7 @@ ExecResult, Image, Operation, + Port, ) __all__ = [ @@ -34,4 +35,5 @@ "ImageUnavailableError", "ImageUnknownError", "Operation", + "Port", ] diff --git a/src/usedock/client.py b/src/usedock/client.py index 2bee5f3..dd856f7 100644 --- a/src/usedock/client.py +++ b/src/usedock/client.py @@ -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" @@ -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, @@ -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``.""" diff --git a/src/usedock/models.py b/src/usedock/models.py index 692e0c2..87f5893 100644 --- a/src/usedock/models.py +++ b/src/usedock/models.py @@ -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, + ) diff --git a/tests/test_client.py b/tests/test_client.py index caa3cc2..545ec16 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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",