Skip to content

Commit 1cc36fc

Browse files
nderjungdanielvallance
authored andcommitted
docs: Document the SDK
Written around the two decisions a reader trips over first: that a name is only unique within a metro, so addressing one across metros can be ambiguous on purpose, and that single-resource operations hand back a chainable handle rather than a coroutine. Signed-off-by: Alexander Jung <alex@unikraft.com>
1 parent 70ce8eb commit 1cc36fc

1 file changed

Lines changed: 304 additions & 5 deletions

File tree

‎README.md‎

Lines changed: 304 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,329 @@
33
The official Python SDK for the [Unikraft Cloud](https://unikraft.com) Platform and
44
control-plane APIs.
55

6+
It has two layers. The **idiomatic** layer is what you reach for: envelope-free results,
7+
automatic pagination, chainable references, and multi-metro fan-out. The **plumbing**
8+
layer underneath mirrors the OpenAPI specification exactly, and stays available for
9+
anything the idiomatic layer does not cover yet.
10+
11+
The SDK is async-only.
12+
613
## Installation
714

815
```sh
916
pip install unikraft-cloud
1017
```
1118

12-
## Usage
19+
Requires Python 3.10 or newer.
20+
21+
## Quickstart
1322

1423
```python
1524
import asyncio
16-
import os
1725

1826
from unikraft_cloud import UnikraftCloud
1927

2028

2129
async def main() -> None:
22-
async with UnikraftCloud(token=os.environ["UKC_TOKEN"]) as ukc:
23-
async for instance in ukc.instances.list(details=True):
24-
print(instance.metro, instance.name, instance.state)
30+
async with UnikraftCloud() as ukc: # token from UKC_TOKEN
31+
instance = await ukc.metro("fra").instances.create(
32+
image="nginx:latest", memory_mb=256, autostart=True
33+
)
34+
print(instance.name, instance.uuid, instance.metro)
2535

2636

2737
asyncio.run(main())
2838
```
2939

40+
The client owns a connection pool, so close it when you are done — either with
41+
`async with`, or by awaiting `ukc.aclose()`.
42+
43+
## Configuration
44+
45+
```python
46+
ukc = UnikraftCloud(
47+
token="...", # falls back to UKC_TOKEN
48+
metro="fra", # falls back to UKC_METRO; omit to cover every metro
49+
)
50+
```
51+
52+
| Argument | Purpose |
53+
| --- | --- |
54+
| `token` | Bearer token. Falls back to `UKC_TOKEN`. |
55+
| `metro` | The metro operations default to, or a full `http(s)://` URL for a staging or self-hosted deployment. Falls back to `UKC_METRO`. A code leaves the other metros reachable with `ukc.metro(...)`; a URL pins the client to that endpoint, and naming another metro then raises. |
56+
| `metros` | The metros operations cover by default: `"all"`, one metro, or a list. Creating a resource needs exactly one, so name a metro somewhere when you create. |
57+
| `base_url` | Explicit platform API base URL. It settles where requests go, so it overrides `metro` and `UKC_METRO` alike, and pins the client to that one endpoint. |
58+
| `control_plane_url` | Override the control-plane API base URL. |
59+
| `headers` | Extra headers sent with every request. |
60+
| `user_agent` | Override the default User-Agent. |
61+
| `http` | An `httpx.AsyncClient` to send through. Supplying one makes its lifetime yours. |
62+
| `transport` | An `httpx.AsyncBaseTransport`, chiefly for testing with `httpx.MockTransport`. |
63+
| `trust_env` | Honour `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`. Defaults to `True`. |
64+
| `timeout` | Timeout for every request. Omitted, an injected `http` client keeps its own; otherwise the default bounds connecting but not reading, because `wait` operations block for as long as you asked. |
65+
66+
## Metros
67+
68+
The platform API is metro-scoped. By default the client is **account-wide**: reads ask
69+
every metro the account can reach and merge the answers as they arrive, and each result
70+
carries the metro it came from.
71+
72+
```python
73+
# Every metro, merged as the pages arrive. Await the listing instead for a list.
74+
async for inst in ukc.instances.list(details=True):
75+
print(inst.metro, inst.name, inst.state)
76+
every = await ukc.instances.list(details=True) # one pass each: call list() again for more
77+
78+
# One metro. Because it is known, no lookup is needed.
79+
await ukc.metro("fra").instances.get(name="web").suspend()
80+
81+
# Several metros, for one call or for a whole client.
82+
async for inst in ukc.instances.list(metros=["fra", "dal"]):
83+
...
84+
scoped = ukc.metros(["fra", "dal"])
85+
86+
# A listing you stop reading holds a page of every metro, so close it.
87+
async with ukc.instances.list() as listing:
88+
async for inst in listing:
89+
break
90+
91+
# What the account can reach, as the control plane reports it.
92+
for endpoint in await ukc.available_metros():
93+
print(endpoint.metro, endpoint.base_url)
94+
```
95+
96+
Naming metros is also how you skip metro discovery, which is otherwise one extra request
97+
per client.
98+
99+
## References
100+
101+
A resource is addressed by `name` or `uuid` — one or the other, because the API validates
102+
whichever field it is given.
103+
104+
```python
105+
await ukc.instances.get(name="web")
106+
await ukc.instances.get(uuid="550e8400-e29b-41d4-a716-446655440000")
107+
```
108+
109+
A name is only unique **within** a metro, so the same name can exist in several. Add
110+
`metro=` to say which you mean, which also saves a lookup:
111+
112+
```python
113+
await ukc.instances.get(name="web", metro="fra")
114+
```
115+
116+
Without it, and with more than one metro in scope, the SDK asks every metro. If the name
117+
matches in several it raises `AmbiguousRefError` rather than picking one — with the
118+
matches attached, so recovering costs no further requests:
119+
120+
```python
121+
from unikraft_cloud import AmbiguousRefError
122+
123+
try:
124+
await ukc.instances.get(name="web")
125+
except AmbiguousRefError as err:
126+
print(err.metros) # ("fra", "dal")
127+
print([m.uuid for m in err.matches])
128+
```
129+
130+
To act on all of them deliberately, use `each()`:
131+
132+
```python
133+
await ukc.instances.each(name="web").suspend() # in every metro that has one
134+
```
135+
136+
Bulk operations take a sequence of references, as `Ref` objects, plain dicts, or names:
137+
138+
```python
139+
from unikraft_cloud import Ref
140+
141+
await ukc.instances.delete([Ref(uuid="a"), {"name": "b"}, "web"])
142+
```
143+
144+
An operation the API could only carry out in part raises, naming what failed. What did
145+
succeed is on `err.results`, so a partial failure costs nothing already done:
146+
147+
```python
148+
from unikraft_cloud import NotFoundError
149+
150+
try:
151+
await ukc.instances.delete(["web", "gone"])
152+
except NotFoundError as err:
153+
print([deleted.name for deleted in err.results]) # ["web"]
154+
```
155+
156+
## Chainable handles
157+
158+
Single-resource operations return a **handle** rather than a coroutine, so they compose.
159+
A handle is awaitable too, so awaiting one gives you the resource:
160+
161+
```python
162+
inst = await ukc.instances.get(name="web") # the instance
163+
await ukc.instances.get(name="web").suspend() # the suspend
164+
165+
logs = await (
166+
ukc.metro("fra")
167+
.instances.create(image="nginx:latest")
168+
.wait(state="running", timeout_seconds=30)
169+
.logs(offset=-4096)
170+
)
171+
```
172+
173+
Nothing is sent until a handle is awaited or an operation is chained onto it. With one
174+
metro in scope, `get(name=...).suspend()` is a single request; when the scope spans
175+
metros, the instance is located first so the operation reaches the metro that holds it.
176+
177+
A handle that is dropped without ever being awaited emits a `RuntimeWarning`: unlike a
178+
forgotten `await` on a coroutine, nothing else would tell you no request was sent.
179+
180+
A handle is awaitable but is not a coroutine, so `asyncio.gather(...)` takes one while
181+
`asyncio.create_task(...)` does not; wrap it in `asyncio.ensure_future(...)` for a task.
182+
183+
## Updating
184+
185+
Properties are keyword arguments. A value sets the property, `REMOVE` clears it out, and
186+
anything omitted is left alone — all in one request.
187+
188+
```python
189+
from unikraft_cloud import REMOVE
190+
191+
await ukc.instances.get(name="web").update(memory_mb=512, vcpus=2, autokill=REMOVE)
192+
```
193+
194+
When `set` is not what you mean — merging into a property, or removing individual members
195+
— stage the operations and apply them together:
196+
197+
```python
198+
await (
199+
ukc.instances.get(name="web")
200+
.edit()
201+
.set(memory_mb=512)
202+
.add(env={"LOG_LEVEL": "debug"}, tags=["prod"])
203+
.delete(env=["OLD_FLAG"])
204+
.apply()
205+
)
206+
```
207+
208+
`apply()` returns a handle, so the chain continues. For anything keyword arguments cannot
209+
express, `patch()` takes the raw triples.
210+
211+
## Errors
212+
213+
Every failure is an `UnikraftCloudError`, so one `except` catches the lot. Its `kind`
214+
says which layer failed (`"http"`, `"network"`, `"parse"` or `"fanout"`) and `status`
215+
carries the HTTP status where there was one.
216+
217+
```python
218+
from unikraft_cloud import NotFoundError, UnikraftCloudError
219+
220+
try:
221+
await ukc.instances.get(name="web")
222+
except NotFoundError:
223+
...
224+
except UnikraftCloudError as err:
225+
print(err.kind, err.status, err.errors)
226+
```
227+
228+
`AuthenticationError` (401/403), `NotFoundError` (404), `AlreadyExistsError` (409),
229+
`RateLimitError` (429) and `ServerError` (5xx) are raised for the statuses they name, and
230+
all subclass `UnikraftCloudError`. The API reports some failures inside an otherwise-200
231+
envelope, per item; those carry the API's own code on `err.errors[n].code` and are raised
232+
with the status that says the same thing.
233+
234+
A `wait()` that runs out of time raises `WaitTimeoutError`, which is also a builtin
235+
`TimeoutError`, and carries the state the API last saw:
236+
237+
```python
238+
try:
239+
await ukc.instances.get(name="web").wait(state="running", timeout_seconds=30)
240+
except TimeoutError as err:
241+
print(err.state) # e.g. "starting"
242+
```
243+
244+
When the API attaches a warning to an answer -- a deprecated field, say -- the SDK
245+
issues it as a Python `UnikraftCloudWarning`, so the standard `warnings` filters
246+
apply.
247+
248+
A multi-metro operation that only partly succeeded raises `MetroFanoutError`. An
249+
iteration yields everything the healthy metros returned *before* raising, so a partial
250+
failure never costs you the whole answer; operations that cannot yield as they go attach
251+
what did arrive to `err.results`.
252+
253+
```python
254+
from unikraft_cloud import MetroFanoutError
255+
256+
try:
257+
async for inst in ukc.instances.list():
258+
...
259+
except MetroFanoutError as err:
260+
print([failure.metro for failure in err.failures])
261+
```
262+
263+
## Resources
264+
265+
`instances`, `volumes`, `services`, `certificates` and `users` hang off any scope —
266+
`ukc`, `ukc.metro("fra")` or `ukc.metros([...])`.
267+
268+
Creating one takes the properties the API describes as keyword arguments, and a property
269+
it does not have is a `TypeError` rather than a field the server quietly ignores.
270+
271+
```python
272+
await ukc.volumes.get(name="data").attach(to="web", at="/data")
273+
await ukc.services.get(name="web").update(hard_limit=10)
274+
await ukc.certificates.get(name="tls").update(chain=chain_pem, pkey=key_pem)
275+
276+
for quota in await ukc.users.quotas():
277+
print(quota.metro, quota.used, quota.hard)
278+
```
279+
280+
## The plumbing layer
281+
282+
Every operation in the specification is available raw, returning the response envelope
283+
untouched. Each client talks to exactly one metro, and a single call can be redirected
284+
with `base_url=`.
285+
286+
```python
287+
res = await ukc.api.platform.instances.get_instances(count=10)
288+
print(res.status, res.op_time_us, res.data.instances)
289+
290+
await ukc.api.controlplane.metros.list_metros()
291+
292+
# Or per resource, alongside its idiomatic client.
293+
await ukc.instances.api.get_instance_metrics(uuid=["..."])
294+
```
295+
296+
It can also be used on its own, without the idiomatic layer:
297+
298+
```python
299+
from unikraft_cloud import ApiClientConfig
300+
from unikraft_cloud.api.platform import PlatformApi
301+
302+
config = ApiClientConfig(base_url="https://api.fra.unikraft.cloud", token=token)
303+
async with PlatformApi(config) as api:
304+
res = await api.instances.get_instances(count=10)
305+
```
306+
307+
## Examples
308+
309+
- [`examples/quickstart.py`](examples/quickstart.py) — create, wait, read logs, list, suspend, delete
310+
- [`examples/update.py`](examples/update.py) — patch objects and the staged editor
311+
- [`examples/plumbing.py`](examples/plumbing.py) — the raw API on its own
312+
313+
## Development
314+
315+
The `api/platform` and `api/controlplane` packages are generated from the OpenAPI
316+
specification by [`openapi-gen`](https://github.com/unikraft-cloud) using the templates in
317+
[`templates/`](templates). Everything else is hand-written. Files ending in `_gen.py` are
318+
never edited by hand.
319+
320+
```sh
321+
make generate # regenerate both plumbing clients from the specs
322+
make lint # ruff check + format --check
323+
make typecheck # mypy
324+
make test # pytest
325+
```
326+
327+
The test suite runs entirely offline through `httpx.MockTransport`.
328+
30329
## Licence
31330

32331
BSD-3-Clause. See [`LICENSE.md`](LICENSE.md).

0 commit comments

Comments
 (0)